consciousness/poc-memory/src/cursor.rs
ProofOfConcept 7c1b96293f cursor: spatial memory navigation
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.
2026-03-13 22:31:23 -04:00

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