147 lines
4.7 KiB
Rust
147 lines
4.7 KiB
Rust
|
|
// agent/memory.rs — Agent's live view of memory nodes
|
||
|
|
//
|
||
|
|
// MemoryNode is the agent's in-memory representation of a loaded
|
||
|
|
// graph node. Unlike the store's Node (which has all the metadata),
|
||
|
|
// this holds what the agent needs: the key, rendered content, and
|
||
|
|
// links for navigation. The agent's context window tracks which
|
||
|
|
// MemoryNodes are currently loaded.
|
||
|
|
|
||
|
|
use crate::store::Store;
|
||
|
|
|
||
|
|
/// A memory node loaded into the agent's working memory.
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct MemoryNode {
|
||
|
|
pub key: String,
|
||
|
|
pub content: String,
|
||
|
|
pub links: Vec<Link>,
|
||
|
|
/// Version from the store — used for change detection.
|
||
|
|
pub version: u32,
|
||
|
|
/// Weight in the graph.
|
||
|
|
pub weight: f32,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A link to a neighbor node.
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct Link {
|
||
|
|
pub target: String,
|
||
|
|
pub strength: f32,
|
||
|
|
/// Whether this link target is already referenced inline in the content.
|
||
|
|
pub inline: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl MemoryNode {
|
||
|
|
/// Load a node from the store by key. Returns None if not found.
|
||
|
|
pub fn load(key: &str) -> Option<Self> {
|
||
|
|
let store = Store::load().ok()?;
|
||
|
|
Self::from_store(&store, key)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Load from an already-open store.
|
||
|
|
pub fn from_store(store: &Store, key: &str) -> Option<Self> {
|
||
|
|
let node = store.nodes.get(key)?;
|
||
|
|
|
||
|
|
// Collect neighbor strengths
|
||
|
|
let mut neighbors: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
|
||
|
|
for r in &store.relations {
|
||
|
|
if r.deleted { continue; }
|
||
|
|
if r.source_key == key {
|
||
|
|
let e = neighbors.entry(&r.target_key).or_insert(0.0);
|
||
|
|
*e = e.max(r.strength);
|
||
|
|
} else if r.target_key == key {
|
||
|
|
let e = neighbors.entry(&r.source_key).or_insert(0.0);
|
||
|
|
*e = e.max(r.strength);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let mut links: Vec<Link> = neighbors.into_iter()
|
||
|
|
.map(|(target, strength)| Link {
|
||
|
|
inline: node.content.contains(target),
|
||
|
|
target: target.to_string(),
|
||
|
|
strength,
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
links.sort_by(|a, b| b.strength.total_cmp(&a.strength));
|
||
|
|
|
||
|
|
Some(MemoryNode {
|
||
|
|
key: key.to_string(),
|
||
|
|
content: node.content.clone(),
|
||
|
|
links,
|
||
|
|
version: node.version,
|
||
|
|
weight: node.weight,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Render for inclusion in the context window.
|
||
|
|
pub fn render(&self) -> String {
|
||
|
|
let mut out = self.content.clone();
|
||
|
|
|
||
|
|
// Footer: links not already referenced inline
|
||
|
|
let footer_links: Vec<&Link> = self.links.iter()
|
||
|
|
.filter(|l| !l.inline)
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
if !footer_links.is_empty() {
|
||
|
|
let total = footer_links.len();
|
||
|
|
out.push_str("\n\n---\nLinks:");
|
||
|
|
for link in footer_links.iter().take(15) {
|
||
|
|
out.push_str(&format!("\n ({:.2}) `poc-memory render {}`",
|
||
|
|
link.strength, link.target));
|
||
|
|
}
|
||
|
|
if total > 15 {
|
||
|
|
out.push_str(&format!("\n ... and {} more (`poc-memory graph link {}`)",
|
||
|
|
total - 15, self.key));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Write content to the store and return an updated MemoryNode.
|
||
|
|
pub fn write(key: &str, content: &str, provenance: Option<&str>) -> Result<Self, String> {
|
||
|
|
let prov = provenance.unwrap_or("manual");
|
||
|
|
let mut store = Store::load()?;
|
||
|
|
store.upsert_provenance(key, content, prov)?;
|
||
|
|
store.save()?;
|
||
|
|
|
||
|
|
Self::from_store(&store, key)
|
||
|
|
.ok_or_else(|| format!("wrote {} but failed to load back", key))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Search for nodes matching a query. Returns lightweight results.
|
||
|
|
pub fn search(query: &str) -> Result<Vec<SearchResult>, String> {
|
||
|
|
let store = Store::load()?;
|
||
|
|
let results = crate::search::search(query, &store);
|
||
|
|
|
||
|
|
Ok(results.into_iter().take(20).map(|hit| SearchResult {
|
||
|
|
key: hit.key.clone(),
|
||
|
|
score: hit.activation as f32,
|
||
|
|
snippet: hit.snippet.unwrap_or_default(),
|
||
|
|
}).collect())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Mark a node as used (boosts weight).
|
||
|
|
pub fn mark_used(key: &str) -> Result<String, String> {
|
||
|
|
let mut store = Store::load()?;
|
||
|
|
if !store.nodes.contains_key(key) {
|
||
|
|
return Err(format!("node not found: {}", key));
|
||
|
|
}
|
||
|
|
store.mark_used(key);
|
||
|
|
store.save()?;
|
||
|
|
Ok(format!("marked {} as used", key))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A search result — lightweight, not a full node load.
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct SearchResult {
|
||
|
|
pub key: String,
|
||
|
|
pub score: f32,
|
||
|
|
pub snippet: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl SearchResult {
|
||
|
|
/// Format for display.
|
||
|
|
pub fn render(&self) -> String {
|
||
|
|
format!("({:.2}) {} — {}", self.score, self.key, self.snippet)
|
||
|
|
}
|
||
|
|
}
|