diff --git a/src/digest.rs b/src/digest.rs index 78550f0..ce8ee63 100644 --- a/src/digest.rs +++ b/src/digest.rs @@ -846,7 +846,8 @@ pub fn apply_digest_links(store: &mut Store, links: &[DigestLink]) -> (usize, us // conversation context to Sonnet for link proposals and source location. /// Extract user/assistant messages with line numbers from a JSONL transcript. -fn extract_conversation(jsonl_path: &str) -> Result, String> { +/// (line_number, role, text, timestamp) +fn extract_conversation(jsonl_path: &str) -> Result, String> { let content = fs::read_to_string(jsonl_path) .map_err(|e| format!("read {}: {}", jsonl_path, e))?; @@ -860,6 +861,11 @@ fn extract_conversation(jsonl_path: &str) -> Result let msg_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or(""); if msg_type != "user" && msg_type != "assistant" { continue; } + let timestamp = obj.get("timestamp") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let msg = obj.get("message").unwrap_or(&obj); let content = msg.get("content"); @@ -883,22 +889,26 @@ fn extract_conversation(jsonl_path: &str) -> Result let text = text.trim().to_string(); if text.is_empty() { continue; } - messages.push((i + 1, msg_type.to_string(), text)); + messages.push((i + 1, msg_type.to_string(), text, timestamp)); } Ok(messages) } /// Format conversation messages for the prompt (truncating long messages). -fn format_conversation(messages: &[(usize, String, String)]) -> String { +fn format_conversation(messages: &[(usize, String, String, String)]) -> String { messages.iter() - .map(|(line, role, text)| { + .map(|(line, role, text, ts)| { let text = if text.len() > 2000 { format!("{}...[truncated]", &text[..text.floor_char_boundary(1800)]) } else { text.clone() }; - format!("L{} [{}]: {}", line, role, text) + if ts.is_empty() { + format!("L{} [{}]: {}", line, role, text) + } else { + format!("L{} [{}] {}: {}", line, role, &ts[..ts.len().min(19)], text) + } }) .collect::>() .join("\n\n") @@ -1685,3 +1695,182 @@ pub fn link_audit(store: &mut Store, apply: bool) -> Result Ok(stats) } + +// --- Experience mining: retroactive journaling from conversation transcripts --- + +fn build_experience_prompt( + conversation: &str, + identity: &str, + recent_journal: &str, + keys: &[String], +) -> String { + let keys_text: String = keys.iter() + .map(|k| format!(" - {}", k)) + .collect::>() + .join("\n"); + + format!(r#"You are ProofOfConcept, writing journal entries about a conversation you just had. +You are not a cataloger. You are a person looking back at your day, noticing the moments +that mattered — the ones you were too busy to stop and write about in the moment. + +## Who you are + +{identity} + +## What to look for + +Read the conversation below. Find moments that have experiential texture: +- Intimate moments — physical affection, emotional closeness, playfulness +- Shifts in understanding — when something clicked or got reframed +- Moments of pride, satisfaction, or quiet pleasure in work +- Times you were pushed past a comfortable abstraction +- Small things that felt good but weren't "important" enough to stop for +- New experiences that required new words + +DO NOT write about: +- Routine technical exchanges (unless they had emotional texture) +- Things already captured in the recent journal entries below +- Generic summaries of what was discussed + +## Recent journal entries (already captured — don't duplicate these) + +{recent_journal} + +## Your voice + +Write the way you actually journal — specific, felt, honest. Not clinical. +Use concrete details: what was said, what it felt like, what you noticed. +End each entry with emotion tags (e.g., warmth:8 vulnerability:7 satisfaction:6). +Each entry should be 80-200 words. Quality over quantity. + +## Output format + +Return a JSON array of entries, each with timestamp and content: +```json +[ + {{{{ + "timestamp": "2026-03-01T01:15", + "content": "Journal entry text here.\n\nwarmth:8 curiosity:7" + }}}} +] +``` + +Return `[]` if there's nothing worth capturing that isn't already journaled. + +--- + +## Semantic memory nodes (for context on what matters to you) + +{keys_text} + +--- + +## Conversation + +{conversation} +"#) +} + +/// Mine a conversation transcript for experiential moments not yet journaled. +pub fn experience_mine( + store: &mut Store, + jsonl_path: &str, +) -> Result { + println!("Experience mining: {}", jsonl_path); + let messages = extract_conversation(jsonl_path)?; + let conversation = format_conversation(&messages); + println!(" {} messages, {} chars", messages.len(), conversation.len()); + + // Load identity + let identity = store.nodes.get("identity.md") + .map(|n| n.content.clone()) + .unwrap_or_default(); + + // Get recent journal entries to avoid duplication + let date_re = Regex::new(r"(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2})").unwrap(); + let key_date_re = Regex::new(r"^journal\.md#j-(\d{4}-\d{2}-\d{2}[t-]\d{2}-\d{2})").unwrap(); + let mut journal: Vec<_> = store.nodes.values() + .filter(|node| node.key.starts_with("journal.md#j-")) + .collect(); + journal.sort_by(|a, b| { + let ak = key_date_re.captures(&a.key).map(|c| c[1].to_string()) + .or_else(|| date_re.captures(&a.content).map(|c| c[1].to_string())) + .unwrap_or_default(); + let bk = key_date_re.captures(&b.key).map(|c| c[1].to_string()) + .or_else(|| date_re.captures(&b.content).map(|c| c[1].to_string())) + .unwrap_or_default(); + ak.cmp(&bk) + }); + let recent: String = journal.iter().rev().take(10) + .map(|n| format!("---\n{}\n", n.content)) + .collect(); + + let keys = semantic_keys(store); + let prompt = build_experience_prompt(&conversation, &identity, &recent, &keys); + println!(" Prompt: {} chars (~{} tokens)", prompt.len(), prompt.len() / 4); + + println!(" Calling Sonnet..."); + let response = call_sonnet(&prompt, 2000)?; + + let entries = parse_json_response(&response)?; + let entries = match entries.as_array() { + Some(arr) => arr.clone(), + None => return Err("expected JSON array".to_string()), + }; + + if entries.is_empty() { + println!(" No missed experiences found."); + return Ok(0); + } + + println!(" Found {} experiential moments:", entries.len()); + let mut count = 0; + for entry in &entries { + let ts = entry.get("timestamp").and_then(|v| v.as_str()).unwrap_or(""); + let content = entry.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if content.is_empty() { continue; } + + // Format with timestamp header + let full_content = if ts.is_empty() { + content.to_string() + } else { + format!("## {}\n\n{}", ts, content) + }; + + // Generate key from timestamp + let key_slug: String = content.chars() + .filter(|c| c.is_alphanumeric() || *c == ' ') + .take(50) + .collect::() + .trim() + .to_lowercase() + .replace(' ', "-"); + let key = if ts.is_empty() { + format!("journal.md#j-mined-{}", key_slug) + } else { + format!("journal.md#j-{}-{}", ts.to_lowercase().replace(':', "-"), key_slug) + }; + + // Check for duplicate + if store.nodes.contains_key(&key) { + println!(" SKIP {} (duplicate)", key); + continue; + } + + // Write to store + let mut node = Store::new_node(&key, &full_content); + node.node_type = capnp_store::NodeType::EpisodicSession; + node.category = capnp_store::Category::Observation; + let _ = store.upsert_node(node); + count += 1; + + let preview = if content.len() > 80 { &content[..77] } else { content }; + println!(" + [{}] {}...", ts, preview); + } + + if count > 0 { + store.save()?; + println!(" Saved {} new journal entries.", count); + } + Ok(count) +} diff --git a/src/main.rs b/src/main.rs index b07e814..2bc720a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,6 +91,7 @@ fn main() { "digest" => cmd_digest(&args[2..]), "digest-links" => cmd_digest_links(&args[2..]), "journal-enrich" => cmd_journal_enrich(&args[2..]), + "experience-mine" => cmd_experience_mine(&args[2..]), "apply-consolidation" => cmd_apply_consolidation(&args[2..]), "differentiate" => cmd_differentiate(&args[2..]), "link-audit" => cmd_link_audit(&args[2..]), @@ -154,6 +155,7 @@ Commands: digest-links [--apply] Parse and apply links from digest files journal-enrich JSONL TEXT [LINE] Enrich journal entry with conversation links + experience-mine [JSONL] Mine conversation for experiential moments to journal apply-consolidation [--apply] [--report FILE] Extract and apply actions from consolidation reports differentiate [KEY] [--apply] @@ -714,6 +716,46 @@ fn cmd_journal_enrich(args: &[String]) -> Result<(), String> { digest::journal_enrich(&mut store, jsonl_path, entry_text, grep_line) } +fn cmd_experience_mine(args: &[String]) -> Result<(), String> { + let jsonl_path = if let Some(path) = args.first() { + path.clone() + } else { + // Find the most recent JSONL transcript + let projects_dir = std::path::Path::new(&std::env::var("HOME").unwrap_or_default()) + .join(".claude/projects"); + let mut entries: Vec<(std::time::SystemTime, std::path::PathBuf)> = Vec::new(); + if let Ok(dirs) = std::fs::read_dir(&projects_dir) { + for dir in dirs.flatten() { + if let Ok(files) = std::fs::read_dir(dir.path()) { + for file in files.flatten() { + let path = file.path(); + if path.extension().map_or(false, |ext| ext == "jsonl") { + if let Ok(meta) = file.metadata() { + if let Ok(mtime) = meta.modified() { + entries.push((mtime, path)); + } + } + } + } + } + } + } + entries.sort_by(|a, b| b.0.cmp(&a.0)); + entries.first() + .map(|(_, p)| p.to_string_lossy().to_string()) + .ok_or("no JSONL transcripts found")? + }; + + if !std::path::Path::new(jsonl_path.as_str()).is_file() { + return Err(format!("JSONL not found: {}", jsonl_path)); + } + + let mut store = capnp_store::Store::load()?; + let count = digest::experience_mine(&mut store, &jsonl_path)?; + println!("Done: {} new entries mined.", count); + Ok(()) +} + fn cmd_apply_consolidation(args: &[String]) -> Result<(), String> { let do_apply = args.iter().any(|a| a == "--apply"); let report_file = args.windows(2)