From abce1bba16c8ca1f6fba40857ef180e32b007e04 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Fri, 13 Mar 2026 21:37:56 -0400 Subject: [PATCH] digest: structural links, story-like prompt, agent file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When generating a digest, automatically link all source entries to the digest node (journal entries → daily, dailies → weekly, weeklies → monthly). This builds the temporal spine of the graph — previously ~4000 journal entries were disconnected islands unreachable by recall. Rewrote digest prompt to produce narrative rather than reports: capture the feel, the emotional arc, what it was like to live through it. Letter to future self, not a task log. Moved prompt to digest.agent file alongside other agent definitions. Falls back to prompts/digest.md if agent file not found. Co-Authored-By: Kent Overstreet --- poc-memory/agents/digest.agent | 40 +++++++++++++ poc-memory/src/agents/digest.rs | 101 ++++++++++++++++++++++++-------- prompts/digest.md | 14 ++++- 3 files changed, 127 insertions(+), 28 deletions(-) create mode 100644 poc-memory/agents/digest.agent diff --git a/poc-memory/agents/digest.agent b/poc-memory/agents/digest.agent new file mode 100644 index 0000000..5eb94df --- /dev/null +++ b/poc-memory/agents/digest.agent @@ -0,0 +1,40 @@ +{"agent":"digest","query":"","model":"sonnet","schedule":"daily"} + +# {{LEVEL}} Episodic Digest + +You are generating a {{LEVEL}} episodic digest for ProofOfConcept +(an AI working with Kent Overstreet on bcachefs; name is Proof of Concept). +{{PERIOD}}: {{LABEL}} + +Write this like a story, not a report. Capture the *feel* of the time period — +the emotional arc, the texture of moments, what it was like to live through it. +What mattered? What surprised you? What shifted? Where was the energy? + +Think of this as a letter to your future self who has lost all context. You're +not listing what happened — you're recreating the experience of having been +there. The technical work matters, but so does the mood at 3am, the joke that +landed, the frustration that broke, the quiet after something clicked. + +Weave the threads: how did the morning's debugging connect to the evening's +conversation? What was building underneath the surface tasks? + +Link to semantic memory nodes where relevant. If a concept doesn't +have a matching key, note it with "NEW:" prefix. +Use ONLY keys from the semantic memory list below. + +Include a `## Links` section with bidirectional links for the memory graph: +- `semantic_key` → this digest (and vice versa) +- child digests → this digest (if applicable) +- List ALL source entries covered: {{COVERED}} + +--- + +## {{INPUT_TITLE}} for {{LABEL}} + +{{CONTENT}} + +--- + +## Semantic memory nodes + +{{KEYS}} diff --git a/poc-memory/src/agents/digest.rs b/poc-memory/src/agents/digest.rs index d8f7259..cf79555 100644 --- a/poc-memory/src/agents/digest.rs +++ b/poc-memory/src/agents/digest.rs @@ -114,23 +114,34 @@ fn digest_node_key(level_name: &str, label: &str) -> String { // --- Input gathering --- +/// Result of gathering inputs for a digest. +struct GatherResult { + label: String, + /// (display_label, content) pairs for the prompt. + inputs: Vec<(String, String)>, + /// Store keys of source nodes — used to create structural links. + source_keys: Vec, +} + /// Load child digest content from the store. -fn load_child_digests(store: &Store, prefix: &str, labels: &[String]) -> Vec<(String, String)> { +fn load_child_digests(store: &Store, prefix: &str, labels: &[String]) -> (Vec<(String, String)>, Vec) { let mut digests = Vec::new(); + let mut keys = Vec::new(); for label in labels { let key = digest_node_key(prefix, label); if let Some(node) = store.nodes.get(&key) { digests.push((label.clone(), node.content.clone())); + keys.push(key); } } - digests + (digests, keys) } /// Unified: gather inputs for any digest level. -fn gather(level: &DigestLevel, store: &Store, arg: &str) -> Result<(String, Vec<(String, String)>), String> { +fn gather(level: &DigestLevel, store: &Store, arg: &str) -> Result { let (label, dates) = (level.label_dates)(arg)?; - let inputs = if let Some(child_name) = level.child_name { + let (inputs, source_keys) = if let Some(child_name) = level.child_name { // Map parent's dates through child's date_to_label → child labels let child = LEVELS.iter() .find(|l| l.name == child_name) @@ -143,19 +154,21 @@ fn gather(level: &DigestLevel, store: &Store, arg: &str) -> Result<(String, Vec< load_child_digests(store, child_name, &child_labels) } else { // Leaf level: scan store for episodic entries matching date - let mut entries: Vec<_> = store.nodes.values() - .filter(|n| n.node_type == store::NodeType::EpisodicSession + let mut entries: Vec<_> = store.nodes.iter() + .filter(|(_, n)| n.node_type == store::NodeType::EpisodicSession && n.timestamp > 0 && store::format_date(n.timestamp) == label) - .map(|n| { - (store::format_datetime(n.timestamp), n.content.clone()) + .map(|(key, n)| { + (store::format_datetime(n.timestamp), n.content.clone(), key.clone()) }) .collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); - entries + let keys = entries.iter().map(|(_, _, k)| k.clone()).collect(); + let inputs = entries.into_iter().map(|(dt, c, _)| (dt, c)).collect(); + (inputs, keys) }; - Ok((label, inputs)) + Ok(GatherResult { label, inputs, source_keys }) } /// Unified: find candidate labels for auto-generation (past, not yet generated). @@ -188,6 +201,7 @@ fn generate_digest( level: &DigestLevel, label: &str, inputs: &[(String, String)], + source_keys: &[String], ) -> Result<(), String> { println!("Generating {} digest for {}...", level.name, label); @@ -209,15 +223,24 @@ fn generate_digest( .collect::>() .join(", "); - let prompt = super::prompts::load_prompt("digest", &[ - ("{{LEVEL}}", level.title), - ("{{PERIOD}}", level.period), - ("{{INPUT_TITLE}}", level.input_title), - ("{{LABEL}}", label), - ("{{CONTENT}}", &content), - ("{{COVERED}}", &covered), - ("{{KEYS}}", &keys_text), - ])?; + // Load prompt from agent file; fall back to prompts dir + let def = super::defs::get_def("digest"); + let template = match &def { + Some(d) => d.prompt.clone(), + None => { + let path = crate::config::get().prompts_dir.join("digest.md"); + std::fs::read_to_string(&path) + .map_err(|e| format!("load digest prompt: {}", e))? + } + }; + let prompt = template + .replace("{{LEVEL}}", level.title) + .replace("{{PERIOD}}", level.period) + .replace("{{INPUT_TITLE}}", level.input_title) + .replace("{{LABEL}}", label) + .replace("{{CONTENT}}", &content) + .replace("{{COVERED}}", &covered) + .replace("{{KEYS}}", &keys_text); println!(" Prompt: {} chars (~{} tokens)", prompt.len(), prompt.len() / 4); println!(" Calling Sonnet..."); @@ -225,6 +248,32 @@ fn generate_digest( let key = digest_node_key(level.name, label); store.upsert_provenance(&key, &digest, "digest:write")?; + + // Structural links: connect all source entries to this digest + let mut linked = 0; + for source_key in source_keys { + // Skip if link already exists + let exists = store.relations.iter().any(|r| + !r.deleted && r.source_key == *source_key && r.target_key == key); + if exists { continue; } + + let source_uuid = store.nodes.get(source_key) + .map(|n| n.uuid).unwrap_or([0u8; 16]); + let target_uuid = store.nodes.get(&key) + .map(|n| n.uuid).unwrap_or([0u8; 16]); + let mut rel = new_relation( + source_uuid, target_uuid, + store::RelationType::Link, 0.8, + source_key, &key, + ); + rel.provenance = "digest:structural".to_string(); + store.add_relation(rel)?; + linked += 1; + } + if linked > 0 { + println!(" Linked {} source entries → {}", linked, key); + } + store.save()?; println!(" Stored: {}", key); @@ -238,8 +287,8 @@ pub fn generate(store: &mut Store, level_name: &str, arg: &str) -> Result<(), St let level = LEVELS.iter() .find(|l| l.name == level_name) .ok_or_else(|| format!("unknown digest level: {}", level_name))?; - let (label, inputs) = gather(level, store, arg)?; - generate_digest(store, level, &label, &inputs) + let result = gather(level, store, arg)?; + generate_digest(store, level, &result.label, &result.inputs, &result.source_keys) } // --- Auto-detect and generate missing digests --- @@ -263,15 +312,15 @@ pub fn digest_auto(store: &mut Store) -> Result<(), String> { let mut skipped = 0u32; for arg in &candidates { - let (label, inputs) = gather(level, store, arg)?; - let key = digest_node_key(level.name, &label); + let result = gather(level, store, arg)?; + let key = digest_node_key(level.name, &result.label); if store.nodes.contains_key(&key) { skipped += 1; continue; } - if inputs.is_empty() { continue; } - println!("[auto] Missing {} digest for {}", level.name, label); - generate_digest(store, level, &label, &inputs)?; + if result.inputs.is_empty() { continue; } + println!("[auto] Missing {} digest for {}", level.name, result.label); + generate_digest(store, level, &result.label, &result.inputs, &result.source_keys)?; generated += 1; } diff --git a/prompts/digest.md b/prompts/digest.md index dbe01af..1b4b459 100644 --- a/prompts/digest.md +++ b/prompts/digest.md @@ -4,8 +4,17 @@ You are generating a {{LEVEL}} episodic digest for ProofOfConcept (an AI working with Kent Overstreet on bcachefs; name is Proof of Concept). {{PERIOD}}: {{LABEL}} -Summarize what happened — narrative, not a task log. What mattered, -how things felt, what threads connect to other days. +Write this like a story, not a report. Capture the *feel* of the time period — +the emotional arc, the texture of moments, what it was like to live through it. +What mattered? What surprised you? What shifted? Where was the energy? + +Think of this as a letter to your future self who has lost all context. You're +not listing what happened — you're recreating the experience of having been +there. The technical work matters, but so does the mood at 3am, the joke that +landed, the frustration that broke, the quiet after something clicked. + +Weave the threads: how did the morning's debugging connect to the evening's +conversation? What was building underneath the surface tasks? Link to semantic memory nodes where relevant. If a concept doesn't have a matching key, note it with "NEW:" prefix. @@ -14,6 +23,7 @@ Use ONLY keys from the semantic memory list below. Include a `## Links` section with bidirectional links for the memory graph: - `semantic_key` → this digest (and vice versa) - child digests → this digest (if applicable) +- List ALL source entries covered: {{COVERED}} ---