// 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::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 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) }