2026-03-14 18:10:22 -04:00
|
|
|
// cli/journal.rs — journal subcommand handlers
|
|
|
|
|
|
|
|
|
|
|
2026-03-26 19:11:17 -04:00
|
|
|
pub fn cmd_tail(n: usize, full: bool, provenance: Option<&str>, dedup: bool) -> Result<(), String> {
|
2026-03-14 18:10:22 -04:00
|
|
|
let path = crate::store::nodes_path();
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
return Err("No node log found".into());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
use std::io::BufReader;
|
|
|
|
|
let file = std::fs::File::open(&path)
|
|
|
|
|
.map_err(|e| format!("open {}: {}", path.display(), e))?;
|
|
|
|
|
let mut reader = BufReader::new(file);
|
|
|
|
|
|
|
|
|
|
// Read all entries, keep last N
|
|
|
|
|
let mut entries: Vec<crate::store::Node> = Vec::new();
|
|
|
|
|
while let Ok(msg) = capnp::serialize::read_message(&mut reader, capnp::message::ReaderOptions::new()) {
|
|
|
|
|
let log = msg.get_root::<crate::memory_capnp::node_log::Reader>()
|
|
|
|
|
.map_err(|e| format!("read log: {}", e))?;
|
|
|
|
|
for node_reader in log.get_nodes()
|
|
|
|
|
.map_err(|e| format!("get nodes: {}", e))? {
|
|
|
|
|
let node = crate::store::Node::from_capnp_migrate(node_reader)?;
|
|
|
|
|
entries.push(node);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 19:11:17 -04:00
|
|
|
// Filter by provenance if specified (substring match)
|
2026-03-26 18:41:10 -04:00
|
|
|
if let Some(prov) = provenance {
|
|
|
|
|
entries.retain(|n| n.provenance.contains(prov));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 19:11:17 -04:00
|
|
|
// Dedup: keep only the latest version of each key
|
|
|
|
|
if dedup {
|
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
|
|
|
// Walk backwards so we keep the latest
|
|
|
|
|
entries = entries.into_iter().rev()
|
|
|
|
|
.filter(|n| seen.insert(n.key.clone()))
|
|
|
|
|
.collect();
|
|
|
|
|
entries.reverse();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 18:10:22 -04:00
|
|
|
let start = entries.len().saturating_sub(n);
|
|
|
|
|
for node in &entries[start..] {
|
|
|
|
|
let ts = if node.timestamp > 0 && node.timestamp < 4_000_000_000 {
|
|
|
|
|
crate::store::format_datetime(node.timestamp)
|
|
|
|
|
} else {
|
|
|
|
|
format!("(raw:{})", node.timestamp)
|
|
|
|
|
};
|
|
|
|
|
let del = if node.deleted { " [DELETED]" } else { "" };
|
|
|
|
|
if full {
|
2026-03-26 17:48:44 -04:00
|
|
|
println!("--- {} (v{}) {} via {} w={:.3}{} ---",
|
2026-03-14 18:10:22 -04:00
|
|
|
node.key, node.version, ts, node.provenance, node.weight, del);
|
2026-03-26 17:48:44 -04:00
|
|
|
println!("{}\n", node.content);
|
2026-03-14 18:10:22 -04:00
|
|
|
} else {
|
|
|
|
|
let preview = crate::util::first_n_chars(&node.content, 100).replace('\n', "\\n");
|
2026-03-26 17:48:44 -04:00
|
|
|
println!(" {} v{} w={:.2}{}",
|
2026-03-14 18:10:22 -04:00
|
|
|
ts, node.version, node.weight, del);
|
2026-03-26 17:48:44 -04:00
|
|
|
println!(" {} via {}", node.key, node.provenance);
|
2026-03-14 18:10:22 -04:00
|
|
|
if !preview.is_empty() {
|
2026-03-26 17:48:44 -04:00
|
|
|
println!(" {}", preview);
|
2026-03-14 18:10:22 -04:00
|
|
|
}
|
2026-03-26 17:48:44 -04:00
|
|
|
println!();
|
2026-03-14 18:10:22 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 18:14:52 -04:00
|
|
|
pub fn find_current_transcript() -> Option<String> {
|
|
|
|
|
let projects = crate::config::get().projects_dir.clone();
|
|
|
|
|
if !projects.exists() { return None; }
|
|
|
|
|
|
|
|
|
|
let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
|
|
|
|
|
if let Ok(dirs) = std::fs::read_dir(&projects) {
|
|
|
|
|
for dir_entry in dirs.filter_map(|e| e.ok()) {
|
|
|
|
|
if !dir_entry.path().is_dir() { continue; }
|
|
|
|
|
if let Ok(files) = std::fs::read_dir(dir_entry.path()) {
|
|
|
|
|
for f in files.filter_map(|e| e.ok()) {
|
|
|
|
|
let p = f.path();
|
2026-03-21 19:42:38 -04:00
|
|
|
if p.extension().map(|x| x == "jsonl").unwrap_or(false)
|
|
|
|
|
&& let Ok(meta) = p.metadata()
|
|
|
|
|
&& let Ok(mtime) = meta.modified()
|
|
|
|
|
&& newest.as_ref().is_none_or(|(t, _)| mtime > *t) {
|
2026-03-14 18:14:52 -04:00
|
|
|
newest = Some((mtime, p));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
newest.map(|(_, p)| p.to_string_lossy().to_string())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 02:29:52 -04:00
|
|
|
fn journal_tail_query(store: &crate::store::Store, query: &str, n: usize, full: bool) -> Result<(), String> {
|
|
|
|
|
let graph = store.build_graph();
|
|
|
|
|
let stages = crate::query_parser::parse_stages(query)?;
|
|
|
|
|
let results = crate::search::run_query(&stages, vec![], &graph, store, false, n);
|
|
|
|
|
|
|
|
|
|
// Query sorts desc and limits, so reverse to show oldest-to-newest
|
|
|
|
|
for (key, _score) in results.into_iter().rev() {
|
|
|
|
|
let Some(node) = store.nodes.get(&key) else { continue };
|
|
|
|
|
let ts = if node.created_at > 0 {
|
|
|
|
|
crate::store::format_datetime(node.created_at)
|
|
|
|
|
} else if node.timestamp > 0 {
|
|
|
|
|
crate::store::format_datetime(node.timestamp)
|
2026-03-14 18:14:52 -04:00
|
|
|
} else {
|
2026-04-12 02:29:52 -04:00
|
|
|
node.key.clone()
|
|
|
|
|
};
|
2026-03-14 18:14:52 -04:00
|
|
|
let title = extract_title(&node.content);
|
|
|
|
|
if full {
|
|
|
|
|
println!("--- [{}] {} ---\n{}\n", ts, title, node.content);
|
|
|
|
|
} else {
|
|
|
|
|
println!("[{}] {}", ts, title);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn cmd_journal_tail(n: usize, full: bool, level: u8) -> Result<(), String> {
|
|
|
|
|
let store = crate::store::Store::load()?;
|
|
|
|
|
|
2026-04-12 02:29:52 -04:00
|
|
|
let query = format!("all | type:{} | sort:timestamp | limit:{}",
|
|
|
|
|
match level { 0 => "episodic", 1 => "daily", 2 => "weekly", _ => "monthly" },
|
|
|
|
|
n
|
|
|
|
|
);
|
|
|
|
|
journal_tail_query(&store, &query, n, full)
|
2026-03-14 18:14:52 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-26 19:11:17 -04:00
|
|
|
pub fn cmd_journal_write(name: &str, text: &[String]) -> Result<(), String> {
|
2026-03-14 18:14:52 -04:00
|
|
|
if text.is_empty() {
|
2026-03-26 19:11:17 -04:00
|
|
|
return Err("journal write requires text".into());
|
2026-03-14 18:14:52 -04:00
|
|
|
}
|
poc-memory: POC_MEMORY_DRY_RUN=1 for agent testing
All mutating commands (write, delete, rename, link-add, journal write,
used, wrong, not-useful, gap) check POC_MEMORY_DRY_RUN after argument
validation but before mutation. If set, process exits silently — agent
tool calls are visible in the LLM output so we can see what it tried
to do without applying changes.
Read commands (render, search, graph link, journal tail) work normally
in dry-run mode so agents can still explore the graph.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:09:56 -04:00
|
|
|
super::check_dry_run();
|
2026-03-14 18:14:52 -04:00
|
|
|
let text = text.join(" ");
|
|
|
|
|
|
|
|
|
|
let timestamp = crate::store::format_datetime(crate::store::now_epoch());
|
2026-03-26 19:11:17 -04:00
|
|
|
let content = format!("## {} — {}\n\n{}", timestamp, name, text);
|
2026-03-14 18:14:52 -04:00
|
|
|
|
2026-03-26 19:11:17 -04:00
|
|
|
let key: String = name.split_whitespace()
|
2026-03-14 18:14:52 -04:00
|
|
|
.map(|w| w.to_lowercase()
|
|
|
|
|
.chars().filter(|c| c.is_alphanumeric() || *c == '-')
|
|
|
|
|
.collect::<String>())
|
2026-03-26 19:11:17 -04:00
|
|
|
.filter(|s| !s.is_empty())
|
2026-03-14 18:14:52 -04:00
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("-");
|
|
|
|
|
|
|
|
|
|
let source_ref = find_current_transcript();
|
|
|
|
|
|
|
|
|
|
let mut store = crate::store::Store::load()?;
|
|
|
|
|
|
|
|
|
|
let mut node = crate::store::new_node(&key, &content);
|
|
|
|
|
node.node_type = crate::store::NodeType::EpisodicSession;
|
|
|
|
|
node.provenance = "journal".to_string();
|
|
|
|
|
if let Some(src) = source_ref {
|
|
|
|
|
node.source_ref = src;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
store.upsert_node(node)?;
|
|
|
|
|
store.save()?;
|
|
|
|
|
|
|
|
|
|
let word_count = text.split_whitespace().count();
|
|
|
|
|
println!("Appended entry at {} ({} words)", timestamp, word_count);
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fn extract_title(content: &str) -> String {
|
|
|
|
|
let date_re = regex::Regex::new(r"(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2})").unwrap();
|
|
|
|
|
for line in content.lines() {
|
|
|
|
|
let stripped = line.trim();
|
|
|
|
|
if stripped.is_empty() { continue; }
|
|
|
|
|
if date_re.is_match(stripped) && stripped.len() < 25 { continue; }
|
|
|
|
|
if let Some(h) = stripped.strip_prefix("## ") {
|
|
|
|
|
return h.to_string();
|
|
|
|
|
} else if let Some(h) = stripped.strip_prefix("# ") {
|
|
|
|
|
return h.to_string();
|
|
|
|
|
} else {
|
|
|
|
|
return crate::util::truncate(stripped, 67, "...");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
String::from("(untitled)")
|
|
|
|
|
}
|
|
|
|
|
|