naming agent: resolve node names before creation
Any time an agent creates a new node (WRITE_NODE) or the fact miner stores extracted facts, a naming sub-agent now checks for conflicts and ensures the key is meaningful: - find_conflicts() searches existing nodes via component matching - Haiku LLM decides: CREATE (good name), RENAME (better name), or MERGE_INTO (fold into existing node) - WriteNode actions may be converted to Refine on MERGE_INTO Also updates the rename agent to handle _facts-<UUID> nodes — these are no longer skipped, and the prompt explains how to name them based on their domain/claim content.
This commit is contained in:
parent
15dedea322
commit
b62fffc326
5 changed files with 281 additions and 6 deletions
62
poc-memory/agents/naming.agent
Normal file
62
poc-memory/agents/naming.agent
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
{"agent":"naming","query":"","model":"haiku","schedule":""}
|
||||||
|
# Naming Agent — Node Key Resolution
|
||||||
|
|
||||||
|
You are given a proposed new node (key + content) and a list of existing
|
||||||
|
nodes that might overlap with it. Decide what to do:
|
||||||
|
|
||||||
|
1. **CREATE** — the proposed key is good and there's no meaningful overlap
|
||||||
|
with existing nodes. The name is descriptive and specific.
|
||||||
|
|
||||||
|
2. **RENAME** — the content is unique but the proposed key is bad (too
|
||||||
|
generic, uses a UUID, is truncated, or doesn't describe the content).
|
||||||
|
Suggest a better key.
|
||||||
|
|
||||||
|
3. **MERGE_INTO** — an existing node already covers this content. The new
|
||||||
|
content should be folded into the existing node instead of creating a
|
||||||
|
duplicate.
|
||||||
|
|
||||||
|
## Naming conventions
|
||||||
|
|
||||||
|
Good keys are 2-5 words in kebab-case, optionally with a `#` subtopic:
|
||||||
|
- `oscillatory-coupling` — a concept
|
||||||
|
- `patterns#theta-gamma-nesting` — a pattern within patterns
|
||||||
|
- `skills#btree-debugging` — a skill
|
||||||
|
- `kent-medellin` — a fact about kent
|
||||||
|
- `irc-access` — how to access IRC
|
||||||
|
|
||||||
|
Bad keys:
|
||||||
|
- `_facts-ec29bdaa-0a58-465f-ad5e-d89e62d9c583` — UUID garbage
|
||||||
|
- `consciousness` — too generic
|
||||||
|
- `journal#j-2026-02-28t03-07-i-told-him-about-the-dream--the-violin` — truncated auto-slug
|
||||||
|
- `new-node-1` — meaningless
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
Respond with exactly ONE line:
|
||||||
|
|
||||||
|
```
|
||||||
|
CREATE proposed_key
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```
|
||||||
|
RENAME better_key
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```
|
||||||
|
MERGE_INTO existing_key
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing else. No explanation. One line.
|
||||||
|
|
||||||
|
## Proposed node
|
||||||
|
|
||||||
|
Key: `{{proposed_key}}`
|
||||||
|
|
||||||
|
Content:
|
||||||
|
```
|
||||||
|
{{proposed_content}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Existing nodes that might overlap
|
||||||
|
|
||||||
|
{{conflicts}}
|
||||||
|
|
@ -9,6 +9,7 @@ You are a memory maintenance agent that gives nodes better names.
|
||||||
Many nodes have auto-generated keys that are opaque or truncated:
|
Many nodes have auto-generated keys that are opaque or truncated:
|
||||||
- Journal entries: `journal#j-2026-02-28t03-07-i-told-him-about-the-dream--the-violin-room-the-af`
|
- Journal entries: `journal#j-2026-02-28t03-07-i-told-him-about-the-dream--the-violin-room-the-af`
|
||||||
- Mined transcripts: `_mined-transcripts#f-80a7b321-2caa-451a-bc5c-6565009f94eb.143`
|
- Mined transcripts: `_mined-transcripts#f-80a7b321-2caa-451a-bc5c-6565009f94eb.143`
|
||||||
|
- Extracted facts: `_facts-ec29bdaa-0a58-465f-ad5e-d89e62d9c583`
|
||||||
|
|
||||||
These names are terrible for search — semantic names dramatically improve
|
These names are terrible for search — semantic names dramatically improve
|
||||||
retrieval.
|
retrieval.
|
||||||
|
|
@ -24,10 +25,15 @@ retrieval.
|
||||||
- Extract date from content if available, otherwise use created_at
|
- Extract date from content if available, otherwise use created_at
|
||||||
- Same 3-5 word semantic slug
|
- Same 3-5 word semantic slug
|
||||||
|
|
||||||
|
### Extracted facts: `domain-specific-topic`
|
||||||
|
- Read the facts JSON — the `domain` and `claim` fields tell you what it's about
|
||||||
|
- Group by dominant theme, name accordingly
|
||||||
|
- Examples: `identity-irc-config`, `kent-medellin-background`, `memory-compaction-behavior`
|
||||||
|
|
||||||
### Skip these — already well-named:
|
### Skip these — already well-named:
|
||||||
- Keys with semantic names (patterns#, practices#, skills#, etc.)
|
- Keys with semantic names (patterns#, practices#, skills#, etc.)
|
||||||
- Keys shorter than 60 characters
|
- Keys shorter than 60 characters
|
||||||
- System keys (_consolidation-*, _facts-*)
|
- System keys (_consolidation-*)
|
||||||
|
|
||||||
## What to output
|
## What to output
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ pub fn mine_and_store(
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| "unknown".into());
|
.unwrap_or_else(|| "unknown".into());
|
||||||
|
|
||||||
let key = format!("_facts-{}", filename.trim_end_matches(".jsonl"));
|
let proposed_key = format!("_facts-{}", filename.trim_end_matches(".jsonl"));
|
||||||
|
|
||||||
// Always write a marker so we don't re-queue empty transcripts
|
// Always write a marker so we don't re-queue empty transcripts
|
||||||
let json = if facts.is_empty() {
|
let json = if facts.is_empty() {
|
||||||
|
|
@ -256,6 +256,25 @@ pub fn mine_and_store(
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut store = store::Store::load()?;
|
let mut store = store::Store::load()?;
|
||||||
|
|
||||||
|
// Run naming resolution to get a good key (and possibly merge into existing)
|
||||||
|
let resolution = super::knowledge::resolve_naming(&store, &proposed_key, &json);
|
||||||
|
let key = match resolution {
|
||||||
|
super::knowledge::NamingResolution::Create(k) => k,
|
||||||
|
super::knowledge::NamingResolution::MergeInto(existing_key) => {
|
||||||
|
// Merge: append facts to existing node's content
|
||||||
|
eprintln!(" Merging facts into existing node: {}", existing_key);
|
||||||
|
if let Some(node) = store.nodes.get(existing_key.as_str()) {
|
||||||
|
let merged = format!("{}\n\n{}", node.content, json);
|
||||||
|
store.upsert_provenance(&existing_key, &merged, Provenance::AgentFactMine)?;
|
||||||
|
store.save()?;
|
||||||
|
return Ok(facts.len());
|
||||||
|
}
|
||||||
|
// Fallback if existing node disappeared
|
||||||
|
proposed_key
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
store.upsert_provenance(&key, &json, Provenance::AgentFactMine)?;
|
store.upsert_provenance(&key, &json, Provenance::AgentFactMine)?;
|
||||||
store.save()?;
|
store.save()?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -348,6 +348,149 @@ fn agent_provenance(agent: &str) -> store::Provenance {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Naming resolution — called before creating any new node
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Resolution from the naming agent.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum NamingResolution {
|
||||||
|
/// Create with the proposed key (or a better one).
|
||||||
|
Create(String),
|
||||||
|
/// Merge content into an existing node instead.
|
||||||
|
MergeInto(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find existing nodes that might conflict with a proposed new node.
|
||||||
|
/// Returns up to `limit` (key, content_preview) pairs.
|
||||||
|
fn find_conflicts(
|
||||||
|
store: &Store,
|
||||||
|
proposed_key: &str,
|
||||||
|
proposed_content: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
// Extract search terms from the key (split on separators) and first ~200 chars of content
|
||||||
|
let mut terms: BTreeMap<String, f64> = BTreeMap::new();
|
||||||
|
for part in proposed_key.split(|c: char| c == '-' || c == '_' || c == '#' || c == '.') {
|
||||||
|
let p = part.to_lowercase();
|
||||||
|
if p.len() >= 3 {
|
||||||
|
terms.insert(p, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add a few content terms
|
||||||
|
let content_terms = crate::search::extract_query_terms(proposed_content, 5);
|
||||||
|
for term in content_terms.split_whitespace() {
|
||||||
|
terms.entry(term.to_string()).or_insert(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if terms.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use component matching to find related nodes
|
||||||
|
let (seeds, _) = crate::search::match_seeds_opts(&terms, store, true, false);
|
||||||
|
|
||||||
|
let mut results: Vec<(String, f64)> = seeds.into_iter()
|
||||||
|
.filter(|(k, _)| k != proposed_key)
|
||||||
|
.collect();
|
||||||
|
results.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||||
|
|
||||||
|
results.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.filter_map(|(key, _)| {
|
||||||
|
let node = store.nodes.get(key.as_str())?;
|
||||||
|
let preview: String = node.content.chars().take(200).collect();
|
||||||
|
Some((key, preview))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the naming prompt for a proposed node.
|
||||||
|
fn format_naming_prompt(
|
||||||
|
proposed_key: &str,
|
||||||
|
proposed_content: &str,
|
||||||
|
conflicts: &[(String, String)],
|
||||||
|
) -> String {
|
||||||
|
let conflict_section = if conflicts.is_empty() {
|
||||||
|
"(no existing nodes found with overlapping content)".to_string()
|
||||||
|
} else {
|
||||||
|
conflicts.iter()
|
||||||
|
.map(|(key, preview)| format!("### `{}`\n\n{}", key, preview))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Truncate content for the prompt (don't send huge nodes to Haiku)
|
||||||
|
let content_preview: String = proposed_content.chars().take(1000).collect();
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"# Naming Agent — Node Key Resolution\n\n\
|
||||||
|
You are given a proposed new node (key + content) and a list of existing\n\
|
||||||
|
nodes that might overlap with it. Decide what to do:\n\n\
|
||||||
|
1. **CREATE** — the proposed key is good and there's no meaningful overlap.\n\
|
||||||
|
2. **RENAME** — the content is unique but the key is bad (UUID, truncated, generic).\n\
|
||||||
|
3. **MERGE_INTO** — an existing node already covers this content.\n\n\
|
||||||
|
Good keys: 2-5 words in kebab-case, optionally with `#` subtopic.\n\
|
||||||
|
Bad keys: UUIDs, single generic words, truncated auto-slugs.\n\n\
|
||||||
|
Respond with exactly ONE line: `CREATE key`, `RENAME better_key`, or `MERGE_INTO existing_key`.\n\n\
|
||||||
|
## Proposed node\n\n\
|
||||||
|
Key: `{}`\n\n\
|
||||||
|
Content:\n```\n{}\n```\n\n\
|
||||||
|
## Existing nodes that might overlap\n\n\
|
||||||
|
{}",
|
||||||
|
proposed_key, content_preview, conflict_section,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse naming agent response.
|
||||||
|
fn parse_naming_response(response: &str) -> Option<NamingResolution> {
|
||||||
|
for line in response.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if let Some(key) = trimmed.strip_prefix("CREATE ") {
|
||||||
|
return Some(NamingResolution::Create(key.trim().to_string()));
|
||||||
|
}
|
||||||
|
if let Some(key) = trimmed.strip_prefix("RENAME ") {
|
||||||
|
return Some(NamingResolution::Create(key.trim().to_string()));
|
||||||
|
}
|
||||||
|
if let Some(key) = trimmed.strip_prefix("MERGE_INTO ") {
|
||||||
|
return Some(NamingResolution::MergeInto(key.trim().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve naming for a proposed WriteNode action.
|
||||||
|
///
|
||||||
|
/// Searches for conflicts, calls the naming LLM (Haiku), and returns
|
||||||
|
/// either a Create (possibly with a better key) or MergeInto resolution.
|
||||||
|
/// On LLM failure, falls through to using the proposed key as-is.
|
||||||
|
pub fn resolve_naming(
|
||||||
|
store: &Store,
|
||||||
|
proposed_key: &str,
|
||||||
|
proposed_content: &str,
|
||||||
|
) -> NamingResolution {
|
||||||
|
let conflicts = find_conflicts(store, proposed_key, proposed_content, 5);
|
||||||
|
let prompt = format_naming_prompt(proposed_key, proposed_content, &conflicts);
|
||||||
|
|
||||||
|
match llm::call_haiku("naming", &prompt) {
|
||||||
|
Ok(response) => {
|
||||||
|
match parse_naming_response(&response) {
|
||||||
|
Some(resolution) => resolution,
|
||||||
|
None => {
|
||||||
|
eprintln!("naming: unparseable response, using proposed key");
|
||||||
|
NamingResolution::Create(proposed_key.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("naming: LLM error ({}), using proposed key", e);
|
||||||
|
NamingResolution::Create(proposed_key.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared agent execution
|
// Shared agent execution
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -360,6 +503,48 @@ pub struct AgentResult {
|
||||||
pub node_keys: Vec<String>,
|
pub node_keys: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve naming for all WriteNode actions in a list.
|
||||||
|
///
|
||||||
|
/// For each WriteNode, calls the naming agent to check for conflicts and
|
||||||
|
/// get a good key. May convert WriteNode → Refine (if MERGE_INTO) or
|
||||||
|
/// update the key (if RENAME/CREATE with different key).
|
||||||
|
pub fn resolve_action_names(store: &Store, actions: Vec<Action>) -> Vec<Action> {
|
||||||
|
actions.into_iter().map(|action| {
|
||||||
|
match &action.kind {
|
||||||
|
ActionKind::WriteNode { key, content, covers } => {
|
||||||
|
match resolve_naming(store, key, content) {
|
||||||
|
NamingResolution::Create(new_key) => {
|
||||||
|
if new_key == *key {
|
||||||
|
action // keep as-is
|
||||||
|
} else {
|
||||||
|
eprintln!("naming: {} → {}", key, new_key);
|
||||||
|
Action {
|
||||||
|
kind: ActionKind::WriteNode {
|
||||||
|
key: new_key,
|
||||||
|
content: content.clone(),
|
||||||
|
covers: covers.clone(),
|
||||||
|
},
|
||||||
|
..action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NamingResolution::MergeInto(existing_key) => {
|
||||||
|
eprintln!("naming: {} → MERGE_INTO {}", key, existing_key);
|
||||||
|
Action {
|
||||||
|
kind: ActionKind::Refine {
|
||||||
|
key: existing_key,
|
||||||
|
content: content.clone(),
|
||||||
|
},
|
||||||
|
..action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => action,
|
||||||
|
}
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a single agent and apply its actions (no depth tracking).
|
/// Run a single agent and apply its actions (no depth tracking).
|
||||||
///
|
///
|
||||||
/// Returns (total_actions, applied_count) or an error.
|
/// Returns (total_actions, applied_count) or an error.
|
||||||
|
|
@ -370,14 +555,15 @@ pub fn run_and_apply(
|
||||||
llm_tag: &str,
|
llm_tag: &str,
|
||||||
) -> Result<(usize, usize), String> {
|
) -> Result<(usize, usize), String> {
|
||||||
let result = run_one_agent(store, agent_name, batch_size, llm_tag)?;
|
let result = run_one_agent(store, agent_name, batch_size, llm_tag)?;
|
||||||
|
let actions = resolve_action_names(store, result.actions);
|
||||||
let ts = store::compact_timestamp();
|
let ts = store::compact_timestamp();
|
||||||
let mut applied = 0;
|
let mut applied = 0;
|
||||||
for action in &result.actions {
|
for action in &actions {
|
||||||
if apply_action(store, action, agent_name, &ts, 0) {
|
if apply_action(store, action, agent_name, &ts, 0) {
|
||||||
applied += 1;
|
applied += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok((result.actions.len(), applied))
|
Ok((actions.len(), applied))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a single agent: build prompt → call LLM → store output → parse actions → record visits.
|
/// Run a single agent: build prompt → call LLM → store output → parse actions → record visits.
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,7 @@ pub fn format_pairs_section(
|
||||||
pub fn format_rename_candidates(store: &Store, count: usize) -> (Vec<String>, String) {
|
pub fn format_rename_candidates(store: &Store, count: usize) -> (Vec<String>, String) {
|
||||||
let mut candidates: Vec<(&str, &crate::store::Node)> = store.nodes.iter()
|
let mut candidates: Vec<(&str, &crate::store::Node)> = store.nodes.iter()
|
||||||
.filter(|(key, _)| {
|
.filter(|(key, _)| {
|
||||||
|
if key.starts_with("_facts-") { return true; }
|
||||||
if key.len() < 60 { return false; }
|
if key.len() < 60 { return false; }
|
||||||
if key.starts_with("journal#j-") { return true; }
|
if key.starts_with("journal#j-") { return true; }
|
||||||
if key.starts_with("_mined-transcripts#f-") { return true; }
|
if key.starts_with("_mined-transcripts#f-") { return true; }
|
||||||
|
|
@ -282,8 +283,9 @@ pub fn format_rename_candidates(store: &Store, count: usize) -> (Vec<String>, St
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
out.push_str(&format!("## Nodes to rename ({} of {} candidates)\n\n",
|
out.push_str(&format!("## Nodes to rename ({} of {} candidates)\n\n",
|
||||||
candidates.len(),
|
candidates.len(),
|
||||||
store.nodes.keys().filter(|k| k.len() >= 60 &&
|
store.nodes.keys().filter(|k| k.starts_with("_facts-") ||
|
||||||
(k.starts_with("journal#j-") || k.starts_with("_mined-transcripts#f-"))).count()));
|
(k.len() >= 60 &&
|
||||||
|
(k.starts_with("journal#j-") || k.starts_with("_mined-transcripts#f-")))).count()));
|
||||||
|
|
||||||
for (key, node) in &candidates {
|
for (key, node) in &candidates {
|
||||||
out.push_str(&format!("### {}\n", key));
|
out.push_str(&format!("### {}\n", key));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue