diff --git a/poc-memory/src/cursor.rs b/poc-memory/src/cursor.rs new file mode 100644 index 0000000..b287b49 --- /dev/null +++ b/poc-memory/src/cursor.rs @@ -0,0 +1,339 @@ +// Spatial memory cursor — a persistent pointer into the knowledge graph. +// +// The cursor maintains a "you are here" position that persists across +// sessions. Navigation moves through three dimensions: +// - Temporal: forward/back among same-type nodes by timestamp +// - Hierarchical: up/down the digest tree (journal→daily→weekly→monthly) +// - Spatial: sideways along graph edges to linked nodes +// +// This is the beginning of place cells — the hippocampus doesn't just +// store, it maintains a map. The cursor is the map's current position. + +use crate::graph::Graph; +use crate::store::{self, Node, Store}; + +use std::path::PathBuf; + +fn cursor_path() -> PathBuf { + store::memory_dir().join("cursor") +} + +/// Read the current cursor position (node key), if any. +pub fn get() -> Option { + std::fs::read_to_string(cursor_path()) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Set the cursor to a node key. +pub fn set(key: &str) -> Result<(), String> { + std::fs::write(cursor_path(), format!("{}\n", key)) + .map_err(|e| format!("write cursor: {}", e)) +} + +/// Clear the cursor. +pub fn clear() -> Result<(), String> { + let p = cursor_path(); + if p.exists() { + std::fs::remove_file(&p) + .map_err(|e| format!("clear cursor: {}", e))?; + } + Ok(()) +} + +/// Temporal neighbors: nodes of the same type, sorted by timestamp. +/// Returns (prev, next) keys relative to the given node. +pub fn temporal_neighbors(store: &Store, key: &str) -> (Option, Option) { + let Some(node) = store.nodes.get(key) else { return (None, None) }; + let node_type = node.node_type; + let ts = node.timestamp; + + let mut same_type: Vec<(&str, i64)> = store.nodes.iter() + .filter(|(_, n)| !n.deleted && n.node_type == node_type && n.timestamp > 0) + .map(|(k, n)| (k.as_str(), n.timestamp)) + .collect(); + same_type.sort_by_key(|(_, t)| *t); + + let pos = same_type.iter().position(|(k, _)| *k == key); + let prev = pos.and_then(|i| if i > 0 { Some(same_type[i - 1].0.to_string()) } else { None }); + let next = pos.and_then(|i| same_type.get(i + 1).map(|(k, _)| k.to_string())); + + (prev, next) +} + +/// Digest hierarchy: find the parent digest for a node. +/// Journal → daily, daily → weekly, weekly → monthly. +pub fn digest_parent(store: &Store, key: &str) -> Option { + let node = store.nodes.get(key)?; + + let parent_type = match node.node_type { + store::NodeType::EpisodicSession => store::NodeType::EpisodicDaily, + store::NodeType::EpisodicDaily => store::NodeType::EpisodicWeekly, + store::NodeType::EpisodicWeekly => store::NodeType::EpisodicMonthly, + _ => return None, + }; + + // Look for structural links first (digest:structural provenance) + for r in &store.relations { + if r.deleted { continue; } + if r.source_key == key { + if let Some(target) = store.nodes.get(&r.target_key) { + if target.node_type == parent_type { + return Some(r.target_key.clone()); + } + } + } + } + + // Fallback: match by date for journal→daily + if node.node_type == store::NodeType::EpisodicSession { + // Try extracting date from timestamp first, then from key + let mut dates = Vec::new(); + if node.timestamp > 0 { + dates.push(store::format_date(node.timestamp)); + } + // Extract date from key patterns like "journal#2026-03-03-..." or "journal#j-2026-03-13t..." + if let Some(rest) = key.strip_prefix("journal#j-").or_else(|| key.strip_prefix("journal#")) { + if rest.len() >= 10 { + let candidate = &rest[..10]; + if candidate.chars().nth(4) == Some('-') { + let date = candidate.to_string(); + if !dates.contains(&date) { + dates.push(date); + } + } + } + } + for date in &dates { + for prefix in [&format!("daily-{}", date), &format!("digest#daily#{}", date)] { + for (k, n) in &store.nodes { + if !n.deleted && n.node_type == parent_type && k.starts_with(prefix.as_str()) { + return Some(k.clone()); + } + } + } + } + } + + None +} + +/// Digest children: find nodes that feed into this digest. +/// Monthly → weeklies, weekly → dailies, daily → journal entries. +pub fn digest_children(store: &Store, key: &str) -> Vec { + let Some(node) = store.nodes.get(key) else { return vec![] }; + + let child_type = match node.node_type { + store::NodeType::EpisodicDaily => store::NodeType::EpisodicSession, + store::NodeType::EpisodicWeekly => store::NodeType::EpisodicDaily, + store::NodeType::EpisodicMonthly => store::NodeType::EpisodicWeekly, + _ => return vec![], + }; + + // Look for structural links (source → this digest) + let mut children: Vec<(String, i64)> = Vec::new(); + for r in &store.relations { + if r.deleted { continue; } + if r.target_key == key { + if let Some(source) = store.nodes.get(&r.source_key) { + if source.node_type == child_type { + children.push((r.source_key.clone(), source.timestamp)); + } + } + } + } + + // Fallback for daily → journal: extract date from key and match + if children.is_empty() && node.node_type == store::NodeType::EpisodicDaily { + // Extract date from keys like "daily-2026-03-13" or "daily-2026-03-13-suffix" + let date = key.strip_prefix("daily-") + .or_else(|| key.strip_prefix("digest#daily#")) + .and_then(|rest| rest.get(..10)); // "YYYY-MM-DD" + if let Some(date) = date { + for (k, n) in &store.nodes { + if n.deleted { continue; } + if n.node_type == store::NodeType::EpisodicSession + && n.timestamp > 0 + && store::format_date(n.timestamp) == date + { + children.push((k.clone(), n.timestamp)); + } + } + } + } + + children.sort_by_key(|(_, t)| *t); + children.into_iter().map(|(k, _)| k).collect() +} + +/// Graph neighbors sorted by edge strength. +pub fn graph_neighbors(store: &Store, key: &str) -> Vec<(String, f32)> { + let mut neighbors: Vec<(String, f32)> = Vec::new(); + for r in &store.relations { + if r.deleted { continue; } + if r.source_key == key { + neighbors.push((r.target_key.clone(), r.strength)); + } else if r.target_key == key { + neighbors.push((r.source_key.clone(), r.strength)); + } + } + neighbors.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + neighbors.dedup_by(|a, b| a.0 == b.0); + neighbors +} + +/// Format a one-line summary of a node for context display. +fn node_summary(node: &Node) -> String { + let ts = if node.timestamp > 0 { + store::format_datetime(node.timestamp) + } else { + "no-date".to_string() + }; + let type_tag = match node.node_type { + store::NodeType::EpisodicSession => "journal", + store::NodeType::EpisodicDaily => "daily", + store::NodeType::EpisodicWeekly => "weekly", + store::NodeType::EpisodicMonthly => "monthly", + store::NodeType::Semantic => "semantic", + }; + // First line of content, truncated + let first_line = node.content.lines().next().unwrap_or("") + .chars().take(80).collect::(); + format!("[{}] ({}) {}", ts, type_tag, first_line) +} + +/// Display the cursor position with full context. +pub fn show(store: &Store) -> Result<(), String> { + let key = get().ok_or_else(|| "No cursor set. Use `poc-memory cursor set KEY`".to_string())?; + let node = store.nodes.get(&key) + .ok_or_else(|| format!("Cursor points to missing node: {}", key))?; + + // Header + let type_tag = match node.node_type { + store::NodeType::EpisodicSession => "journal", + store::NodeType::EpisodicDaily => "daily", + store::NodeType::EpisodicWeekly => "weekly", + store::NodeType::EpisodicMonthly => "monthly", + store::NodeType::Semantic => "semantic", + }; + if node.timestamp > 0 { + eprintln!("@ {} [{}]", key, type_tag); + eprintln!(" {}", store::format_datetime(node.timestamp)); + } else { + eprintln!("@ {} [{}]", key, type_tag); + } + + // Temporal context + let (prev, next) = temporal_neighbors(store, &key); + eprintln!(); + if let Some(ref p) = prev { + if let Some(pn) = store.nodes.get(p) { + eprintln!(" ← {}", node_summary(pn)); + eprintln!(" `cursor back`"); + } + } + if let Some(ref n) = next { + if let Some(nn) = store.nodes.get(n) { + eprintln!(" → {}", node_summary(nn)); + eprintln!(" `cursor forward`"); + } + } + + // Hierarchy + if let Some(ref parent) = digest_parent(store, &key) { + if let Some(pn) = store.nodes.get(parent) { + eprintln!(" ↑ {}", node_summary(pn)); + eprintln!(" `cursor up`"); + } + } + let children = digest_children(store, &key); + if !children.is_empty() { + let count = children.len(); + if let Some(first) = children.first().and_then(|k| store.nodes.get(k)) { + eprintln!(" ↓ {} children — first: {}", count, node_summary(first)); + eprintln!(" `cursor down`"); + } + } + + // Graph neighbors (non-temporal) + let neighbors = graph_neighbors(store, &key); + let semantic: Vec<_> = neighbors.iter() + .filter(|(k, _)| { + store.nodes.get(k) + .map(|n| n.node_type == store::NodeType::Semantic) + .unwrap_or(false) + }) + .take(8) + .collect(); + if !semantic.is_empty() { + eprintln!(); + eprintln!(" Linked:"); + for (k, strength) in &semantic { + eprintln!(" [{:.1}] {}", strength, k); + } + } + + eprintln!(); + eprintln!("---"); + + // Content + print!("{}", node.content); + + Ok(()) +} + +/// Move cursor in a temporal direction. +pub fn move_temporal(store: &Store, forward: bool) -> Result<(), String> { + let key = get().ok_or("No cursor set")?; + let _ = store.nodes.get(&key) + .ok_or_else(|| format!("Cursor points to missing node: {}", key))?; + + let (prev, next) = temporal_neighbors(store, &key); + let target = if forward { next } else { prev }; + match target { + Some(k) => { + set(&k)?; + show(store) + } + None => { + let dir = if forward { "forward" } else { "back" }; + Err(format!("No {} neighbor from {}", dir, key)) + } + } +} + +/// Move cursor up the digest hierarchy. +pub fn move_up(store: &Store) -> Result<(), String> { + let key = get().ok_or("No cursor set")?; + match digest_parent(store, &key) { + Some(parent) => { + set(&parent)?; + show(store) + } + None => Err(format!("No parent digest for {}", key)), + } +} + +/// Move cursor down the digest hierarchy (to first child). +pub fn move_down(store: &Store) -> Result<(), String> { + let key = get().ok_or("No cursor set")?; + let children = digest_children(store, &key); + match children.first() { + Some(child) => { + set(child)?; + show(store) + } + None => Err(format!("No children for {}", key)), + } +} + +/// Move cursor to a graph neighbor by index (from the neighbors list). +pub fn move_to_neighbor(store: &Store, index: usize) -> Result<(), String> { + let key = get().ok_or("No cursor set")?; + let neighbors = graph_neighbors(store, &key); + let (target, _) = neighbors.get(index) + .ok_or_else(|| format!("Neighbor index {} out of range (have {})", index, neighbors.len()))?; + set(target)?; + show(store) +} diff --git a/poc-memory/src/lib.rs b/poc-memory/src/lib.rs index 411a75a..60e4cdf 100644 --- a/poc-memory/src/lib.rs +++ b/poc-memory/src/lib.rs @@ -16,6 +16,7 @@ pub mod query; pub mod transcript; pub mod neuro; pub mod counters; +pub mod cursor; // Agent layer (LLM-powered operations) pub mod agents; diff --git a/poc-memory/src/main.rs b/poc-memory/src/main.rs index 0778ab2..e1c1849 100644 --- a/poc-memory/src/main.rs +++ b/poc-memory/src/main.rs @@ -199,6 +199,12 @@ EXAMPLES: #[command(subcommand, name = "graph")] GraphCmd(GraphCmd), + // ── Cursor (spatial memory) ────────────────────────────────────── + + /// Navigate the memory graph with a persistent cursor + #[command(subcommand)] + Cursor(CursorCmd), + // ── Agents ──────────────────────────────────────────────────────── /// Agent and daemon operations @@ -239,6 +245,27 @@ enum NodeCmd { Dump, } +#[derive(Subcommand)] +enum CursorCmd { + /// Show current cursor position with context + Show, + /// Set cursor to a node key + Set { + /// Node key + key: Vec, + }, + /// Move cursor forward in time + Forward, + /// Move cursor backward in time + Back, + /// Move up the digest hierarchy (journal→daily→weekly→monthly) + Up, + /// Move down the digest hierarchy (to first child) + Down, + /// Clear the cursor + Clear, +} + #[derive(Subcommand)] enum JournalCmd { /// Write a journal entry to the store @@ -730,6 +757,9 @@ fn main() { => cmd_organize(&term, threshold, key_only, anchor), }, + // Cursor + Command::Cursor(sub) => cmd_cursor(sub), + // Agent Command::Agent(sub) => match sub { AgentCmd::Daemon(sub) => cmd_daemon(sub), @@ -2167,6 +2197,45 @@ fn cmd_load_context(stats: bool) -> Result<(), String> { Ok(()) } +fn cmd_cursor(sub: CursorCmd) -> Result<(), String> { + match sub { + CursorCmd::Show => { + let store = store::Store::load()?; + cursor::show(&store) + } + CursorCmd::Set { key } => { + if key.is_empty() { + return Err("cursor set requires a key".into()); + } + let key = key.join(" "); + let store = store::Store::load()?; + let bare = store::strip_md_suffix(&key); + if !store.nodes.contains_key(&bare) { + return Err(format!("Node not found: {}", bare)); + } + cursor::set(&bare)?; + cursor::show(&store) + } + CursorCmd::Forward => { + let store = store::Store::load()?; + cursor::move_temporal(&store, true) + } + CursorCmd::Back => { + let store = store::Store::load()?; + cursor::move_temporal(&store, false) + } + CursorCmd::Up => { + let store = store::Store::load()?; + cursor::move_up(&store) + } + CursorCmd::Down => { + let store = store::Store::load()?; + cursor::move_down(&store) + } + CursorCmd::Clear => cursor::clear(), + } +} + fn cmd_render(key: &[String]) -> Result<(), String> { if key.is_empty() { return Err("render requires a key".into());