- Remove MEMORY_FILES constant from identity.rs - Add ContextGroup struct for deserializing from config - Load context_groups from ~/.config/poc-agent/config.json5 - Check ~/.config/poc-agent/ first for identity files, then project/global - Debug screen now shows what's actually configured This eliminates the hardcoded duplication and makes the debug output match what's in the config file.
328 lines
12 KiB
Rust
328 lines
12 KiB
Rust
// Mutation operations on the store
|
|
//
|
|
// CRUD (upsert, delete, modify), feedback tracking (mark_used, mark_wrong),
|
|
// maintenance (decay, fix_categories, cap_degree), and graph metrics.
|
|
|
|
use super::types::*;
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
tokio::task_local! {
|
|
/// Task-scoped provenance for agent writes. Set by the daemon before
|
|
/// running an agent's tool calls, so all writes within that task are
|
|
/// automatically attributed to the agent.
|
|
pub static TASK_PROVENANCE: String;
|
|
}
|
|
|
|
/// Provenance priority: task_local (agent context) > env var > "manual".
|
|
fn current_provenance() -> String {
|
|
TASK_PROVENANCE.try_with(|p| p.clone())
|
|
.or_else(|_| std::env::var("POC_PROVENANCE").map_err(|_| ()))
|
|
.unwrap_or_else(|_| "manual".to_string())
|
|
}
|
|
|
|
impl Store {
|
|
/// Add or update a node (appends to log + updates cache).
|
|
/// Holds StoreLock across refresh + check + write to prevent duplicate UUIDs.
|
|
pub fn upsert_node(&mut self, mut node: Node) -> Result<(), String> {
|
|
let _lock = StoreLock::acquire()?;
|
|
self.refresh_nodes()?;
|
|
|
|
if let Some(existing) = self.nodes.get(&node.key) {
|
|
node.uuid = existing.uuid;
|
|
node.version = existing.version + 1;
|
|
}
|
|
self.append_nodes_unlocked(&[node.clone()])?;
|
|
self.uuid_to_key.insert(node.uuid, node.key.clone());
|
|
self.nodes.insert(node.key.clone(), node);
|
|
Ok(())
|
|
}
|
|
|
|
/// Add a relation (appends to log + updates cache)
|
|
pub fn add_relation(&mut self, rel: Relation) -> Result<(), String> {
|
|
self.append_relations(std::slice::from_ref(&rel))?;
|
|
self.relations.push(rel);
|
|
Ok(())
|
|
}
|
|
|
|
/// Upsert a node: update if exists (and content changed), create if not.
|
|
/// Returns: "created", "updated", or "unchanged".
|
|
///
|
|
/// Provenance is determined by the POC_PROVENANCE env var if set,
|
|
/// otherwise defaults to Manual.
|
|
pub fn upsert(&mut self, key: &str, content: &str) -> Result<&'static str, String> {
|
|
let prov = current_provenance();
|
|
self.upsert_provenance(key, content, &prov)
|
|
}
|
|
|
|
/// Upsert with explicit provenance (for agent-created nodes).
|
|
/// Holds StoreLock across refresh + check + write to prevent duplicate UUIDs.
|
|
pub fn upsert_provenance(&mut self, key: &str, content: &str, provenance: &str) -> Result<&'static str, String> {
|
|
let _lock = StoreLock::acquire()?;
|
|
self.refresh_nodes()?;
|
|
|
|
if let Some(existing) = self.nodes.get(key) {
|
|
if existing.content == content {
|
|
return Ok("unchanged");
|
|
}
|
|
let mut node = existing.clone();
|
|
node.content = content.to_string();
|
|
node.provenance = provenance.to_string();
|
|
node.timestamp = now_epoch();
|
|
node.version += 1;
|
|
self.append_nodes_unlocked(std::slice::from_ref(&node))?;
|
|
self.nodes.insert(key.to_string(), node);
|
|
Ok("updated")
|
|
} else {
|
|
let mut node = new_node(key, content);
|
|
node.provenance = provenance.to_string();
|
|
self.append_nodes_unlocked(std::slice::from_ref(&node))?;
|
|
self.uuid_to_key.insert(node.uuid, node.key.clone());
|
|
self.nodes.insert(key.to_string(), node);
|
|
Ok("created")
|
|
}
|
|
}
|
|
|
|
/// Soft-delete a node (appends deleted version, removes from cache).
|
|
/// Holds StoreLock across refresh + write to see concurrent creates.
|
|
pub fn delete_node(&mut self, key: &str) -> Result<(), String> {
|
|
let _lock = StoreLock::acquire()?;
|
|
self.refresh_nodes()?;
|
|
|
|
let prov = current_provenance();
|
|
|
|
let node = self.nodes.get(key)
|
|
.ok_or_else(|| format!("No node '{}'", key))?;
|
|
let mut deleted = node.clone();
|
|
deleted.deleted = true;
|
|
deleted.version += 1;
|
|
deleted.provenance = prov;
|
|
deleted.timestamp = now_epoch();
|
|
self.append_nodes_unlocked(std::slice::from_ref(&deleted))?;
|
|
self.nodes.remove(key);
|
|
Ok(())
|
|
}
|
|
|
|
/// Rename a node: change its key, update debug strings on all edges.
|
|
///
|
|
/// Graph edges (source/target UUIDs) are unaffected — they're already
|
|
/// UUID-based. We update the human-readable source_key/target_key strings
|
|
/// on relations, and created_at is preserved untouched.
|
|
///
|
|
/// Appends: (new_key, v+1) + (old_key, deleted, v+1) + updated relations.
|
|
/// Holds StoreLock across refresh + write to prevent races.
|
|
pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> Result<(), String> {
|
|
if old_key == new_key {
|
|
return Ok(());
|
|
}
|
|
|
|
let _lock = StoreLock::acquire()?;
|
|
self.refresh_nodes()?;
|
|
|
|
if self.nodes.contains_key(new_key) {
|
|
return Err(format!("Key '{}' already exists", new_key));
|
|
}
|
|
let node = self.nodes.get(old_key)
|
|
.ok_or_else(|| format!("No node '{}'", old_key))?
|
|
.clone();
|
|
|
|
let prov = current_provenance();
|
|
|
|
// New version under the new key
|
|
let mut renamed = node.clone();
|
|
renamed.key = new_key.to_string();
|
|
renamed.version += 1;
|
|
renamed.provenance = prov.clone();
|
|
renamed.timestamp = now_epoch();
|
|
|
|
// Deletion record for the old key (same UUID, independent version counter)
|
|
let mut tombstone = node.clone();
|
|
tombstone.deleted = true;
|
|
tombstone.version += 1;
|
|
tombstone.provenance = prov;
|
|
tombstone.timestamp = now_epoch();
|
|
|
|
// Collect affected relations and update their debug key strings
|
|
let updated_rels: Vec<_> = self.relations.iter()
|
|
.filter(|r| r.source_key == old_key || r.target_key == old_key)
|
|
.map(|r| {
|
|
let mut r = r.clone();
|
|
r.version += 1;
|
|
if r.source_key == old_key { r.source_key = new_key.to_string(); }
|
|
if r.target_key == old_key { r.target_key = new_key.to_string(); }
|
|
r
|
|
})
|
|
.collect();
|
|
|
|
// Persist under single lock
|
|
self.append_nodes_unlocked(&[renamed.clone(), tombstone])?;
|
|
if !updated_rels.is_empty() {
|
|
self.append_relations_unlocked(&updated_rels)?;
|
|
}
|
|
|
|
// Update in-memory cache
|
|
self.nodes.remove(old_key);
|
|
self.uuid_to_key.insert(renamed.uuid, new_key.to_string());
|
|
self.nodes.insert(new_key.to_string(), renamed);
|
|
for updated in &updated_rels {
|
|
if let Some(r) = self.relations.iter_mut().find(|r| r.uuid == updated.uuid) {
|
|
r.source_key = updated.source_key.clone();
|
|
r.target_key = updated.target_key.clone();
|
|
r.version = updated.version;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Modify a node in-place, bump version, and persist to capnp log.
|
|
fn modify_node(&mut self, key: &str, f: impl FnOnce(&mut Node)) -> Result<(), String> {
|
|
let node = self.nodes.get_mut(key)
|
|
.ok_or_else(|| format!("No node '{}'", key))?;
|
|
f(node);
|
|
node.version += 1;
|
|
let node = node.clone();
|
|
self.append_nodes(&[node])
|
|
}
|
|
|
|
pub fn mark_used(&mut self, key: &str) {
|
|
let boost = self.params.use_boost as f32;
|
|
let _ = self.modify_node(key, |n| {
|
|
n.uses += 1;
|
|
n.weight = (n.weight + boost).min(1.0);
|
|
if n.spaced_repetition_interval < 30 {
|
|
n.spaced_repetition_interval = match n.spaced_repetition_interval {
|
|
1 => 3, 3 => 7, 7 => 14, 14 => 30, _ => 30,
|
|
};
|
|
}
|
|
n.last_replayed = now_epoch();
|
|
});
|
|
}
|
|
|
|
pub fn mark_wrong(&mut self, key: &str, _ctx: Option<&str>) {
|
|
let _ = self.modify_node(key, |n| {
|
|
n.wrongs += 1;
|
|
n.weight = (n.weight - 0.1).max(0.0);
|
|
n.spaced_repetition_interval = 1;
|
|
});
|
|
}
|
|
|
|
/// Adjust edge strength between two nodes by a delta.
|
|
/// Clamps to [0.05, 0.95]. Returns (old_strength, new_strength, edges_modified).
|
|
pub fn adjust_edge_strength(&mut self, key_a: &str, key_b: &str, delta: f32) -> (f32, f32, usize) {
|
|
let mut old = 0.0f32;
|
|
let mut new = 0.0f32;
|
|
let mut count = 0;
|
|
for rel in &mut self.relations {
|
|
if rel.deleted { continue; }
|
|
if (rel.source_key == key_a && rel.target_key == key_b)
|
|
|| (rel.source_key == key_b && rel.target_key == key_a)
|
|
{
|
|
old = rel.strength;
|
|
rel.strength = (rel.strength + delta).clamp(0.05, 0.95);
|
|
new = rel.strength;
|
|
rel.version += 1;
|
|
count += 1;
|
|
}
|
|
}
|
|
(old, new, count)
|
|
}
|
|
|
|
pub fn record_gap(&mut self, desc: &str) {
|
|
self.gaps.push(GapRecord {
|
|
description: desc.to_string(),
|
|
timestamp: today(),
|
|
});
|
|
}
|
|
|
|
/// Cap node degree by soft-deleting edges from mega-hubs.
|
|
pub fn cap_degree(&mut self, max_degree: usize) -> Result<(usize, usize), String> {
|
|
let mut node_degree: HashMap<String, usize> = HashMap::new();
|
|
for rel in &self.relations {
|
|
if rel.deleted { continue; }
|
|
*node_degree.entry(rel.source_key.clone()).or_default() += 1;
|
|
*node_degree.entry(rel.target_key.clone()).or_default() += 1;
|
|
}
|
|
|
|
let mut node_edges: HashMap<String, Vec<usize>> = HashMap::new();
|
|
for (i, rel) in self.relations.iter().enumerate() {
|
|
if rel.deleted { continue; }
|
|
node_edges.entry(rel.source_key.clone()).or_default().push(i);
|
|
node_edges.entry(rel.target_key.clone()).or_default().push(i);
|
|
}
|
|
|
|
let mut to_delete: HashSet<usize> = HashSet::new();
|
|
let mut hubs_capped = 0;
|
|
|
|
for (_key, edge_indices) in &node_edges {
|
|
let active: Vec<usize> = edge_indices.iter()
|
|
.filter(|&&i| !to_delete.contains(&i))
|
|
.copied()
|
|
.collect();
|
|
if active.len() <= max_degree { continue; }
|
|
|
|
let mut auto_indices: Vec<(usize, f32)> = Vec::new();
|
|
let mut link_indices: Vec<(usize, usize)> = Vec::new();
|
|
for &i in &active {
|
|
let rel = &self.relations[i];
|
|
if rel.rel_type == RelationType::Auto {
|
|
auto_indices.push((i, rel.strength));
|
|
} else {
|
|
let other = if &rel.source_key == _key {
|
|
&rel.target_key
|
|
} else {
|
|
&rel.source_key
|
|
};
|
|
let other_deg = node_degree.get(other).copied().unwrap_or(0);
|
|
link_indices.push((i, other_deg));
|
|
}
|
|
}
|
|
|
|
let excess = active.len() - max_degree;
|
|
|
|
auto_indices.sort_by(|a, b| a.1.total_cmp(&b.1));
|
|
let auto_prune = excess.min(auto_indices.len());
|
|
for &(i, _) in auto_indices.iter().take(auto_prune) {
|
|
to_delete.insert(i);
|
|
}
|
|
|
|
let remaining_excess = excess.saturating_sub(auto_prune);
|
|
if remaining_excess > 0 {
|
|
link_indices.sort_by(|a, b| b.1.cmp(&a.1));
|
|
let link_prune = remaining_excess.min(link_indices.len());
|
|
for &(i, _) in link_indices.iter().take(link_prune) {
|
|
to_delete.insert(i);
|
|
}
|
|
}
|
|
|
|
hubs_capped += 1;
|
|
}
|
|
|
|
let mut pruned_rels = Vec::new();
|
|
for &i in &to_delete {
|
|
self.relations[i].deleted = true;
|
|
self.relations[i].version += 1;
|
|
pruned_rels.push(self.relations[i].clone());
|
|
}
|
|
|
|
if !pruned_rels.is_empty() {
|
|
self.append_relations(&pruned_rels)?;
|
|
}
|
|
|
|
self.relations.retain(|r| !r.deleted);
|
|
|
|
Ok((hubs_capped, to_delete.len()))
|
|
}
|
|
|
|
/// Update graph-derived fields on all nodes
|
|
pub fn update_graph_metrics(&mut self) {
|
|
let g = self.build_graph();
|
|
let communities = g.communities();
|
|
|
|
for (key, node) in &mut self.nodes {
|
|
node.community_id = communities.get(key).copied();
|
|
node.clustering_coefficient = Some(g.clustering_coefficient(key));
|
|
node.degree = Some(g.degree(key) as u32);
|
|
}
|
|
}
|
|
}
|