2026-03-03 17:18:18 -05:00
|
|
|
// Episodic digest generation: daily, weekly, monthly, auto
|
2026-02-28 23:58:05 -05:00
|
|
|
//
|
2026-03-03 17:18:18 -05:00
|
|
|
// 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};
|
2026-03-01 00:33:46 -05:00
|
|
|
use crate::neuro;
|
2026-02-28 23:58:05 -05:00
|
|
|
|
|
|
|
|
use regex::Regex;
|
|
|
|
|
use std::fs;
|
2026-03-01 00:10:03 -05:00
|
|
|
use std::path::{Path, PathBuf};
|
2026-02-28 23:58:05 -05:00
|
|
|
|
|
|
|
|
fn episodic_dir() -> PathBuf {
|
2026-03-03 17:18:18 -05:00
|
|
|
let dir = store::memory_dir().join("episodic");
|
2026-02-28 23:58:05 -05:00
|
|
|
fs::create_dir_all(&dir).ok();
|
|
|
|
|
dir
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn agent_results_dir() -> PathBuf {
|
2026-03-03 17:18:18 -05:00
|
|
|
let dir = store::memory_dir().join("agent-results");
|
2026-02-28 23:58:05 -05:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 17:18:18 -05:00
|
|
|
fn build_daily_prompt(date: &str, entries: &[(String, String)], keys: &[String]) -> Result<String, String> {
|
2026-02-28 23:58:05 -05:00
|
|
|
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::<Vec<_>>()
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
2026-03-03 17:18:18 -05:00
|
|
|
neuro::load_prompt("daily-digest", &[
|
|
|
|
|
("{{DATE}}", date),
|
|
|
|
|
("{{ENTRIES}}", &entries_text),
|
|
|
|
|
("{{KEYS}}", &keys_text),
|
|
|
|
|
])
|
2026-02-28 23:58:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
2026-03-03 17:18:18 -05:00
|
|
|
let prompt = build_daily_prompt(date, &entries, &keys)?;
|
2026-02-28 23:58:05 -05:00
|
|
|
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<serde_json::Value> = 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>), 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);
|
2026-03-03 12:56:15 -05:00
|
|
|
let (dy, dm, dd, _, _, _) = store::epoch_to_local(day_epoch as f64);
|
2026-02-28 23:58:05 -05:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 17:18:18 -05:00
|
|
|
fn build_weekly_prompt(week_label: &str, digests: &[(String, String)], keys: &[String]) -> Result<String, String> {
|
2026-02-28 23:58:05 -05:00
|
|
|
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::<Vec<_>>()
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
|
|
|
|
let dates_covered: String = digests.iter()
|
|
|
|
|
.map(|(d, _)| d.as_str())
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join(", ");
|
|
|
|
|
|
2026-03-03 17:18:18 -05:00
|
|
|
neuro::load_prompt("weekly-digest", &[
|
|
|
|
|
("{{WEEK_LABEL}}", week_label),
|
|
|
|
|
("{{DATES_COVERED}}", &dates_covered),
|
|
|
|
|
("{{DIGESTS}}", &digests_text),
|
|
|
|
|
("{{KEYS}}", &keys_text),
|
|
|
|
|
])
|
2026-02-28 23:58:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
2026-03-03 17:18:18 -05:00
|
|
|
let prompt = build_weekly_prompt(&week_label, &digests, &keys)?;
|
2026-02-28 23:58:05 -05:00
|
|
|
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::<Vec<_>>(),
|
|
|
|
|
});
|
|
|
|
|
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<String> {
|
|
|
|
|
let mut weeks = std::collections::BTreeSet::new();
|
|
|
|
|
let mut d = 1u32;
|
|
|
|
|
loop {
|
|
|
|
|
let epoch = date_to_epoch(year, month, d);
|
2026-03-03 12:56:15 -05:00
|
|
|
let (_, _, _, _, _, _) = store::epoch_to_local(epoch as f64);
|
2026-02-28 23:58:05 -05:00
|
|
|
// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 17:18:18 -05:00
|
|
|
fn build_monthly_prompt(month_label: &str, digests: &[(String, String)], keys: &[String]) -> Result<String, String> {
|
2026-02-28 23:58:05 -05:00
|
|
|
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::<Vec<_>>()
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
|
|
|
|
let weeks_covered: String = digests.iter()
|
|
|
|
|
.map(|(w, _)| w.as_str())
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join(", ");
|
|
|
|
|
|
2026-03-03 17:18:18 -05:00
|
|
|
neuro::load_prompt("monthly-digest", &[
|
|
|
|
|
("{{MONTH_LABEL}}", month_label),
|
|
|
|
|
("{{WEEKS_COVERED}}", &weeks_covered),
|
|
|
|
|
("{{DIGESTS}}", &digests_text),
|
|
|
|
|
("{{KEYS}}", &keys_text),
|
|
|
|
|
])
|
2026-02-28 23:58:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn generate_monthly(store: &mut Store, month_arg: &str) -> Result<(), String> {
|
|
|
|
|
let (year, month) = if month_arg.is_empty() {
|
2026-03-03 12:56:15 -05:00
|
|
|
let now = store::now_epoch();
|
|
|
|
|
let (y, m, _, _, _, _) = store::epoch_to_local(now);
|
2026-02-28 23:58:05 -05:00
|
|
|
(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());
|
|
|
|
|
|
2026-03-03 17:18:18 -05:00
|
|
|
let prompt = build_monthly_prompt(&month_label, &digests, &keys)?;
|
2026-02-28 23:58:05 -05:00
|
|
|
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::<Vec<_>>(),
|
|
|
|
|
});
|
|
|
|
|
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(())
|
|
|
|
|
}
|
2026-03-01 00:10:03 -05:00
|
|
|
|
2026-03-01 07:14:03 -05:00
|
|
|
// --- 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> {
|
2026-03-03 12:56:15 -05:00
|
|
|
let today = store::today();
|
2026-03-01 07:14:03 -05:00
|
|
|
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<String> = 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<String> = 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<String, Vec<String>> = 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<String> = 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.
|
|
|
|
|
|
2026-03-03 12:56:15 -05:00
|
|
|
let (cur_y, cur_m, _, _, _, _) = store::epoch_to_local(store::now_epoch());
|
2026-03-01 07:14:03 -05:00
|
|
|
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::<i32>(), parts[1].parse::<u32>()) {
|
|
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-03-01 00:10:03 -05:00
|
|
|
// --- 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<DigestLink> {
|
|
|
|
|
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<DigestLink> {
|
|
|
|
|
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<PathBuf> = 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-01 00:33:46 -05:00
|
|
|
// 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);
|
|
|
|
|
|
2026-03-01 00:10:03 -05:00
|
|
|
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; }
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-03 12:56:15 -05:00
|
|
|
let rel = new_relation(
|
2026-03-01 00:10:03 -05:00
|
|
|
source_uuid, target_uuid,
|
2026-03-03 12:56:15 -05:00
|
|
|
store::RelationType::Link,
|
2026-03-01 00:10:03 -05:00
|
|
|
0.5,
|
|
|
|
|
&source, &target,
|
|
|
|
|
);
|
|
|
|
|
if store.add_relation(rel).is_ok() {
|
|
|
|
|
println!(" + {} → {}", source, target);
|
|
|
|
|
applied += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(applied, skipped, fallbacks)
|
|
|
|
|
}
|