Persistent cursor into the knowledge graph with navigation: - temporal: forward/back among same-type nodes by timestamp - hierarchical: up/down the digest tree (journal→daily→weekly→monthly) - spatial: graph neighbor display at every position The cursor file (~/.claude/memory/cursor) holds a single node key. Show displays: temporal arrows, hierarchy links, semantic neighbors, and full content. Date extraction from both timestamps and key names handles the mixed-timestamp data gracefully. This is the start of place cells — spatial awareness of position in your own knowledge.
339 lines
12 KiB
Rust
339 lines
12 KiB
Rust
// 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<String> {
|
|
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<String>, Option<String>) {
|
|
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<String> {
|
|
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<String> {
|
|
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::<String>();
|
|
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)
|
|
}
|