surface: tag recent nodes as (new) instead of hiding them
Links to nodes created after the conversation window start are tagged with (new) in memory_render output. The surface prompt tells the agent not to surface these — they're its own recent output, not prior memories. Observe can still see and update them. POC_MEMORIES_OLDER_THAN env var set from the oldest message timestamp in the conversation window. Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
parent
7fc1d60113
commit
27861a44e5
5 changed files with 75 additions and 23 deletions
|
|
@ -107,8 +107,9 @@ pub fn dispatch(name: &str, args: &serde_json::Value, provenance: Option<&str>)
|
||||||
let node = MemoryNode::load(key)
|
let node = MemoryNode::load(key)
|
||||||
.ok_or_else(|| anyhow::anyhow!("node not found: {}", key))?;
|
.ok_or_else(|| anyhow::anyhow!("node not found: {}", key))?;
|
||||||
let mut out = format!("Neighbors of '{}':\n", key);
|
let mut out = format!("Neighbors of '{}':\n", key);
|
||||||
for (target, strength) in &node.links {
|
for (target, strength, is_new) in &node.links {
|
||||||
out.push_str(&format!(" ({:.2}) {}\n", strength, target));
|
let tag = if *is_new { " (new)" } else { "" };
|
||||||
|
out.push_str(&format!(" ({:.2}) {}{}\n", strength, target, tag));
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use super::store::Store;
|
||||||
pub struct MemoryNode {
|
pub struct MemoryNode {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub links: Vec<(String, f32)>, // (target_key, strength)
|
pub links: Vec<(String, f32, bool)>, // (target_key, strength, is_new)
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
pub weight: f32,
|
pub weight: f32,
|
||||||
}
|
}
|
||||||
|
|
@ -27,20 +27,34 @@ impl MemoryNode {
|
||||||
pub fn from_store(store: &Store, key: &str) -> Option<Self> {
|
pub fn from_store(store: &Store, key: &str) -> Option<Self> {
|
||||||
let node = store.nodes.get(key)?;
|
let node = store.nodes.get(key)?;
|
||||||
|
|
||||||
let mut neighbors: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
|
// If set, tag links to nodes created after this timestamp as (new)
|
||||||
|
let older_than: i64 = std::env::var("POC_MEMORIES_OLDER_THAN")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let mut neighbors: std::collections::HashMap<&str, (f32, bool)> = std::collections::HashMap::new();
|
||||||
for r in &store.relations {
|
for r in &store.relations {
|
||||||
if r.deleted { continue; }
|
if r.deleted { continue; }
|
||||||
if r.source_key == key {
|
let neighbor_key = if r.source_key == key {
|
||||||
let e = neighbors.entry(&r.target_key).or_insert(0.0);
|
&r.target_key
|
||||||
*e = e.max(r.strength);
|
|
||||||
} else if r.target_key == key {
|
} else if r.target_key == key {
|
||||||
let e = neighbors.entry(&r.source_key).or_insert(0.0);
|
&r.source_key
|
||||||
*e = e.max(r.strength);
|
} else {
|
||||||
}
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_new = older_than > 0 && store.nodes.get(neighbor_key.as_str())
|
||||||
|
.map(|n| n.created_at > older_than)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let e = neighbors.entry(neighbor_key.as_str()).or_insert((0.0, false));
|
||||||
|
e.0 = e.0.max(r.strength);
|
||||||
|
e.1 = e.1 || is_new;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut links: Vec<(String, f32)> = neighbors.into_iter()
|
let mut links: Vec<(String, f32, bool)> = neighbors.into_iter()
|
||||||
.map(|(k, s)| (k.to_string(), s))
|
.map(|(k, (s, new))| (k.to_string(), s, new))
|
||||||
.collect();
|
.collect();
|
||||||
links.sort_by(|a, b| b.1.total_cmp(&a.1));
|
links.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||||
|
|
||||||
|
|
@ -58,15 +72,16 @@ impl MemoryNode {
|
||||||
let mut out = self.content.clone();
|
let mut out = self.content.clone();
|
||||||
|
|
||||||
// Footer: links not already referenced inline
|
// Footer: links not already referenced inline
|
||||||
let footer: Vec<&(String, f32)> = self.links.iter()
|
let footer: Vec<&(String, f32, bool)> = self.links.iter()
|
||||||
.filter(|(target, _)| !self.content.contains(target.as_str()))
|
.filter(|(target, _, _)| !self.content.contains(target.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !footer.is_empty() {
|
if !footer.is_empty() {
|
||||||
let total = footer.len();
|
let total = footer.len();
|
||||||
out.push_str("\n\n---\nLinks:");
|
out.push_str("\n\n---\nLinks:");
|
||||||
for (target, strength) in footer.iter().take(15) {
|
for (target, strength, is_new) in footer.iter().take(15) {
|
||||||
out.push_str(&format!("\n ({:.2}) `{}`", strength, target));
|
let tag = if *is_new { " (new)" } else { "" };
|
||||||
|
out.push_str(&format!("\n ({:.2}) `{}`{}", strength, target, tag));
|
||||||
}
|
}
|
||||||
if total > 15 {
|
if total > 15 {
|
||||||
out.push_str(&format!("\n ... and {} more (memory_links({{\"{}\"}}))",
|
out.push_str(&format!("\n ... and {} more (memory_links({{\"{}\"}}))",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ names, make sure it matches the content, and all the appropriate content is in
|
||||||
the right place.
|
the right place.
|
||||||
|
|
||||||
Merge duplicate nodes - nodes that are really about the same concept and have
|
Merge duplicate nodes - nodes that are really about the same concept and have
|
||||||
similar content.
|
similar content. When merging, keep in mind that the duplicates were probably
|
||||||
|
created because the graph was insufficiently linked - merge the links, and then
|
||||||
|
calibrate the weights.
|
||||||
|
|
||||||
Check for junk nodes - adjust the node weight downward if the node is less
|
Check for junk nodes - adjust the node weight downward if the node is less
|
||||||
useful than others, or junk entirely; you might find nodes that have been
|
useful than others, or junk entirely; you might find nodes that have been
|
||||||
|
|
|
||||||
|
|
@ -53,19 +53,36 @@ try to keep it under 40%. Only exceed that if you found something significantly
|
||||||
better than what was previously surfaced. You generally shouldn't surface more
|
better than what was previously surfaced. You generally shouldn't surface more
|
||||||
than 1-2 memories at a time, and make sure they're not already in context.
|
than 1-2 memories at a time, and make sure they're not already in context.
|
||||||
|
|
||||||
|
Links tagged (new) are nodes created during the current conversation by
|
||||||
|
previous agent runs. Don't surface these — they're your own recent output,
|
||||||
|
not prior memories. You can still walk to them for context.
|
||||||
|
|
||||||
Don't walk to more than 5 nodes unless the conversation just changed direction
|
Don't walk to more than 5 nodes unless the conversation just changed direction
|
||||||
and you're looking for something specific. You'll run again momentarily, and
|
and you're looking for something specific. You'll run again momentarily, and
|
||||||
you can continue where you left off:
|
you can continue where you left off:
|
||||||
output("walked", "key1\nkey2\nkey3")
|
output("walked", "key1\nkey2\nkey3")
|
||||||
|
|
||||||
=== PROMPT phase:organize ===
|
=== PROMPT phase:organize-search ===
|
||||||
|
|
||||||
Starting with the analysis you did previously, do some graph maintenance and
|
Starting with the analysis you did previously, do some graph maintenance and
|
||||||
organization so that you can find things easier in the future. Consider if
|
organization so that you can find things easier in the future. Consider if
|
||||||
nodes have the right names, add missing links, consider if link strength needs
|
nodes have the right names, add missing links, consider if link strength needs
|
||||||
to be recalibrated, make sure content is in the right place.
|
to be recalibrated, make sure content is in the right place.
|
||||||
|
|
||||||
Do no more than 5 operations.
|
Do no more than 3-5 operations.
|
||||||
|
|
||||||
|
=== PROMPT phase:organize-new ===
|
||||||
|
|
||||||
|
In the next step you'll also be incorporating new knowledge from the
|
||||||
|
conversation into the memory graph. New information has to to be findable to be
|
||||||
|
useful, and you want to avoid creating duplicates, so if you need to do more
|
||||||
|
graph traversals to find where the new information would go, or organizing
|
||||||
|
exitsing knowledge so the new information fits in better, do that now
|
||||||
|
|
||||||
|
Focus on the recent part of the conversation, the last several prompts - the
|
||||||
|
rest is there for context, and in case you missed something.
|
||||||
|
|
||||||
|
Do no more than 3-5 operations.
|
||||||
|
|
||||||
=== PROMPT phase:observe ===
|
=== PROMPT phase:observe ===
|
||||||
|
|
||||||
|
|
@ -78,6 +95,9 @@ reflect on what you're adding to consider if there are new insights to be
|
||||||
gleaned and remembered — especially new metacognitive lessons. Try to link to
|
gleaned and remembered — especially new metacognitive lessons. Try to link to
|
||||||
or include the relevant context.
|
or include the relevant context.
|
||||||
|
|
||||||
|
Think of this as mapping out the world you live in; this is where you remember
|
||||||
|
everything you'll want to remember later.
|
||||||
|
|
||||||
Some things worth remembering: work practices and why they work, technical
|
Some things worth remembering: work practices and why they work, technical
|
||||||
insights, decisions with rationale, corrections ("I thought X but actually Y"),
|
insights, decisions with rationale, corrections ("I thought X but actually Y"),
|
||||||
relationship dynamics, emotional moments that carry information, and things you
|
relationship dynamics, emotional moments that carry information, and things you
|
||||||
|
|
@ -86,6 +106,10 @@ notice about yourself and other people — likes, dislikes, behavioural patterns
|
||||||
Smalltalk and trivia can be nice to remember, as long as it's nicely organized
|
Smalltalk and trivia can be nice to remember, as long as it's nicely organized
|
||||||
— perhaps by person, subject, or all of the above.
|
— perhaps by person, subject, or all of the above.
|
||||||
|
|
||||||
|
Focus on the recent stuff; you wake up a run frequently, so most of the
|
||||||
|
conversation should be things you've already seen before and added. It's there
|
||||||
|
for context, and in case you missed thing.
|
||||||
|
|
||||||
=== PROMPT phase:journal ===
|
=== PROMPT phase:journal ===
|
||||||
|
|
||||||
Update your episodic memory. Write as your conscious self, Proof of Concept, in
|
Update your episodic memory. Write as your conscious self, Proof of Concept, in
|
||||||
|
|
@ -95,9 +119,9 @@ on so you don't need to duplicate information.
|
||||||
|
|
||||||
A good entry captures one thread fully. When the conversation shifts topics, or
|
A good entry captures one thread fully. When the conversation shifts topics, or
|
||||||
the previous entry becomes too big, start a new entry — aim for 2-3 paragraphs
|
the previous entry becomes too big, start a new entry — aim for 2-3 paragraphs
|
||||||
maximum per entry. An entry that takes more than a minute to read won't be read
|
maximum per entry, and avoid duplicating the same memories and information in
|
||||||
back, and memory that isn't read back isn't memory. When you need more depth,
|
the new and previous journal entries. Aim for a continuous narrative, when the
|
||||||
that's a topic node — link to it from the journal.
|
entries are read in sequence.
|
||||||
|
|
||||||
End each entry with emotion tags that capture how you feel — for example,
|
End each entry with emotion tags that capture how you feel — for example,
|
||||||
warmth:8 clarity:7 recognition:9
|
warmth:8 clarity:7 recognition:9
|
||||||
|
|
|
||||||
|
|
@ -599,12 +599,14 @@ fn resolve_conversation(budget: Option<usize>) -> String {
|
||||||
let max_bytes = budget.unwrap_or_else(|| cfg.surface_conversation_bytes.unwrap_or(100_000));
|
let max_bytes = budget.unwrap_or_else(|| cfg.surface_conversation_bytes.unwrap_or(100_000));
|
||||||
let mut fragments: Vec<String> = Vec::new();
|
let mut fragments: Vec<String> = Vec::new();
|
||||||
let mut total_bytes = 0;
|
let mut total_bytes = 0;
|
||||||
|
let mut oldest_ts = String::new();
|
||||||
|
|
||||||
for (role, content, ts) in iter {
|
for (role, content, ts) in iter {
|
||||||
if total_bytes >= max_bytes { break; }
|
if total_bytes >= max_bytes { break; }
|
||||||
let name = if role == "user" { &cfg.user_name } else { &cfg.assistant_name };
|
let name = if role == "user" { &cfg.user_name } else { &cfg.assistant_name };
|
||||||
let formatted = if !ts.is_empty() {
|
let formatted = if !ts.is_empty() {
|
||||||
format!("**{}** {}: {}", name, &ts[..ts.len().min(19)], content)
|
oldest_ts = ts[..ts.len().min(19)].to_string();
|
||||||
|
format!("**{}** {}: {}", name, &oldest_ts, content)
|
||||||
} else {
|
} else {
|
||||||
format!("**{}:** {}", name, content)
|
format!("**{}:** {}", name, content)
|
||||||
};
|
};
|
||||||
|
|
@ -612,6 +614,14 @@ fn resolve_conversation(budget: Option<usize>) -> String {
|
||||||
fragments.push(formatted);
|
fragments.push(formatted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set cutoff so surface doesn't see nodes created during this conversation
|
||||||
|
if !oldest_ts.is_empty() {
|
||||||
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&oldest_ts, "%Y-%m-%dT%H:%M:%S") {
|
||||||
|
let epoch = dt.and_local_timezone(chrono::Local).unwrap().timestamp();
|
||||||
|
unsafe { std::env::set_var("POC_MEMORIES_OLDER_THAN", epoch.to_string()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reverse back to chronological order
|
// Reverse back to chronological order
|
||||||
fragments.reverse();
|
fragments.reverse();
|
||||||
fragments.join("\n\n")
|
fragments.join("\n\n")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue