// Episodic digest generation: daily, weekly, monthly, auto // // Temporal digest generation and digest link parsing. Each digest type // gathers input from the store, builds a Sonnet prompt, calls Sonnet, // writes results to the episodic dir, and extracts links. use crate::llm::{call_sonnet, semantic_keys}; use crate::store::{self, Store, new_relation}; use crate::neuro; use regex::Regex; use std::fs; use std::path::{Path, PathBuf}; fn episodic_dir() -> PathBuf { let dir = store::memory_dir().join("episodic"); fs::create_dir_all(&dir).ok(); dir } fn agent_results_dir() -> PathBuf { let dir = store::memory_dir().join("agent-results"); fs::create_dir_all(&dir).ok(); dir } /// Extract link proposals from digest text (backtick-arrow patterns) fn extract_links(text: &str) -> Vec<(String, String)> { let re_left = Regex::new(r"`([^`]+)`\s*→").unwrap(); let re_right = Regex::new(r"→\s*`([^`]+)`").unwrap(); let mut links = Vec::new(); for line in text.lines() { if let Some(cap) = re_left.captures(line) { links.push((cap[1].to_string(), line.trim().to_string())); } if let Some(cap) = re_right.captures(line) { links.push((cap[1].to_string(), line.trim().to_string())); } } links } // --- Daily digest --- fn daily_journal_entries(store: &Store, target_date: &str) -> Vec<(String, String)> { // Collect journal nodes for the target date // Keys like: journal.md#j-2026-02-28t23-39-... let date_re = Regex::new(&format!( r"^journal\.md#j-{}", regex::escape(target_date) )).unwrap(); let mut entries: Vec<_> = store.nodes.values() .filter(|n| date_re.is_match(&n.key)) .map(|n| (n.key.clone(), n.content.clone())) .collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); entries } fn build_daily_prompt(date: &str, entries: &[(String, String)], keys: &[String]) -> Result { let mut entries_text = String::new(); for (key, content) in entries { let ts = key.strip_prefix("journal.md#j-").unwrap_or(key); entries_text.push_str(&format!("\n### {}\n\n{}\n", ts, content)); } let keys_text: String = keys.iter() .map(|k| format!(" - {}", k)) .collect::>() .join("\n"); neuro::load_prompt("daily-digest", &[ ("{{DATE}}", date), ("{{ENTRIES}}", &entries_text), ("{{KEYS}}", &keys_text), ]) } pub fn generate_daily(store: &mut Store, date: &str) -> Result<(), String> { println!("Generating daily digest for {}...", date); let entries = daily_journal_entries(store, date); if entries.is_empty() { println!(" No journal entries found for {}", date); return Ok(()); } println!(" {} journal entries", entries.len()); let keys = semantic_keys(store); println!(" {} semantic keys", keys.len()); let prompt = build_daily_prompt(date, &entries, &keys)?; println!(" Prompt: {} chars (~{} tokens)", prompt.len(), prompt.len() / 4); println!(" Calling Sonnet..."); let digest = call_sonnet(&prompt, 300)?; // Write to episodic dir let output_path = episodic_dir().join(format!("daily-{}.md", date)); fs::write(&output_path, &digest) .map_err(|e| format!("write {}: {}", output_path.display(), e))?; println!(" Written: {}", output_path.display()); // Import into store store.import_file(&output_path)?; store.save()?; // Extract and save links let links = extract_links(&digest); if !links.is_empty() { let links_json: Vec = links.iter() .map(|(target, line)| serde_json::json!({"target": target, "line": line})) .collect(); let result = serde_json::json!({ "type": "daily-digest", "date": date, "digest_path": output_path.to_string_lossy(), "links": links_json, }); let links_path = agent_results_dir().join(format!("daily-{}-links.json", date)); let json = serde_json::to_string_pretty(&result) .map_err(|e| format!("serialize: {}", e))?; fs::write(&links_path, json) .map_err(|e| format!("write {}: {}", links_path.display(), e))?; println!(" {} links extracted → {}", links.len(), links_path.display()); } let line_count = digest.lines().count(); println!(" Done: {} lines", line_count); Ok(()) } // --- Weekly digest --- /// Get ISO week label and the 7 dates (Mon-Sun) for the week containing `date`. fn week_dates(date: &str) -> Result<(String, Vec), String> { // Parse YYYY-MM-DD let parts: Vec<&str> = date.split('-').collect(); if parts.len() != 3 { return Err(format!("bad date: {}", date)); } let y: i32 = parts[0].parse().map_err(|_| "bad year")?; let m: u32 = parts[1].parse().map_err(|_| "bad month")?; let d: u32 = parts[2].parse().map_err(|_| "bad day")?; let (weekday, iso_year, iso_week) = iso_week_info(y, m, d)?; let week_label = format!("{}-W{:02}", iso_year, iso_week); // Find Monday of this week let days_since_monday = (weekday + 6) % 7; // weekday: 0=Sun, adjust to Mon=0 let monday_epoch = date_to_epoch(y, m, d) - (days_since_monday as i64) * 86400; let mut dates = Vec::new(); for i in 0..7 { let day_epoch = monday_epoch + (i * 86400); let (dy, dm, dd, _, _, _) = store::epoch_to_local(day_epoch as f64); dates.push(format!("{:04}-{:02}-{:02}", dy, dm, dd)); } Ok((week_label, dates)) } fn date_to_epoch(y: i32, m: u32, d: u32) -> i64 { let mut tm: libc::tm = unsafe { std::mem::zeroed() }; tm.tm_year = y - 1900; tm.tm_mon = (m as i32) - 1; tm.tm_mday = d as i32; tm.tm_hour = 12; // noon to avoid DST edge cases unsafe { libc::mktime(&mut tm) as i64 } } /// Returns (weekday 0=Sun, iso_year, iso_week) for a given date. fn iso_week_info(y: i32, m: u32, d: u32) -> Result<(u32, i32, u32), String> { let mut tm: libc::tm = unsafe { std::mem::zeroed() }; tm.tm_year = y - 1900; tm.tm_mon = (m as i32) - 1; tm.tm_mday = d as i32; tm.tm_hour = 12; let epoch = unsafe { libc::mktime(&mut tm) }; if epoch == -1 { return Err(format!("invalid date: {}-{}-{}", y, m, d)); } let wday = tm.tm_wday as u32; let mut buf = [0u8; 32]; let fmt = std::ffi::CString::new("%G %V").unwrap(); let len = unsafe { libc::strftime(buf.as_mut_ptr() as *mut libc::c_char, buf.len(), fmt.as_ptr(), &tm) }; let iso_str = std::str::from_utf8(&buf[..len]).unwrap_or("0 0"); let iso_parts: Vec<&str> = iso_str.split_whitespace().collect(); let iso_year: i32 = iso_parts.first().and_then(|s| s.parse().ok()).unwrap_or(y); let iso_week: u32 = iso_parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1); Ok((wday, iso_year, iso_week)) } fn load_digest_files(prefix: &str, labels: &[String]) -> Vec<(String, String)> { let dir = episodic_dir(); let mut digests = Vec::new(); for label in labels { let path = dir.join(format!("{}-{}.md", prefix, label)); if let Ok(content) = fs::read_to_string(&path) { digests.push((label.clone(), content)); } } digests } fn build_weekly_prompt(week_label: &str, digests: &[(String, String)], keys: &[String]) -> Result { let mut digests_text = String::new(); for (date, content) in digests { digests_text.push_str(&format!("\n---\n## {}\n{}\n", date, content)); } let keys_text: String = keys.iter() .map(|k| format!(" - {}", k)) .collect::>() .join("\n"); let dates_covered: String = digests.iter() .map(|(d, _)| d.as_str()) .collect::>() .join(", "); neuro::load_prompt("weekly-digest", &[ ("{{WEEK_LABEL}}", week_label), ("{{DATES_COVERED}}", &dates_covered), ("{{DIGESTS}}", &digests_text), ("{{KEYS}}", &keys_text), ]) } pub fn generate_weekly(store: &mut Store, date: &str) -> Result<(), String> { let (week_label, dates) = week_dates(date)?; println!("Generating weekly digest for {}...", week_label); let digests = load_digest_files("daily", &dates); if digests.is_empty() { println!(" No daily digests found for {}", week_label); println!(" Run `poc-memory digest daily` first for relevant dates"); return Ok(()); } println!(" {} daily digests found", digests.len()); let keys = semantic_keys(store); println!(" {} semantic keys", keys.len()); let prompt = build_weekly_prompt(&week_label, &digests, &keys)?; println!(" Prompt: {} chars (~{} tokens)", prompt.len(), prompt.len() / 4); println!(" Calling Sonnet..."); let digest = call_sonnet(&prompt, 300)?; let output_path = episodic_dir().join(format!("weekly-{}.md", week_label)); fs::write(&output_path, &digest) .map_err(|e| format!("write {}: {}", output_path.display(), e))?; println!(" Written: {}", output_path.display()); store.import_file(&output_path)?; store.save()?; // Save metadata let result = serde_json::json!({ "type": "weekly-digest", "week": week_label, "digest_path": output_path.to_string_lossy(), "daily_digests": digests.iter().map(|(d, _)| d).collect::>(), }); let links_path = agent_results_dir().join(format!("weekly-{}-links.json", week_label)); fs::write(&links_path, serde_json::to_string_pretty(&result).unwrap()) .map_err(|e| format!("write {}: {}", links_path.display(), e))?; println!(" Done: {} lines", digest.lines().count()); Ok(()) } // --- Monthly digest --- fn weeks_in_month(year: i32, month: u32) -> Vec { let mut weeks = std::collections::BTreeSet::new(); let mut d = 1u32; loop { let epoch = date_to_epoch(year, month, d); let (_, _, _, _, _, _) = store::epoch_to_local(epoch as f64); // Check if we're still in the target month let mut tm: libc::tm = unsafe { std::mem::zeroed() }; let secs = epoch as libc::time_t; unsafe { libc::localtime_r(&secs, &mut tm) }; if (tm.tm_mon + 1) as u32 != month || tm.tm_year + 1900 != year { break; } let mut buf = [0u8; 16]; let fmt = std::ffi::CString::new("%G-W%V").unwrap(); let len = unsafe { libc::strftime(buf.as_mut_ptr() as *mut libc::c_char, buf.len(), fmt.as_ptr(), &tm) }; let week = std::str::from_utf8(&buf[..len]).unwrap_or("").to_string(); if !week.is_empty() { weeks.insert(week); } d += 1; } weeks.into_iter().collect() } fn build_monthly_prompt(month_label: &str, digests: &[(String, String)], keys: &[String]) -> Result { let mut digests_text = String::new(); for (week, content) in digests { digests_text.push_str(&format!("\n---\n## {}\n{}\n", week, content)); } let keys_text: String = keys.iter() .map(|k| format!(" - {}", k)) .collect::>() .join("\n"); let weeks_covered: String = digests.iter() .map(|(w, _)| w.as_str()) .collect::>() .join(", "); neuro::load_prompt("monthly-digest", &[ ("{{MONTH_LABEL}}", month_label), ("{{WEEKS_COVERED}}", &weeks_covered), ("{{DIGESTS}}", &digests_text), ("{{KEYS}}", &keys_text), ]) } pub fn generate_monthly(store: &mut Store, month_arg: &str) -> Result<(), String> { let (year, month) = if month_arg.is_empty() { let now = store::now_epoch(); let (y, m, _, _, _, _) = store::epoch_to_local(now); (y, m) } else { let parts: Vec<&str> = month_arg.split('-').collect(); if parts.len() != 2 { return Err(format!("bad month format: {} (expected YYYY-MM)", month_arg)); } let y: i32 = parts[0].parse().map_err(|_| "bad year")?; let m: u32 = parts[1].parse().map_err(|_| "bad month")?; (y, m) }; let month_label = format!("{}-{:02}", year, month); println!("Generating monthly digest for {}...", month_label); let week_labels = weeks_in_month(year, month); println!(" Weeks in month: {}", week_labels.join(", ")); let digests = load_digest_files("weekly", &week_labels); if digests.is_empty() { println!(" No weekly digests found for {}", month_label); println!(" Run `poc-memory digest weekly` first for relevant weeks"); return Ok(()); } println!(" {} weekly digests found", digests.len()); let keys = semantic_keys(store); println!(" {} semantic keys", keys.len()); let prompt = build_monthly_prompt(&month_label, &digests, &keys)?; println!(" Prompt: {} chars (~{} tokens)", prompt.len(), prompt.len() / 4); println!(" Calling Sonnet..."); let digest = call_sonnet(&prompt, 600)?; let output_path = episodic_dir().join(format!("monthly-{}.md", month_label)); fs::write(&output_path, &digest) .map_err(|e| format!("write {}: {}", output_path.display(), e))?; println!(" Written: {}", output_path.display()); store.import_file(&output_path)?; store.save()?; // Save metadata let result = serde_json::json!({ "type": "monthly-digest", "month": month_label, "digest_path": output_path.to_string_lossy(), "weekly_digests": digests.iter().map(|(w, _)| w).collect::>(), }); let links_path = agent_results_dir().join(format!("monthly-{}-links.json", month_label)); fs::write(&links_path, serde_json::to_string_pretty(&result).unwrap()) .map_err(|e| format!("write {}: {}", links_path.display(), e))?; println!(" Done: {} lines", digest.lines().count()); Ok(()) } // --- Digest auto: freshness detection + bottom-up generation --- /// Scan the store for dates/weeks/months that need digests and generate them. /// Works bottom-up: daily first, then weekly (needs dailies), then monthly /// (needs weeklies). Skips today (incomplete day). Skips already-existing /// digests. pub fn digest_auto(store: &mut Store) -> Result<(), String> { let today = store::today(); let epi = episodic_dir(); // --- Phase 1: find dates with journal entries but no daily digest --- let date_re = Regex::new(r"^\d{4}-\d{2}-\d{2}").unwrap(); let mut dates: std::collections::BTreeSet = std::collections::BTreeSet::new(); for key in store.nodes.keys() { // Keys like: journal.md#j-2026-02-28t23-39-... if let Some(rest) = key.strip_prefix("journal.md#j-") { if rest.len() >= 10 && date_re.is_match(rest) { dates.insert(rest[..10].to_string()); } } } let mut daily_generated = 0u32; let mut daily_skipped = 0u32; let mut daily_dates_done: Vec = Vec::new(); for date in &dates { if date == &today { continue; // don't digest an incomplete day } let path = epi.join(format!("daily-{}.md", date)); if path.exists() { daily_skipped += 1; daily_dates_done.push(date.clone()); continue; } println!("[auto] Missing daily digest for {}", date); generate_daily(store, date)?; daily_generated += 1; daily_dates_done.push(date.clone()); } println!("[auto] Daily: {} generated, {} already existed", daily_generated, daily_skipped); // --- Phase 2: find complete weeks needing weekly digests --- // A week is "ready" if its Sunday is before today and at least one // daily digest exists for it. let mut weeks_seen: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for date in &daily_dates_done { if let Ok((week_label, _week_dates)) = week_dates(date) { weeks_seen.entry(week_label).or_default().push(date.clone()); } } let mut weekly_generated = 0u32; let mut weekly_skipped = 0u32; let mut weekly_labels_done: Vec = Vec::new(); for (week_label, example_dates) in &weeks_seen { // Check if this week is complete (Sunday has passed) if let Ok((_, week_day_list)) = week_dates(example_dates.first().unwrap()) { let sunday = week_day_list.last().unwrap(); if sunday >= &today { continue; // week not over yet } } let path = epi.join(format!("weekly-{}.md", week_label)); if path.exists() { weekly_skipped += 1; weekly_labels_done.push(week_label.clone()); continue; } // Check that at least some dailies exist for this week let has_dailies = example_dates.iter().any(|d| epi.join(format!("daily-{}.md", d)).exists() ); if !has_dailies { continue; } println!("[auto] Missing weekly digest for {}", week_label); generate_weekly(store, example_dates.first().unwrap())?; weekly_generated += 1; weekly_labels_done.push(week_label.clone()); } println!("[auto] Weekly: {} generated, {} already existed", weekly_generated, weekly_skipped); // --- Phase 3: find complete months needing monthly digests --- // A month is "ready" if the month is before the current month and at // least one weekly digest exists for it. let (cur_y, cur_m, _, _, _, _) = store::epoch_to_local(store::now_epoch()); let mut months_seen: std::collections::BTreeSet<(i32, u32)> = std::collections::BTreeSet::new(); for date in &daily_dates_done { let parts: Vec<&str> = date.split('-').collect(); if parts.len() >= 2 { if let (Ok(y), Ok(m)) = (parts[0].parse::(), parts[1].parse::()) { months_seen.insert((y, m)); } } } let mut monthly_generated = 0u32; let mut monthly_skipped = 0u32; for (y, m) in &months_seen { // Skip current month (still in progress) if *y == cur_y && *m == cur_m { continue; } // Skip future months if *y > cur_y || (*y == cur_y && *m > cur_m) { continue; } let label = format!("{}-{:02}", y, m); let path = epi.join(format!("monthly-{}.md", label)); if path.exists() { monthly_skipped += 1; continue; } // Check that at least one weekly exists for this month let week_labels = weeks_in_month(*y, *m); let has_weeklies = week_labels.iter().any(|w| epi.join(format!("weekly-{}.md", w)).exists() ); if !has_weeklies { continue; } println!("[auto] Missing monthly digest for {}", label); generate_monthly(store, &label)?; monthly_generated += 1; } println!("[auto] Monthly: {} generated, {} already existed", monthly_generated, monthly_skipped); let total = daily_generated + weekly_generated + monthly_generated; if total == 0 { println!("[auto] All digests up to date."); } else { println!("[auto] Generated {} total digests.", total); } Ok(()) } // --- Digest link parsing --- // Replaces digest-link-parser.py: parses ## Links sections from digest // files and applies them to the memory graph. /// A parsed link from a digest's Links section. pub struct DigestLink { pub source: String, pub target: String, pub reason: String, pub file: String, } /// Normalize a raw link target to a poc-memory key. fn normalize_link_key(raw: &str) -> String { let key = raw.trim().trim_matches('`').trim(); if key.is_empty() { return String::new(); } // Self-references let lower = key.to_lowercase(); if lower.starts_with("this ") { return String::new(); } let mut key = key.to_string(); // weekly/2026-W06 → weekly-2026-W06, etc. if let Some(pos) = key.find('/') { let prefix = &key[..pos]; if prefix == "daily" || prefix == "weekly" || prefix == "monthly" { let rest = &key[pos + 1..]; key = format!("{}-{}", prefix, rest); } } // daily-2026-02-04 → daily-2026-02-04.md let re = Regex::new(r"^(daily|weekly|monthly)-\d{4}").unwrap(); if re.is_match(&key) && !key.ends_with(".md") { key.push_str(".md"); } // Bare date → daily digest let date_re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap(); if date_re.is_match(key.strip_suffix(".md").unwrap_or(&key)) { let date = key.strip_suffix(".md").unwrap_or(&key); key = format!("daily-{}.md", date); } // Ensure .md extension if key.contains('#') { let (file, section) = key.split_once('#').unwrap(); if !file.ends_with(".md") { key = format!("{}.md#{}", file, section); } } else if !key.ends_with(".md") && !key.contains('/') && !key.starts_with("NEW:") { key.push_str(".md"); } key } /// Parse the Links section from a single digest file. fn parse_digest_file_links(path: &Path) -> Vec { let content = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return Vec::new(), }; let digest_name = path.file_stem() .and_then(|s| s.to_str()) .unwrap_or(""); let digest_key = format!("{}.md", digest_name); let filename = path.file_name() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); let link_re = Regex::new(r"^-\s+(.+?)\s*[→↔←]\s*(.+?)(?:\s*\((.+?)\))?\s*$").unwrap(); let header_re = Regex::new(r"^##\s+Links").unwrap(); let mut links = Vec::new(); let mut in_links = false; for line in content.lines() { if header_re.is_match(line) { in_links = true; continue; } if in_links && line.starts_with("## ") { in_links = false; continue; } if !in_links { continue; } if line.starts_with("###") || line.starts_with("**") { continue; } if let Some(cap) = link_re.captures(line) { let raw_source = cap[1].trim(); let raw_target = cap[2].trim(); let reason = cap.get(3).map(|m| m.as_str().to_string()).unwrap_or_default(); let mut source = normalize_link_key(raw_source); let mut target = normalize_link_key(raw_target); // Replace self-references with digest key if source.is_empty() { source = digest_key.clone(); } if target.is_empty() { target = digest_key.clone(); } // Handle "this daily/weekly/monthly" in raw text let raw_s_lower = raw_source.to_lowercase(); let raw_t_lower = raw_target.to_lowercase(); if raw_s_lower.contains("this daily") || raw_s_lower.contains("this weekly") || raw_s_lower.contains("this monthly") { source = digest_key.clone(); } if raw_t_lower.contains("this daily") || raw_t_lower.contains("this weekly") || raw_t_lower.contains("this monthly") { target = digest_key.clone(); } // Skip NEW: and self-links if source.starts_with("NEW:") || target.starts_with("NEW:") { continue; } if source == target { continue; } links.push(DigestLink { source, target, reason, file: filename.clone() }); } } links } /// Parse links from all digest files in the episodic dir. pub fn parse_all_digest_links() -> Vec { let dir = episodic_dir(); let mut all_links = Vec::new(); for pattern in &["daily-*.md", "weekly-*.md", "monthly-*.md"] { if let Ok(entries) = fs::read_dir(&dir) { let mut files: Vec = entries .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| { p.file_name() .and_then(|n| n.to_str()) .map(|n| { let prefix = pattern.split('*').next().unwrap_or(""); n.starts_with(prefix) && n.ends_with(".md") }) .unwrap_or(false) }) .collect(); files.sort(); for path in files { all_links.extend(parse_digest_file_links(&path)); } } } // Deduplicate by (source, target) pair let mut seen = std::collections::HashSet::new(); all_links.retain(|link| seen.insert((link.source.clone(), link.target.clone()))); all_links } /// Apply parsed digest links to the store. pub fn apply_digest_links(store: &mut Store, links: &[DigestLink]) -> (usize, usize, usize) { let mut applied = 0usize; let mut skipped = 0usize; let mut fallbacks = 0usize; for link in links { // Try resolving both keys let source = match store.resolve_key(&link.source) { Ok(s) => s, Err(_) => { // Try stripping section anchor as fallback if let Some(base) = link.source.split('#').next() { match store.resolve_key(base) { Ok(s) => { fallbacks += 1; s } Err(_) => { skipped += 1; continue; } } } else { skipped += 1; continue; } } }; let target = match store.resolve_key(&link.target) { Ok(t) => t, Err(_) => { if let Some(base) = link.target.split('#').next() { match store.resolve_key(base) { Ok(t) => { fallbacks += 1; t } Err(_) => { skipped += 1; continue; } } } else { skipped += 1; continue; } } }; // Refine target to best-matching section if available let source_content = store.nodes.get(&source) .map(|n| n.content.as_str()).unwrap_or(""); let target = neuro::refine_target(store, source_content, &target); if source == target { skipped += 1; continue; } // Check if link already exists let exists = store.relations.iter().any(|r| r.source_key == source && r.target_key == target && !r.deleted ); if exists { skipped += 1; continue; } let source_uuid = match store.nodes.get(&source) { Some(n) => n.uuid, None => { skipped += 1; continue; } }; let target_uuid = match store.nodes.get(&target) { Some(n) => n.uuid, None => { skipped += 1; continue; } }; let rel = new_relation( source_uuid, target_uuid, store::RelationType::Link, 0.5, &source, &target, ); if store.add_relation(rel).is_ok() { println!(" + {} → {}", source, target); applied += 1; } } (applied, skipped, fallbacks) }