organize: exploratory agent with neighbor context
Previously the organize agent received a pre-computed cluster from a term search — 69% of runs produced 0 actions because the same clusters kept being found via different entry points. Now: seed nodes shown with content previews and neighbor lists. Agent uses tools (render, query neighbors, search) to explore outward and discover what needs organizing. Visit filter set to 24h cooldown. Prompt rewritten to encourage active exploration rather than static cluster analysis.
This commit is contained in:
parent
7c1b96293f
commit
958cf9d041
2 changed files with 85 additions and 97 deletions
|
|
@ -165,93 +165,62 @@ fn resolve(
|
|||
}
|
||||
|
||||
"organize" => {
|
||||
// Run cluster diagnostic for the query term
|
||||
// The query field of the agent def holds the search term
|
||||
let term = if keys.is_empty() { "" } else { &keys[0] };
|
||||
if term.is_empty() {
|
||||
return Some(Resolved { text: "(no term provided)".into(), keys: vec![] });
|
||||
}
|
||||
let term_lower = term.to_lowercase();
|
||||
// Show seed nodes with their neighbors for exploratory organizing
|
||||
use crate::store::NodeType;
|
||||
|
||||
let mut cluster: Vec<(String, String, bool)> = Vec::new(); // (key, content, is_journal)
|
||||
for (key, node) in &store.nodes {
|
||||
if node.deleted { continue; }
|
||||
if !key.to_lowercase().contains(&term_lower) { continue; }
|
||||
// Skip digest/system nodes entirely
|
||||
match node.node_type {
|
||||
NodeType::EpisodicDaily | NodeType::EpisodicWeekly
|
||||
| NodeType::EpisodicMonthly => continue,
|
||||
_ => {}
|
||||
}
|
||||
// Skip internal prefixes
|
||||
if key.starts_with('_') { continue; }
|
||||
let is_journal = node.node_type == NodeType::EpisodicSession;
|
||||
cluster.push((key.clone(), node.content.clone(), is_journal));
|
||||
}
|
||||
cluster.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
// Cap cluster size — agent has tools to explore more if needed
|
||||
if cluster.len() > 20 {
|
||||
cluster.truncate(20);
|
||||
}
|
||||
|
||||
// Similarity pairs (need (key, content) tuples)
|
||||
let pair_input: Vec<(String, String)> = cluster.iter()
|
||||
.map(|(k, c, _)| (k.clone(), c.clone())).collect();
|
||||
let pairs = crate::similarity::pairwise_similar(&pair_input, 0.4);
|
||||
|
||||
// Helper: shell-quote keys containing #
|
||||
let sq = |k: &str| -> String {
|
||||
if k.contains('#') { format!("'{}'", k) } else { k.to_string() }
|
||||
};
|
||||
|
||||
let mut text = format!("### Cluster: '{}' ({} nodes)\n\n", term, cluster.len());
|
||||
|
||||
// Similarity report
|
||||
if !pairs.is_empty() {
|
||||
text.push_str("#### Similarity scores\n\n");
|
||||
for (a, b, sim) in &pairs {
|
||||
text.push_str(&format!(" [{:.3}] {} ↔ {}\n", sim, sq(a), sq(b)));
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
|
||||
// Connectivity
|
||||
let cluster_keys: std::collections::HashSet<&str> = cluster.iter()
|
||||
.map(|(k,_,_)| k.as_str()).collect();
|
||||
let mut best_hub: Option<(&str, usize)> = None;
|
||||
for key in &cluster_keys {
|
||||
let intra = graph.neighbor_keys(key).iter()
|
||||
.filter(|n| cluster_keys.contains(*n))
|
||||
.count();
|
||||
if best_hub.is_none() || intra > best_hub.unwrap().1 {
|
||||
best_hub = Some((key, intra));
|
||||
}
|
||||
}
|
||||
if let Some((hub, deg)) = best_hub {
|
||||
text.push_str(&format!("#### Hub: {} (intra-cluster degree {})\n\n", sq(hub), deg));
|
||||
let hub_nbrs = graph.neighbor_keys(hub);
|
||||
for key in &cluster_keys {
|
||||
if *key == hub { continue; }
|
||||
if !hub_nbrs.contains(*key) {
|
||||
text.push_str(&format!(" NOT linked to hub: {}\n", sq(key)));
|
||||
}
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
|
||||
// Full node contents
|
||||
text.push_str("#### Node contents\n\n");
|
||||
let mut text = format!("### Seed nodes ({} starting points)\n\n", keys.len());
|
||||
let mut result_keys = Vec::new();
|
||||
for (key, content, is_journal) in &cluster {
|
||||
let words = content.split_whitespace().count();
|
||||
let tag = if *is_journal { " [JOURNAL — no delete]" } else { "" };
|
||||
text.push_str(&format!("##### {}{} ({} words)\n\n{}\n\n---\n\n",
|
||||
sq(key), tag, words, content));
|
||||
|
||||
for key in keys {
|
||||
let Some(node) = store.nodes.get(key) else { continue };
|
||||
if node.deleted { continue; }
|
||||
|
||||
let is_journal = node.node_type == NodeType::EpisodicSession;
|
||||
let tag = if is_journal { " [JOURNAL — no delete]" } else { "" };
|
||||
let words = node.content.split_whitespace().count();
|
||||
|
||||
text.push_str(&format!("#### {}{} ({} words)\n\n", sq(key), tag, words));
|
||||
|
||||
// Show first ~200 words of content as preview
|
||||
let preview: String = node.content.split_whitespace()
|
||||
.take(200).collect::<Vec<_>>().join(" ");
|
||||
if words > 200 {
|
||||
text.push_str(&format!("{}...\n\n", preview));
|
||||
} else {
|
||||
text.push_str(&format!("{}\n\n", node.content));
|
||||
}
|
||||
|
||||
// Show neighbors with strengths
|
||||
let neighbors = graph.neighbors(key);
|
||||
if !neighbors.is_empty() {
|
||||
text.push_str("**Neighbors:**\n");
|
||||
for (nbr, strength) in neighbors.iter().take(15) {
|
||||
let nbr_type = store.nodes.get(nbr.as_str())
|
||||
.map(|n| match n.node_type {
|
||||
NodeType::EpisodicSession => " [journal]",
|
||||
NodeType::EpisodicDaily => " [daily]",
|
||||
_ => "",
|
||||
})
|
||||
.unwrap_or("");
|
||||
text.push_str(&format!(" [{:.1}] {}{}\n", strength, sq(nbr), nbr_type));
|
||||
}
|
||||
if neighbors.len() > 15 {
|
||||
text.push_str(&format!(" ... and {} more\n", neighbors.len() - 15));
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
|
||||
text.push_str("---\n\n");
|
||||
result_keys.push(key.clone());
|
||||
}
|
||||
|
||||
text.push_str("Use `poc-memory render KEY` and `poc-memory query \"neighbors('KEY')\"` to explore further.\n");
|
||||
|
||||
Some(Resolved { text, keys: result_keys })
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue