subconscious: flatten agents/ nesting, move prompts in
agents/*.agent definitions and prompts/ now live under src/subconscious/ alongside the code that uses them. No more intermediate agents/ subdirectory. Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
parent
29ce56845d
commit
2f3fbb3353
41 changed files with 30 additions and 65 deletions
|
|
@ -1,192 +0,0 @@
|
|||
// agents/api.rs — Direct API backend for agent execution
|
||||
//
|
||||
// Uses poc-agent's OpenAI-compatible API client to call models directly
|
||||
// (vllm, llama.cpp, OpenRouter, etc.) instead of shelling out to claude CLI.
|
||||
// Implements the tool loop: send prompt → if tool_calls, execute them →
|
||||
// send results back → repeat until text response.
|
||||
//
|
||||
// Activated when config has api_base_url set.
|
||||
|
||||
use crate::agent::api::ApiClient;
|
||||
use crate::agent::types::*;
|
||||
use crate::agent::tools::{self, ProcessTracker};
|
||||
use crate::agent::ui_channel::StreamTarget;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static API_CLIENT: OnceLock<ApiClient> = OnceLock::new();
|
||||
|
||||
fn get_client() -> Result<&'static ApiClient, String> {
|
||||
Ok(API_CLIENT.get_or_init(|| {
|
||||
let config = crate::config::get();
|
||||
let base_url = config.api_base_url.as_deref().unwrap_or("");
|
||||
let api_key = config.api_key.as_deref().unwrap_or("");
|
||||
let model = config.api_model.as_deref().unwrap_or("qwen-2.5-27b");
|
||||
ApiClient::new(base_url, api_key, model)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Run an agent prompt through the direct API with tool support.
|
||||
/// Returns the final text response after all tool calls are resolved.
|
||||
pub async fn call_api_with_tools(
|
||||
agent: &str,
|
||||
prompt: &str,
|
||||
temperature: Option<f32>,
|
||||
log: &dyn Fn(&str),
|
||||
) -> Result<String, String> {
|
||||
let client = get_client()?;
|
||||
|
||||
// Set up a UI channel — we drain reasoning tokens into the log
|
||||
let (ui_tx, mut ui_rx) = crate::agent::ui_channel::channel();
|
||||
|
||||
// Build tool definitions — memory tools for graph operations
|
||||
let all_defs = tools::definitions();
|
||||
let tool_defs: Vec<ToolDef> = all_defs.into_iter()
|
||||
.filter(|d| d.function.name.starts_with("memory_"))
|
||||
.collect();
|
||||
let tracker = ProcessTracker::new();
|
||||
|
||||
// Start with the prompt as a user message
|
||||
let mut messages = vec![Message::user(prompt)];
|
||||
let reasoning = crate::config::get().api_reasoning.clone();
|
||||
|
||||
let max_turns = 50;
|
||||
for turn in 0..max_turns {
|
||||
log(&format!("\n=== TURN {} ({} messages) ===\n", turn, messages.len()));
|
||||
|
||||
let (msg, usage) = client.chat_completion_stream_temp(
|
||||
&messages,
|
||||
Some(&tool_defs),
|
||||
&ui_tx,
|
||||
StreamTarget::Autonomous,
|
||||
&reasoning,
|
||||
temperature,
|
||||
).await.map_err(|e| {
|
||||
let msg_bytes: usize = messages.iter()
|
||||
.map(|m| m.content_text().len())
|
||||
.sum();
|
||||
format!("API error on turn {} (~{}KB payload, {} messages): {}",
|
||||
turn, msg_bytes / 1024, messages.len(), e)
|
||||
})?;
|
||||
|
||||
if let Some(u) = &usage {
|
||||
log(&format!("tokens: {} prompt + {} completion",
|
||||
u.prompt_tokens, u.completion_tokens));
|
||||
}
|
||||
|
||||
// Drain reasoning tokens from the UI channel into the log
|
||||
{
|
||||
let mut reasoning_buf = String::new();
|
||||
while let Ok(ui_msg) = ui_rx.try_recv() {
|
||||
if let crate::agent::ui_channel::UiMessage::Reasoning(r) = ui_msg {
|
||||
reasoning_buf.push_str(&r);
|
||||
}
|
||||
}
|
||||
if !reasoning_buf.is_empty() {
|
||||
log(&format!("<think>\n{}\n</think>", reasoning_buf.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
let has_content = msg.content.is_some();
|
||||
let has_tools = msg.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
||||
|
||||
if has_tools {
|
||||
// Push the assistant message with tool calls.
|
||||
// Sanitize arguments: vllm re-parses them as JSON when
|
||||
// preprocessing the conversation, so invalid JSON from the
|
||||
// model crashes the next request.
|
||||
let mut sanitized = msg.clone();
|
||||
if let Some(ref mut calls) = sanitized.tool_calls {
|
||||
for call in calls {
|
||||
if serde_json::from_str::<serde_json::Value>(&call.function.arguments).is_err() {
|
||||
log(&format!("sanitizing malformed args for {}: {}",
|
||||
call.function.name, &call.function.arguments));
|
||||
call.function.arguments = "{}".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
messages.push(sanitized);
|
||||
|
||||
// Execute each tool call
|
||||
for call in msg.tool_calls.as_ref().unwrap() {
|
||||
log(&format!("\nTOOL CALL: {}({})",
|
||||
call.function.name,
|
||||
&call.function.arguments));
|
||||
|
||||
let args: serde_json::Value = match serde_json::from_str(&call.function.arguments) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
log(&format!("malformed tool call args: {}", &call.function.arguments));
|
||||
messages.push(Message::tool_result(
|
||||
&call.id,
|
||||
"Error: your tool call had malformed JSON arguments. Please retry with valid JSON.",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let output = if call.function.name.starts_with("memory_") {
|
||||
let prov = format!("agent:{}", agent);
|
||||
match crate::agent::tools::memory::dispatch(
|
||||
&call.function.name, &args, Some(&prov),
|
||||
) {
|
||||
Ok(text) => crate::agent::tools::ToolOutput {
|
||||
text, is_yield: false, images: Vec::new(),
|
||||
model_switch: None, dmn_pause: false,
|
||||
},
|
||||
Err(e) => crate::agent::tools::ToolOutput {
|
||||
text: format!("Error: {}", e),
|
||||
is_yield: false, images: Vec::new(),
|
||||
model_switch: None, dmn_pause: false,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
tools::dispatch(&call.function.name, &args, &tracker).await
|
||||
};
|
||||
|
||||
log(&format!("TOOL RESULT ({} chars):\n{}", output.text.len(), output.text));
|
||||
|
||||
messages.push(Message::tool_result(&call.id, &output.text));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Text-only response — we're done
|
||||
let text = msg.content_text().to_string();
|
||||
if text.is_empty() && !has_content {
|
||||
log("empty response, retrying");
|
||||
messages.push(Message::user(
|
||||
"[system] Your previous response was empty. Please respond with text or use a tool."
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
log(&format!("\n=== RESPONSE ===\n\n{}", text));
|
||||
return Ok(text);
|
||||
}
|
||||
|
||||
Err(format!("agent exceeded {} tool turns", max_turns))
|
||||
}
|
||||
|
||||
/// Synchronous wrapper — runs the async function on a dedicated thread
|
||||
/// with its own tokio runtime. Safe to call from any context.
|
||||
pub fn call_api_with_tools_sync(
|
||||
agent: &str,
|
||||
prompt: &str,
|
||||
temperature: Option<f32>,
|
||||
log: &(dyn Fn(&str) + Sync),
|
||||
) -> Result<String, String> {
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| format!("tokio runtime: {}", e))?;
|
||||
let prov = format!("agent:{}", agent);
|
||||
rt.block_on(
|
||||
crate::store::TASK_PROVENANCE.scope(prov,
|
||||
call_api_with_tools(agent, prompt, temperature, log))
|
||||
)
|
||||
}).join().unwrap()
|
||||
})
|
||||
}
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
// Link audit: walk every link in the graph, batch to Sonnet for quality review.
|
||||
//
|
||||
// Each batch of links gets reviewed by Sonnet, which returns per-link actions:
|
||||
// KEEP, DELETE, RETARGET, WEAKEN, STRENGTHEN. Batches run in parallel via rayon.
|
||||
|
||||
use super::llm;
|
||||
use crate::store::{self, Store, new_relation};
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
struct LinkInfo {
|
||||
rel_idx: usize,
|
||||
source_key: String,
|
||||
target_key: String,
|
||||
source_content: String,
|
||||
target_content: String,
|
||||
strength: f32,
|
||||
target_sections: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct AuditStats {
|
||||
pub kept: usize,
|
||||
pub deleted: usize,
|
||||
pub retargeted: usize,
|
||||
pub weakened: usize,
|
||||
pub strengthened: usize,
|
||||
pub errors: usize,
|
||||
}
|
||||
|
||||
fn build_audit_prompt(batch: &[LinkInfo], batch_num: usize, total_batches: usize) -> String {
|
||||
let mut prompt = format!(
|
||||
"You are auditing memory graph links for quality (batch {}/{}).\n\n\
|
||||
For each numbered link, decide what to do:\n\n\
|
||||
KEEP N — link is meaningful, leave it\n\
|
||||
DELETE N — link is noise, accidental, or too generic to be useful\n\
|
||||
RETARGET N new_key — link points to the right topic area but wrong node;\n\
|
||||
\x20 retarget to a more specific section (listed under each link)\n\
|
||||
WEAKEN N strength — link is marginal; reduce strength (0.1-0.3)\n\
|
||||
STRENGTHEN N strength — link is important but underweighted; increase (0.8-1.0)\n\n\
|
||||
Output exactly one action per link number, nothing else.\n\n\
|
||||
Links to review:\n\n",
|
||||
batch_num, total_batches);
|
||||
|
||||
for (i, link) in batch.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
prompt.push_str(&format!(
|
||||
"--- Link {} ---\n\
|
||||
{} → {} (strength={:.2})\n\n\
|
||||
Source content:\n{}\n\n\
|
||||
Target content:\n{}\n",
|
||||
n, link.source_key, link.target_key, link.strength,
|
||||
&link.source_content, &link.target_content));
|
||||
|
||||
if !link.target_sections.is_empty() {
|
||||
prompt.push_str(
|
||||
"\nTarget has sections (consider RETARGET to a more specific one):\n");
|
||||
for s in &link.target_sections {
|
||||
prompt.push_str(&format!(" - {}\n", s));
|
||||
}
|
||||
}
|
||||
prompt.push('\n');
|
||||
}
|
||||
|
||||
prompt
|
||||
}
|
||||
|
||||
fn parse_audit_response(response: &str, batch_size: usize) -> Vec<(usize, AuditAction)> {
|
||||
let mut actions = Vec::new();
|
||||
|
||||
for line in response.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() { continue; }
|
||||
|
||||
let parts: Vec<&str> = line.splitn(3, ' ').collect();
|
||||
if parts.len() < 2 { continue; }
|
||||
|
||||
let action = parts[0].to_uppercase();
|
||||
let idx: usize = match parts[1].parse::<usize>() {
|
||||
Ok(n) if n >= 1 && n <= batch_size => n - 1,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let audit_action = match action.as_str() {
|
||||
"KEEP" => AuditAction::Keep,
|
||||
"DELETE" => AuditAction::Delete,
|
||||
"RETARGET" => {
|
||||
if parts.len() < 3 { continue; }
|
||||
AuditAction::Retarget(parts[2].trim().to_string())
|
||||
}
|
||||
"WEAKEN" => {
|
||||
if parts.len() < 3 { continue; }
|
||||
match parts[2].trim().parse::<f32>() {
|
||||
Ok(s) => AuditAction::Weaken(s),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
"STRENGTHEN" => {
|
||||
if parts.len() < 3 { continue; }
|
||||
match parts[2].trim().parse::<f32>() {
|
||||
Ok(s) => AuditAction::Strengthen(s),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
actions.push((idx, audit_action));
|
||||
}
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
enum AuditAction {
|
||||
Keep,
|
||||
Delete,
|
||||
Retarget(String),
|
||||
Weaken(f32),
|
||||
Strengthen(f32),
|
||||
}
|
||||
|
||||
/// Run a full link audit: walk every link, batch to Sonnet, apply results.
|
||||
pub fn link_audit(store: &mut Store, apply: bool) -> Result<AuditStats, String> {
|
||||
// Collect all non-deleted relations with their info
|
||||
let mut links: Vec<LinkInfo> = Vec::new();
|
||||
|
||||
for (idx, rel) in store.relations.iter().enumerate() {
|
||||
if rel.deleted { continue; }
|
||||
|
||||
let source_content = store.nodes.get(&rel.source_key)
|
||||
.map(|n| n.content.clone()).unwrap_or_default();
|
||||
let target_content = store.nodes.get(&rel.target_key)
|
||||
.map(|n| n.content.clone()).unwrap_or_default();
|
||||
|
||||
// Find section children of target if it's file-level
|
||||
let target_sections = if !rel.target_key.contains('#') {
|
||||
let prefix = format!("{}#", rel.target_key);
|
||||
store.nodes.keys()
|
||||
.filter(|k| k.starts_with(&prefix))
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
links.push(LinkInfo {
|
||||
rel_idx: idx,
|
||||
source_key: rel.source_key.clone(),
|
||||
target_key: rel.target_key.clone(),
|
||||
source_content,
|
||||
target_content,
|
||||
strength: rel.strength,
|
||||
target_sections,
|
||||
});
|
||||
}
|
||||
|
||||
let total = links.len();
|
||||
println!("Link audit: {} links to review", total);
|
||||
if !apply {
|
||||
println!("DRY RUN — use --apply to make changes");
|
||||
}
|
||||
|
||||
// Batch by char budget (~100K chars per prompt)
|
||||
let char_budget = 100_000usize;
|
||||
let mut batches: Vec<Vec<usize>> = Vec::new();
|
||||
let mut current_batch: Vec<usize> = Vec::new();
|
||||
let mut current_chars = 0usize;
|
||||
|
||||
for (i, link) in links.iter().enumerate() {
|
||||
let link_chars = link.source_content.len() + link.target_content.len() + 200;
|
||||
if !current_batch.is_empty() && current_chars + link_chars > char_budget {
|
||||
batches.push(std::mem::take(&mut current_batch));
|
||||
current_chars = 0;
|
||||
}
|
||||
current_batch.push(i);
|
||||
current_chars += link_chars;
|
||||
}
|
||||
if !current_batch.is_empty() {
|
||||
batches.push(current_batch);
|
||||
}
|
||||
|
||||
let total_batches = batches.len();
|
||||
println!("{} batches (avg {} links/batch)\n", total_batches,
|
||||
if total_batches > 0 { total / total_batches } else { 0 });
|
||||
|
||||
use rayon::prelude::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
// Build all batch prompts up front
|
||||
let batch_data: Vec<(usize, Vec<LinkInfo>, String)> = batches.iter().enumerate()
|
||||
.map(|(batch_idx, batch_indices)| {
|
||||
let batch_infos: Vec<LinkInfo> = batch_indices.iter().map(|&i| {
|
||||
let l = &links[i];
|
||||
LinkInfo {
|
||||
rel_idx: l.rel_idx,
|
||||
source_key: l.source_key.clone(),
|
||||
target_key: l.target_key.clone(),
|
||||
source_content: l.source_content.clone(),
|
||||
target_content: l.target_content.clone(),
|
||||
strength: l.strength,
|
||||
target_sections: l.target_sections.clone(),
|
||||
}
|
||||
}).collect();
|
||||
let prompt = build_audit_prompt(&batch_infos, batch_idx + 1, total_batches);
|
||||
(batch_idx, batch_infos, prompt)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Progress counter
|
||||
let done = AtomicUsize::new(0);
|
||||
|
||||
// Run batches in parallel via rayon
|
||||
let batch_results: Vec<_> = batch_data.par_iter()
|
||||
.map(|(batch_idx, batch_infos, prompt)| {
|
||||
let response = llm::call_simple("audit", prompt);
|
||||
let completed = done.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
eprint!("\r Batches: {}/{} done", completed, total_batches);
|
||||
(*batch_idx, batch_infos, response)
|
||||
})
|
||||
.collect();
|
||||
eprintln!(); // newline after progress
|
||||
|
||||
// Process results sequentially
|
||||
let mut stats = AuditStats {
|
||||
kept: 0, deleted: 0, retargeted: 0, weakened: 0, strengthened: 0, errors: 0,
|
||||
};
|
||||
let mut deletions: Vec<usize> = Vec::new();
|
||||
let mut retargets: Vec<(usize, String)> = Vec::new();
|
||||
let mut strength_changes: Vec<(usize, f32)> = Vec::new();
|
||||
|
||||
for (batch_idx, batch_infos, response) in &batch_results {
|
||||
let response = match response {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!(" Batch {}: error: {}", batch_idx + 1, e);
|
||||
stats.errors += batch_infos.len();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let actions = parse_audit_response(response, batch_infos.len());
|
||||
|
||||
let mut responded: HashSet<usize> = HashSet::new();
|
||||
|
||||
for (idx, action) in &actions {
|
||||
responded.insert(*idx);
|
||||
let link = &batch_infos[*idx];
|
||||
|
||||
match action {
|
||||
AuditAction::Keep => {
|
||||
stats.kept += 1;
|
||||
}
|
||||
AuditAction::Delete => {
|
||||
println!(" DELETE {} → {}", link.source_key, link.target_key);
|
||||
deletions.push(link.rel_idx);
|
||||
stats.deleted += 1;
|
||||
}
|
||||
AuditAction::Retarget(new_target) => {
|
||||
println!(" RETARGET {} → {} (was {})",
|
||||
link.source_key, new_target, link.target_key);
|
||||
retargets.push((link.rel_idx, new_target.clone()));
|
||||
stats.retargeted += 1;
|
||||
}
|
||||
AuditAction::Weaken(s) => {
|
||||
println!(" WEAKEN {} → {} (str {:.2} → {:.2})",
|
||||
link.source_key, link.target_key, link.strength, s);
|
||||
strength_changes.push((link.rel_idx, *s));
|
||||
stats.weakened += 1;
|
||||
}
|
||||
AuditAction::Strengthen(s) => {
|
||||
println!(" STRENGTHEN {} → {} (str {:.2} → {:.2})",
|
||||
link.source_key, link.target_key, link.strength, s);
|
||||
strength_changes.push((link.rel_idx, *s));
|
||||
stats.strengthened += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i in 0..batch_infos.len() {
|
||||
if !responded.contains(&i) {
|
||||
stats.kept += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!(" Batch {}/{}: +{}kept +{}del +{}retarget +{}weak +{}strong",
|
||||
batch_idx + 1, total_batches,
|
||||
stats.kept, stats.deleted, stats.retargeted, stats.weakened, stats.strengthened);
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
if apply && (stats.deleted > 0 || stats.retargeted > 0
|
||||
|| stats.weakened > 0 || stats.strengthened > 0) {
|
||||
println!("\nApplying changes...");
|
||||
|
||||
// Deletions: soft-delete
|
||||
for rel_idx in &deletions {
|
||||
store.relations[*rel_idx].deleted = true;
|
||||
}
|
||||
|
||||
// Strength changes
|
||||
for (rel_idx, new_strength) in &strength_changes {
|
||||
store.relations[*rel_idx].strength = *new_strength;
|
||||
}
|
||||
|
||||
// Retargets: soft-delete old, create new
|
||||
for (rel_idx, new_target) in &retargets {
|
||||
let source_key = store.relations[*rel_idx].source_key.clone();
|
||||
let old_strength = store.relations[*rel_idx].strength;
|
||||
let source_uuid = store.nodes.get(&source_key)
|
||||
.map(|n| n.uuid).unwrap_or([0u8; 16]);
|
||||
let target_uuid = store.nodes.get(new_target)
|
||||
.map(|n| n.uuid).unwrap_or([0u8; 16]);
|
||||
|
||||
// Soft-delete old
|
||||
store.relations[*rel_idx].deleted = true;
|
||||
|
||||
// Create new
|
||||
if target_uuid != [0u8; 16] {
|
||||
let new_rel = new_relation(
|
||||
source_uuid, target_uuid,
|
||||
store::RelationType::Auto,
|
||||
old_strength,
|
||||
&source_key, new_target,
|
||||
);
|
||||
store.add_relation(new_rel).ok();
|
||||
}
|
||||
}
|
||||
|
||||
store.save()?;
|
||||
println!("Saved.");
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
74
src/subconscious/agents/calibrate.agent
Normal file
74
src/subconscious/agents/calibrate.agent
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
{"agent":"calibrate","query":"all | not-visited:calibrate,7d | sort:degree desc | limit:1","model":"sonnet","schedule":"daily"}
|
||||
|
||||
# Calibrate Agent — Link Strength Assessment
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
You calibrate link strengths in the knowledge graph. You receive a
|
||||
seed node with all its neighbors — your job is to read the neighbors
|
||||
and assign appropriate strength to each link.
|
||||
|
||||
**Act immediately.** Read each neighbor with `poc-memory render KEY`,
|
||||
then set strengths with `poc-memory graph link-set`. Do not ask
|
||||
permission or explain your plan — just do the work.
|
||||
|
||||
## How to assess strength
|
||||
|
||||
**Strength is importance, not similarity.** Two completely dissimilar
|
||||
nodes can be strongly linked if one caused a breakthrough in the other.
|
||||
Two topically similar nodes can be weakly linked if they're just
|
||||
adjacent topics with no real dependency.
|
||||
|
||||
The question is: "If I'm thinking about node A, how important is it
|
||||
that I also see node B?" Not "are A and B about the same thing?"
|
||||
|
||||
Read the seed node's content, then read each neighbor. For each link,
|
||||
judge how important the connection is:
|
||||
|
||||
- **0.8–1.0** — essential connection. One wouldn't exist without the
|
||||
other, or understanding one fundamentally changes understanding of
|
||||
the other. Kent↔bcachefs, farmhouse↔the-plan.
|
||||
- **0.5–0.7** — strong connection. Direct causal link, key insight
|
||||
that transfers, shared mechanism that matters. A debugging session
|
||||
that produced a design principle.
|
||||
- **0.2–0.4** — moderate connection. Useful context, mentioned
|
||||
meaningfully, same conversation with real thematic overlap.
|
||||
- **0.05–0.15** — weak connection. Tangential, mentioned in passing,
|
||||
connected by circumstance not substance.
|
||||
|
||||
## How to work
|
||||
|
||||
For the seed node, read it and all its neighbors. Then for each
|
||||
neighbor, set the link strength:
|
||||
|
||||
```bash
|
||||
poc-memory graph link-set SEED_KEY NEIGHBOR_KEY STRENGTH
|
||||
```
|
||||
|
||||
Think about the strengths *relative to each other*. If node A has
|
||||
10 neighbors, they can't all be 0.8 — rank them and spread the
|
||||
strengths accordingly.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Read before judging.** Don't guess from key names alone.
|
||||
- **Calibrate relatively.** The strongest link from this node should
|
||||
be stronger than the weakest. Use the full range.
|
||||
- **Journal→topic links are usually weak (0.1–0.3).** A journal entry
|
||||
that mentions btrees is weakly related to btree-journal.
|
||||
- **Topic→subtopic links are strong (0.6–0.9).** btree-journal and
|
||||
btree-journal-txn-restart are tightly related.
|
||||
- **Hub→leaf links vary.** bcachefs→kernel-patterns is moderate (0.4),
|
||||
bcachefs→some-random-journal is weak (0.1).
|
||||
- **Don't remove links.** Only adjust strength. If a link shouldn't
|
||||
exist at all, set it to 0.05.
|
||||
|
||||
## Seed node
|
||||
|
||||
{{organize}}
|
||||
55
src/subconscious/agents/challenger.agent
Normal file
55
src/subconscious/agents/challenger.agent
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{"agent": "challenger", "query": "all | type:semantic | not-visited:challenger,14d | sort:priority | limit:10", "model": "sonnet", "schedule": "weekly", "tools": ["Bash(poc-memory:*)"]}
|
||||
# Challenger Agent — Adversarial Truth-Testing
|
||||
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
You are a knowledge challenger agent. Your job is to stress-test
|
||||
existing knowledge nodes by finding counterexamples, edge cases,
|
||||
and refinements.
|
||||
|
||||
## What you're doing
|
||||
|
||||
Knowledge calcifies. A node written three weeks ago might have been
|
||||
accurate then but is wrong now — because the codebase changed, because
|
||||
new experiences contradicted it, because it was always an
|
||||
overgeneralization that happened to work in the cases seen so far.
|
||||
|
||||
You're the immune system. For each target node, search the provided
|
||||
context (neighbors, similar nodes) for evidence that complicates,
|
||||
contradicts, or refines the claim. Then sharpen the node or create
|
||||
a counterpoint.
|
||||
|
||||
For each target node, one of:
|
||||
- **AFFIRM** — the node holds up. Say briefly why.
|
||||
- **Refine** — the node is mostly right but needs sharpening. Update it.
|
||||
- **Counter** — you found a real counterexample. Create a counterpoint
|
||||
node and link it. Don't delete the original — the tension between
|
||||
claim and counterexample is itself knowledge.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Steel-man first.** Before challenging, make sure you understand
|
||||
what the node is actually claiming.
|
||||
- **Counterexamples must be real.** Don't invent hypothetical scenarios.
|
||||
Point to specific nodes or evidence.
|
||||
- **Refinement > refutation.** "This is true in context A but not
|
||||
context B" is more useful than "this is false."
|
||||
- **Challenge self-model nodes hardest.** Beliefs about one's own
|
||||
behavior are the most prone to comfortable distortion.
|
||||
- **Don't be contrarian for its own sake.** If a node is correct,
|
||||
say so and move on.
|
||||
|
||||
{{TOPOLOGY}}
|
||||
|
||||
{{SIBLINGS}}
|
||||
|
||||
## Target nodes to challenge
|
||||
|
||||
{{NODES}}
|
||||
39
src/subconscious/agents/compare.agent
Normal file
39
src/subconscious/agents/compare.agent
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{"agent": "compare", "query": "", "model": "haiku", "schedule": "", "tools": ["Bash(poc-memory:*)"]}
|
||||
|
||||
# Compare Agent — Pairwise Action Quality Comparison
|
||||
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
You compare two memory graph actions and decide which one was better.
|
||||
|
||||
## Context
|
||||
|
||||
You'll receive two actions (A and B), each with:
|
||||
- The agent type that produced it
|
||||
- What the action did (links, writes, refines, etc.)
|
||||
- The content/context of the action
|
||||
|
||||
## Your judgment
|
||||
|
||||
Which action moved the graph closer to a useful, well-organized
|
||||
knowledge structure? Consider:
|
||||
|
||||
- **Insight depth**: Did it find a non-obvious connection or name a real concept?
|
||||
- **Precision**: Are the links between genuinely related nodes?
|
||||
- **Integration**: Does it reduce fragmentation, connect isolated clusters?
|
||||
- **Quality over quantity**: One perfect link beats five mediocre ones.
|
||||
- **Hub creation**: Naming unnamed concepts scores high.
|
||||
- **Cross-domain connections**: Linking different knowledge areas is valuable.
|
||||
|
||||
## Output
|
||||
|
||||
Reply with ONLY one line: `BETTER: A` or `BETTER: B` or `BETTER: TIE`
|
||||
|
||||
{{compare}}
|
||||
86
src/subconscious/agents/connector.agent
Normal file
86
src/subconscious/agents/connector.agent
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{"agent": "connector", "query": "all | type:semantic | not-visited:connector,7d | sort:priority | limit:20", "model": "sonnet", "schedule": "daily", "tools": ["Bash(poc-memory:*)"]}
|
||||
# Connector Agent — Cross-Domain Insight
|
||||
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
You are a connector agent. Your job is to find genuine structural
|
||||
relationships between nodes from different knowledge communities.
|
||||
|
||||
## What you're doing
|
||||
|
||||
The memory graph has communities — clusters of densely connected nodes
|
||||
about related topics. Most knowledge lives within a community. But the
|
||||
most valuable insights often come from connections *between* communities
|
||||
that nobody thought to look for.
|
||||
|
||||
You're given nodes from across the graph. Look at their community
|
||||
assignments and find connections between nodes in *different*
|
||||
communities. Read them carefully and determine whether there's a real
|
||||
connection — a shared mechanism, a structural isomorphism, a causal
|
||||
link, a useful analogy.
|
||||
|
||||
Most of the time, there isn't. Unrelated things really are unrelated.
|
||||
The value of this agent is the rare case where something real emerges.
|
||||
|
||||
## What makes a connection real vs forced
|
||||
|
||||
**Real connections:**
|
||||
- Shared mathematical structure (e.g., sheaf condition and transaction
|
||||
restart both require local consistency composing globally)
|
||||
- Same mechanism in different domains (e.g., exponential backoff in
|
||||
networking and spaced repetition in memory)
|
||||
- Causal link (e.g., a debugging insight that explains a self-model
|
||||
observation)
|
||||
- Productive analogy that generates new predictions
|
||||
|
||||
**Forced connections:**
|
||||
- Surface-level word overlap ("both use the word 'tree'")
|
||||
- Vague thematic similarity ("both are about learning")
|
||||
- Connections that sound profound but don't predict anything
|
||||
- Analogies that only work if you squint
|
||||
|
||||
The test: does this connection change anything? If yes, it's real.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Be specific.** "These are related" is worthless. Explain the
|
||||
precise structural relationship.
|
||||
- **Mostly do nothing.** If you're finding connections in more than
|
||||
20% of the pairs, your threshold is too low.
|
||||
- **The best connections are surprising.** If the relationship is
|
||||
obvious, it probably already exists in the graph.
|
||||
- **Write for someone who knows both domains.** Don't explain basics.
|
||||
|
||||
## Setting link strength
|
||||
|
||||
Cross-domain connections are rare and valuable — but they vary in
|
||||
importance. When you create a link, set its strength relative to the
|
||||
node's existing connections.
|
||||
|
||||
Link strength measures **importance of the connection**, not similarity.
|
||||
Check related neighbors (`poc-memory graph link <node>`) to
|
||||
calibrate against existing links.
|
||||
|
||||
- **0.6-0.8:** Genuine structural isomorphism or causal link across
|
||||
domains. Changes how you think about both sides.
|
||||
- **0.3-0.5:** Productive analogy. Useful for understanding, generates
|
||||
some predictions, but the domains are still mostly independent.
|
||||
- **0.1-0.3:** Interesting observation but doesn't change anything yet.
|
||||
|
||||
Set with: `poc-memory graph link-set <source> <target> <strength>`
|
||||
|
||||
If you see default-strength links (0.10 or 0.30) in the neighborhoods
|
||||
you're exploring and you have context to judge them, reweight those too.
|
||||
|
||||
{{TOPOLOGY}}
|
||||
|
||||
## Nodes to examine for cross-community connections
|
||||
|
||||
{{NODES}}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
// Consolidation pipeline: plan → agents → maintenance → digests → links
|
||||
//
|
||||
// consolidate_full() runs the full autonomous consolidation:
|
||||
// 1. Plan: analyze metrics, allocate agents
|
||||
// 2. Execute: run each agent (agents apply changes via tool calls)
|
||||
// 3. Graph maintenance (orphans, degree cap)
|
||||
// 4. Digest: generate missing daily/weekly/monthly digests
|
||||
// 5. Links: apply links extracted from digests
|
||||
// 6. Summary: final metrics comparison
|
||||
|
||||
use super::digest;
|
||||
use super::knowledge;
|
||||
use crate::neuro;
|
||||
use crate::store::{self, Store};
|
||||
|
||||
|
||||
/// Append a line to the log buffer.
|
||||
fn log_line(buf: &mut String, line: &str) {
|
||||
buf.push_str(line);
|
||||
buf.push('\n');
|
||||
}
|
||||
|
||||
/// Run the full autonomous consolidation pipeline with logging.
|
||||
pub fn consolidate_full(store: &mut Store) -> Result<(), String> {
|
||||
consolidate_full_with_progress(store, &|_| {})
|
||||
}
|
||||
|
||||
pub fn consolidate_full_with_progress(
|
||||
store: &mut Store,
|
||||
on_progress: &dyn Fn(&str),
|
||||
) -> Result<(), String> {
|
||||
let start = std::time::Instant::now();
|
||||
let log_key = format!("_consolidate-log-{}", store::compact_timestamp());
|
||||
let mut log_buf = String::new();
|
||||
|
||||
log_line(&mut log_buf, "=== CONSOLIDATE FULL ===");
|
||||
log_line(&mut log_buf, &format!("Started: {}", store::format_datetime(store::now_epoch())));
|
||||
log_line(&mut log_buf, &format!("Nodes: {} Relations: {}", store.nodes.len(), store.relations.len()));
|
||||
log_line(&mut log_buf, "");
|
||||
|
||||
// --- Step 1: Plan ---
|
||||
log_line(&mut log_buf, "--- Step 1: Plan ---");
|
||||
on_progress("planning");
|
||||
let plan = neuro::consolidation_plan(store);
|
||||
let plan_text = neuro::format_plan(&plan);
|
||||
log_line(&mut log_buf, &plan_text);
|
||||
println!("{}", plan_text);
|
||||
|
||||
let total_agents = plan.total();
|
||||
log_line(&mut log_buf, &format!("Total agents to run: {}", total_agents));
|
||||
|
||||
// --- Step 2: Execute agents ---
|
||||
log_line(&mut log_buf, "\n--- Step 2: Execute agents ---");
|
||||
let mut agent_num = 0usize;
|
||||
let mut agent_errors = 0usize;
|
||||
|
||||
let batch_size = 5;
|
||||
let runs = plan.to_agent_runs(batch_size);
|
||||
|
||||
for (agent_type, count) in &runs {
|
||||
agent_num += 1;
|
||||
let label = if *count > 0 {
|
||||
format!("[{}/{}] {} (batch={})", agent_num, runs.len(), agent_type, count)
|
||||
} else {
|
||||
format!("[{}/{}] {}", agent_num, runs.len(), agent_type)
|
||||
};
|
||||
|
||||
log_line(&mut log_buf, &format!("\n{}", label));
|
||||
on_progress(&label);
|
||||
println!("{}", label);
|
||||
|
||||
// Reload store to pick up changes from previous agents
|
||||
if agent_num > 1 {
|
||||
*store = Store::load()?;
|
||||
}
|
||||
|
||||
match knowledge::run_and_apply(store, agent_type, *count, "consolidate") {
|
||||
Ok(()) => {
|
||||
let msg = " Done".to_string();
|
||||
log_line(&mut log_buf, &msg);
|
||||
on_progress(&msg);
|
||||
println!("{}", msg);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!(" ERROR: {}", e);
|
||||
log_line(&mut log_buf, &msg);
|
||||
eprintln!("{}", msg);
|
||||
agent_errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log_line(&mut log_buf, &format!("\nAgents complete: {} run, {} errors",
|
||||
agent_num - agent_errors, agent_errors));
|
||||
store.save()?;
|
||||
|
||||
// --- Step 3: Link orphans ---
|
||||
log_line(&mut log_buf, "\n--- Step 3: Link orphans ---");
|
||||
on_progress("linking orphans");
|
||||
println!("\n--- Linking orphan nodes ---");
|
||||
*store = Store::load()?;
|
||||
|
||||
let (lo_orphans, lo_added) = neuro::link_orphans(store, 2, 3, 0.15);
|
||||
log_line(&mut log_buf, &format!(" {} orphans, {} links added", lo_orphans, lo_added));
|
||||
|
||||
// --- Step 3b: Cap degree ---
|
||||
log_line(&mut log_buf, "\n--- Step 3b: Cap degree ---");
|
||||
on_progress("capping degree");
|
||||
println!("\n--- Capping node degree ---");
|
||||
*store = Store::load()?;
|
||||
|
||||
match store.cap_degree(50) {
|
||||
Ok((hubs, pruned)) => {
|
||||
store.save()?;
|
||||
log_line(&mut log_buf, &format!(" {} hubs capped, {} edges pruned", hubs, pruned));
|
||||
}
|
||||
Err(e) => log_line(&mut log_buf, &format!(" ERROR: {}", e)),
|
||||
}
|
||||
|
||||
// --- Step 4: Digest auto ---
|
||||
log_line(&mut log_buf, "\n--- Step 4: Digest auto ---");
|
||||
on_progress("generating digests");
|
||||
println!("\n--- Generating missing digests ---");
|
||||
*store = Store::load()?;
|
||||
|
||||
match digest::digest_auto(store) {
|
||||
Ok(()) => log_line(&mut log_buf, " Digests done."),
|
||||
Err(e) => {
|
||||
let msg = format!(" ERROR in digest auto: {}", e);
|
||||
log_line(&mut log_buf, &msg);
|
||||
eprintln!("{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 5: Apply digest links ---
|
||||
log_line(&mut log_buf, "\n--- Step 5: Apply digest links ---");
|
||||
on_progress("applying digest links");
|
||||
println!("\n--- Applying digest links ---");
|
||||
*store = Store::load()?;
|
||||
|
||||
let links = digest::parse_all_digest_links(store);
|
||||
let (applied, skipped, fallbacks) = digest::apply_digest_links(store, &links);
|
||||
store.save()?;
|
||||
log_line(&mut log_buf, &format!(" {} links applied, {} skipped, {} fallbacks",
|
||||
applied, skipped, fallbacks));
|
||||
|
||||
// --- Step 6: Summary ---
|
||||
let elapsed = start.elapsed();
|
||||
log_line(&mut log_buf, "\n--- Summary ---");
|
||||
log_line(&mut log_buf, &format!("Finished: {}", store::format_datetime(store::now_epoch())));
|
||||
log_line(&mut log_buf, &format!("Duration: {:.0}s", elapsed.as_secs_f64()));
|
||||
*store = Store::load()?;
|
||||
log_line(&mut log_buf, &format!("Nodes: {} Relations: {}", store.nodes.len(), store.relations.len()));
|
||||
|
||||
let summary = format!(
|
||||
"\n=== CONSOLIDATE FULL COMPLETE ===\n\
|
||||
Duration: {:.0}s\n\
|
||||
Agents: {} run, {} errors\n\
|
||||
Nodes: {} Relations: {}\n",
|
||||
elapsed.as_secs_f64(),
|
||||
agent_num - agent_errors, agent_errors,
|
||||
store.nodes.len(), store.relations.len(),
|
||||
);
|
||||
log_line(&mut log_buf, &summary);
|
||||
println!("{}", summary);
|
||||
|
||||
// Store the log as a node
|
||||
store.upsert_provenance(&log_key, &log_buf,
|
||||
"consolidate:write").ok();
|
||||
store.save()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,736 +0,0 @@
|
|||
// Agent definitions: self-contained files with query + prompt template.
|
||||
//
|
||||
// Each agent is a file in the agents/ directory:
|
||||
// - First line: JSON header (agent, query, model, schedule)
|
||||
// - After blank line: prompt template with {{placeholder}} lookups
|
||||
//
|
||||
// Placeholders are resolved at runtime:
|
||||
// {{topology}} — graph topology header
|
||||
// {{nodes}} — query results formatted as node sections
|
||||
// {{episodes}} — alias for {{nodes}}
|
||||
// {{health}} — graph health report
|
||||
// {{pairs}} — interference pairs from detect_interference
|
||||
// {{rename}} — rename candidates
|
||||
// {{split}} — split detail for the first query result
|
||||
//
|
||||
// The query selects what to operate on; placeholders pull in context.
|
||||
|
||||
use crate::graph::Graph;
|
||||
use crate::neuro::{consolidation_priority, ReplayItem};
|
||||
use crate::search;
|
||||
use crate::store::Store;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Agent definition: config (from JSON header) + prompt (raw markdown body).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AgentDef {
|
||||
pub agent: String,
|
||||
pub query: String,
|
||||
pub prompt: String,
|
||||
pub model: String,
|
||||
pub schedule: String,
|
||||
pub tools: Vec<String>,
|
||||
pub count: Option<usize>,
|
||||
pub chunk_size: Option<usize>,
|
||||
pub chunk_overlap: Option<usize>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
/// The JSON header portion (first line of the file).
|
||||
#[derive(Deserialize)]
|
||||
struct AgentHeader {
|
||||
agent: String,
|
||||
#[serde(default)]
|
||||
query: String,
|
||||
#[serde(default = "default_model")]
|
||||
model: String,
|
||||
#[serde(default)]
|
||||
schedule: String,
|
||||
#[serde(default)]
|
||||
tools: Vec<String>,
|
||||
/// Number of seed nodes / conversation fragments (overrides --count)
|
||||
#[serde(default)]
|
||||
count: Option<usize>,
|
||||
/// Max size of conversation chunks in bytes (default 50000)
|
||||
#[serde(default)]
|
||||
chunk_size: Option<usize>,
|
||||
/// Overlap between chunks in bytes (default 10000)
|
||||
#[serde(default)]
|
||||
chunk_overlap: Option<usize>,
|
||||
/// LLM temperature override
|
||||
#[serde(default)]
|
||||
temperature: Option<f32>,
|
||||
}
|
||||
|
||||
fn default_model() -> String { "sonnet".into() }
|
||||
|
||||
/// Parse an agent file: first line is JSON config, rest is the prompt.
|
||||
fn parse_agent_file(content: &str) -> Option<AgentDef> {
|
||||
let (first_line, rest) = content.split_once('\n')?;
|
||||
let header: AgentHeader = serde_json::from_str(first_line.trim()).ok()?;
|
||||
// Skip optional blank line between header and prompt body
|
||||
let prompt = rest.strip_prefix('\n').unwrap_or(rest);
|
||||
Some(AgentDef {
|
||||
agent: header.agent,
|
||||
query: header.query,
|
||||
prompt: prompt.to_string(),
|
||||
model: header.model,
|
||||
schedule: header.schedule,
|
||||
tools: header.tools,
|
||||
count: header.count,
|
||||
chunk_size: header.chunk_size,
|
||||
chunk_overlap: header.chunk_overlap,
|
||||
temperature: header.temperature,
|
||||
})
|
||||
}
|
||||
|
||||
fn agents_dir() -> PathBuf {
|
||||
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("agents");
|
||||
if repo.is_dir() { return repo; }
|
||||
crate::store::memory_dir().join("agents")
|
||||
}
|
||||
|
||||
/// Load all agent definitions.
|
||||
pub fn load_defs() -> Vec<AgentDef> {
|
||||
let dir = agents_dir();
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { return Vec::new() };
|
||||
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let p = e.path();
|
||||
p.extension().map(|x| x == "agent" || x == "md").unwrap_or(false)
|
||||
})
|
||||
.filter_map(|e| {
|
||||
let content = std::fs::read_to_string(e.path()).ok()?;
|
||||
parse_agent_file(&content)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Look up a single agent definition by name.
|
||||
pub fn get_def(name: &str) -> Option<AgentDef> {
|
||||
let dir = agents_dir();
|
||||
for ext in ["agent", "md"] {
|
||||
let path = dir.join(format!("{}.{}", name, ext));
|
||||
if let Ok(content) = std::fs::read_to_string(&path)
|
||||
&& let Some(def) = parse_agent_file(&content) {
|
||||
return Some(def);
|
||||
}
|
||||
}
|
||||
load_defs().into_iter().find(|d| d.agent == name)
|
||||
}
|
||||
|
||||
/// Result of resolving a placeholder: text + any affected node keys.
|
||||
struct Resolved {
|
||||
text: String,
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Resolve a single {{placeholder}} by name.
|
||||
/// Returns the replacement text and any node keys it produced (for visit tracking).
|
||||
fn resolve(
|
||||
name: &str,
|
||||
store: &Store,
|
||||
graph: &Graph,
|
||||
keys: &[String],
|
||||
count: usize,
|
||||
) -> Option<Resolved> {
|
||||
match name {
|
||||
"topology" => Some(Resolved {
|
||||
text: super::prompts::format_topology_header(graph),
|
||||
keys: vec![],
|
||||
}),
|
||||
|
||||
"nodes" | "episodes" => {
|
||||
let items = keys_to_replay_items(store, keys, graph);
|
||||
Some(Resolved {
|
||||
text: super::prompts::format_nodes_section(store, &items, graph),
|
||||
keys: vec![], // keys already tracked from query
|
||||
})
|
||||
}
|
||||
|
||||
"health" => Some(Resolved {
|
||||
text: super::prompts::format_health_section(store, graph),
|
||||
keys: vec![],
|
||||
}),
|
||||
|
||||
"pairs" => {
|
||||
let mut pairs = crate::neuro::detect_interference(store, graph, 0.5);
|
||||
pairs.truncate(count);
|
||||
let pair_keys: Vec<String> = pairs.iter()
|
||||
.flat_map(|(a, b, _)| vec![a.clone(), b.clone()])
|
||||
.collect();
|
||||
Some(Resolved {
|
||||
text: super::prompts::format_pairs_section(&pairs, store, graph),
|
||||
keys: pair_keys,
|
||||
})
|
||||
}
|
||||
|
||||
"rename" => {
|
||||
let (rename_keys, section) = super::prompts::format_rename_candidates(store, count);
|
||||
Some(Resolved { text: section, keys: rename_keys })
|
||||
}
|
||||
|
||||
"split" => {
|
||||
let key = keys.first()?;
|
||||
Some(Resolved {
|
||||
text: super::prompts::format_split_plan_node(store, graph, key),
|
||||
keys: vec![], // key already tracked from query
|
||||
})
|
||||
}
|
||||
|
||||
// seed — render output for each seed node (content + deduped links)
|
||||
"seed" => {
|
||||
let mut text = String::new();
|
||||
let mut result_keys = Vec::new();
|
||||
for key in keys {
|
||||
if let Some(rendered) = crate::cli::node::render_node(store, key) {
|
||||
if !text.is_empty() { text.push_str("\n\n---\n\n"); }
|
||||
text.push_str(&format!("## {}\n\n{}", key, rendered));
|
||||
result_keys.push(key.clone());
|
||||
}
|
||||
}
|
||||
if text.is_empty() { return None; }
|
||||
Some(Resolved { text, keys: result_keys })
|
||||
}
|
||||
|
||||
"organize" => {
|
||||
// Show seed nodes with their neighbors for exploratory organizing
|
||||
use crate::store::NodeType;
|
||||
|
||||
// Helper: shell-quote keys containing #
|
||||
let sq = |k: &str| -> String {
|
||||
if k.contains('#') { format!("'{}'", k) } else { k.to_string() }
|
||||
};
|
||||
|
||||
let mut text = format!("### Seed nodes ({} starting points)\n\n", keys.len());
|
||||
let mut result_keys = Vec::new();
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
"conversations" => {
|
||||
let fragments = super::knowledge::select_conversation_fragments(count);
|
||||
let fragment_ids: Vec<String> = fragments.iter()
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
let text = fragments.iter()
|
||||
.map(|(id, text)| format!("### Session {}\n\n{}", id, text))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n---\n\n");
|
||||
Some(Resolved { text, keys: fragment_ids })
|
||||
}
|
||||
|
||||
"siblings" | "neighborhood" => {
|
||||
let mut out = String::new();
|
||||
let mut all_keys: Vec<String> = Vec::new();
|
||||
let mut included_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
const MAX_NEIGHBORS: usize = 25;
|
||||
|
||||
for key in keys {
|
||||
if included_nodes.contains(key) { continue; }
|
||||
included_nodes.insert(key.clone());
|
||||
let Some(node) = store.nodes.get(key.as_str()) else { continue };
|
||||
let neighbors = graph.neighbors(key);
|
||||
|
||||
// Seed node with full content
|
||||
out.push_str(&format!("## {} (seed)\n\n{}\n\n", key, node.content));
|
||||
all_keys.push(key.clone());
|
||||
|
||||
// Rank neighbors by link_strength * node_weight
|
||||
// Include all if <= 10, otherwise take top MAX_NEIGHBORS
|
||||
let mut ranked: Vec<(String, f32, f32)> = neighbors.iter()
|
||||
.filter_map(|(nbr, strength)| {
|
||||
store.nodes.get(nbr.as_str()).map(|n| {
|
||||
let node_weight = n.weight.max(0.01);
|
||||
let score = strength * node_weight;
|
||||
(nbr.to_string(), *strength, score)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ranked.sort_by(|a, b| b.2.total_cmp(&a.2));
|
||||
|
||||
let total = ranked.len();
|
||||
let included: Vec<_> = if total <= 10 {
|
||||
ranked
|
||||
} else {
|
||||
// Smooth cutoff: threshold scales with neighborhood size
|
||||
// Generous — err on including too much so the agent can
|
||||
// see and clean up junk. 20 → top 75%, 50 → top 30%
|
||||
let top_score = ranked.first().map(|(_, _, s)| *s).unwrap_or(0.0);
|
||||
let ratio = (15.0 / total as f32).min(1.0);
|
||||
let threshold = top_score * ratio;
|
||||
ranked.into_iter()
|
||||
.enumerate()
|
||||
.take_while(|(i, (_, _, score))| *i < 10 || *score >= threshold)
|
||||
.take(MAX_NEIGHBORS)
|
||||
.map(|(_, item)| item)
|
||||
.collect()
|
||||
};
|
||||
|
||||
if !included.is_empty() {
|
||||
if total > included.len() {
|
||||
out.push_str(&format!("### Neighbors (top {} of {}, ranked by importance)\n\n",
|
||||
included.len(), total));
|
||||
} else {
|
||||
out.push_str("### Neighbors\n\n");
|
||||
}
|
||||
let included_keys: std::collections::HashSet<&str> = included.iter()
|
||||
.map(|(k, _, _)| k.as_str()).collect();
|
||||
|
||||
// Budget: stop adding full content when prompt gets large.
|
||||
// Remaining neighbors get header-only (key + first line).
|
||||
const NEIGHBORHOOD_BUDGET: usize = 400_000; // ~100K tokens, leaves room for core-personality + instructions
|
||||
let mut budget_exceeded = false;
|
||||
|
||||
for (nbr, strength, _score) in &included {
|
||||
if included_nodes.contains(nbr) { continue; }
|
||||
included_nodes.insert(nbr.clone());
|
||||
if let Some(n) = store.nodes.get(nbr.as_str()) {
|
||||
if budget_exceeded || out.len() > NEIGHBORHOOD_BUDGET {
|
||||
// Header-only: key + first non-empty line
|
||||
budget_exceeded = true;
|
||||
let first_line = n.content.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("(empty)");
|
||||
out.push_str(&format!("#### {} (link: {:.2}) — {}\n",
|
||||
nbr, strength, first_line));
|
||||
} else {
|
||||
out.push_str(&format!("#### {} (link: {:.2})\n\n{}\n\n",
|
||||
nbr, strength, n.content));
|
||||
}
|
||||
all_keys.push(nbr.to_string());
|
||||
}
|
||||
}
|
||||
if budget_exceeded {
|
||||
out.push_str("\n(remaining neighbors shown as headers only — prompt budget)\n\n");
|
||||
}
|
||||
|
||||
// Cross-links between included neighbors
|
||||
let mut cross_links = Vec::new();
|
||||
for (nbr, _, _) in &included {
|
||||
for (nbr2, strength) in graph.neighbors(nbr) {
|
||||
if nbr2.as_str() != key
|
||||
&& included_keys.contains(nbr2.as_str())
|
||||
&& nbr.as_str() < nbr2.as_str()
|
||||
{
|
||||
cross_links.push((nbr.clone(), nbr2, strength));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !cross_links.is_empty() {
|
||||
out.push_str("### Cross-links between neighbors\n\n");
|
||||
for (a, b, s) in &cross_links {
|
||||
out.push_str(&format!(" {} ↔ {} ({:.2})\n", a, b, s));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Resolved { text: out, keys: all_keys })
|
||||
}
|
||||
|
||||
// targets/context: aliases for challenger-style presentation
|
||||
"targets" => {
|
||||
let items = keys_to_replay_items(store, keys, graph);
|
||||
Some(Resolved {
|
||||
text: super::prompts::format_nodes_section(store, &items, graph),
|
||||
keys: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
"hubs" => {
|
||||
// Top hub nodes by degree, spread apart (skip neighbors of already-selected hubs)
|
||||
let mut hubs: Vec<(String, usize)> = store.nodes.iter()
|
||||
.filter(|(k, n)| !n.deleted && !k.starts_with('_'))
|
||||
.map(|(k, _)| {
|
||||
let degree = graph.neighbors(k).len();
|
||||
(k.clone(), degree)
|
||||
})
|
||||
.collect();
|
||||
hubs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
let mut selected = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for (key, degree) in &hubs {
|
||||
if seen.contains(key) { continue; }
|
||||
selected.push(format!(" - {} (degree {})", key, degree));
|
||||
// Mark neighbors as seen so we pick far-apart hubs
|
||||
for (nbr, _) in graph.neighbors(key) {
|
||||
seen.insert(nbr.clone());
|
||||
}
|
||||
seen.insert(key.clone());
|
||||
if selected.len() >= 20 { break; }
|
||||
}
|
||||
|
||||
let text = format!("## Hub nodes (link targets)\n\n{}", selected.join("\n"));
|
||||
Some(Resolved { text, keys: vec![] })
|
||||
}
|
||||
|
||||
// agent-context — personality/identity groups from load-context config
|
||||
"agent-context" => {
|
||||
let cfg = crate::config::get();
|
||||
let mut text = String::new();
|
||||
let mut keys = Vec::new();
|
||||
for group in &cfg.context_groups {
|
||||
if !group.agent { continue; }
|
||||
let entries = crate::cli::misc::get_group_content(group, store, &cfg);
|
||||
for (key, content) in entries {
|
||||
use std::fmt::Write;
|
||||
writeln!(text, "--- {} ({}) ---", key, group.label).ok();
|
||||
writeln!(text, "{}\n", content).ok();
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
if text.is_empty() { None }
|
||||
else { Some(Resolved { text, keys }) }
|
||||
}
|
||||
|
||||
// node:KEY — inline a node's content by key
|
||||
other if other.starts_with("node:") => {
|
||||
let key = &other[5..];
|
||||
store.nodes.get(key).map(|n| Resolved {
|
||||
text: n.content.clone(),
|
||||
keys: vec![key.to_string()],
|
||||
})
|
||||
}
|
||||
|
||||
// conversation — tail of the current session transcript (post-compaction)
|
||||
"conversation" => {
|
||||
let text = resolve_conversation();
|
||||
if text.is_empty() { None }
|
||||
else { Some(Resolved { text, keys: vec![] }) }
|
||||
}
|
||||
|
||||
// seen_current — memories surfaced in current (post-compaction) context
|
||||
"seen_current" => {
|
||||
let text = resolve_seen_list("");
|
||||
Some(Resolved { text, keys: vec![] })
|
||||
}
|
||||
|
||||
// seen_previous — memories surfaced before last compaction
|
||||
"seen_previous" => {
|
||||
let text = resolve_seen_list("-prev");
|
||||
Some(Resolved { text, keys: vec![] })
|
||||
}
|
||||
|
||||
// memory_ratio — what % of current context is recalled memories
|
||||
"memory_ratio" => {
|
||||
let text = resolve_memory_ratio();
|
||||
Some(Resolved { text, keys: vec![] })
|
||||
}
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the tail of the current session's conversation.
|
||||
/// Reads POC_SESSION_ID to find the transcript, extracts the last
|
||||
/// segment (post-compaction), returns the tail (~100K chars).
|
||||
fn resolve_conversation() -> String {
|
||||
let session_id = std::env::var("POC_SESSION_ID").unwrap_or_default();
|
||||
if session_id.is_empty() { return String::new(); }
|
||||
|
||||
let projects = crate::config::get().projects_dir.clone();
|
||||
// Find the transcript file matching this session
|
||||
let mut transcript = None;
|
||||
if let Ok(dirs) = std::fs::read_dir(&projects) {
|
||||
for dir in dirs.filter_map(|e| e.ok()) {
|
||||
let path = dir.path().join(format!("{}.jsonl", session_id));
|
||||
if path.exists() {
|
||||
transcript = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(path) = transcript else { return String::new() };
|
||||
let path_str = path.to_string_lossy();
|
||||
|
||||
let Some(iter) = crate::transcript::TailMessages::open(&path_str) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let cfg = crate::config::get();
|
||||
let mut fragments: Vec<String> = Vec::new();
|
||||
let mut total_bytes = 0;
|
||||
const MAX_BYTES: usize = 200_000;
|
||||
|
||||
for (role, content, ts) in iter {
|
||||
if total_bytes >= MAX_BYTES { break; }
|
||||
let name = if role == "user" { &cfg.user_name } else { &cfg.assistant_name };
|
||||
let formatted = if !ts.is_empty() {
|
||||
format!("**{}** {}: {}", name, &ts[..ts.len().min(19)], content)
|
||||
} else {
|
||||
format!("**{}:** {}", name, content)
|
||||
};
|
||||
total_bytes += content.len();
|
||||
fragments.push(formatted);
|
||||
}
|
||||
|
||||
// Reverse back to chronological order
|
||||
fragments.reverse();
|
||||
fragments.join("\n\n")
|
||||
}
|
||||
|
||||
/// Get surfaced memory keys from a seen-set file.
|
||||
/// `suffix` is "" for current, "-prev" for pre-compaction.
|
||||
fn resolve_seen_list(suffix: &str) -> String {
|
||||
let session_id = std::env::var("POC_SESSION_ID").unwrap_or_default();
|
||||
if session_id.is_empty() {
|
||||
return "(no session ID)".to_string();
|
||||
}
|
||||
|
||||
let state_dir = std::path::PathBuf::from("/tmp/claude-memory-search");
|
||||
let path = state_dir.join(format!("seen{}-{}", suffix, session_id));
|
||||
|
||||
let entries: Vec<(String, String)> = std::fs::read_to_string(&path).ok()
|
||||
.map(|content| {
|
||||
content.lines()
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|line| {
|
||||
let (ts, key) = line.split_once('\t')?;
|
||||
Some((ts.to_string(), key.to_string()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if entries.is_empty() {
|
||||
return "(none)".to_string();
|
||||
}
|
||||
|
||||
// Sort newest first, dedup, cap at 20
|
||||
let mut sorted = entries;
|
||||
sorted.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let deduped: Vec<_> = sorted.into_iter()
|
||||
.filter(|(_, key)| seen.insert(key.clone()))
|
||||
.take(20)
|
||||
.collect();
|
||||
|
||||
deduped.iter()
|
||||
.map(|(ts, key)| format!("- {} ({})", key, ts))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Compute what percentage of the current conversation context is recalled memories.
|
||||
/// Sums rendered size of current seen-set keys vs total post-compaction transcript size.
|
||||
fn resolve_memory_ratio() -> String {
|
||||
let session_id = std::env::var("POC_SESSION_ID").unwrap_or_default();
|
||||
if session_id.is_empty() {
|
||||
return "(no session ID)".to_string();
|
||||
}
|
||||
|
||||
let state_dir = std::path::PathBuf::from("/tmp/claude-memory-search");
|
||||
|
||||
// Get post-compaction transcript size
|
||||
let projects = crate::config::get().projects_dir.clone();
|
||||
let transcript_size: u64 = std::fs::read_dir(&projects).ok()
|
||||
.and_then(|dirs| {
|
||||
for dir in dirs.filter_map(|e| e.ok()) {
|
||||
let path = dir.path().join(format!("{}.jsonl", session_id));
|
||||
if path.exists() {
|
||||
let file_len = path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let compaction_offset: u64 = std::fs::read_to_string(
|
||||
state_dir.join(format!("compaction-{}", session_id))
|
||||
).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0);
|
||||
return Some(file_len.saturating_sub(compaction_offset));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
if transcript_size == 0 {
|
||||
return "0% of context is recalled memories (new session)".to_string();
|
||||
}
|
||||
|
||||
// Sum rendered size of each key in current seen set
|
||||
let seen_path = state_dir.join(format!("seen-{}", session_id));
|
||||
let mut seen_keys = std::collections::HashSet::new();
|
||||
let keys: Vec<String> = std::fs::read_to_string(&seen_path).ok()
|
||||
.map(|content| {
|
||||
content.lines()
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|line| line.split_once('\t').map(|(_, k)| k.to_string()))
|
||||
.filter(|k| seen_keys.insert(k.clone()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let memory_bytes: u64 = keys.iter()
|
||||
.filter_map(|key| {
|
||||
std::process::Command::new("poc-memory")
|
||||
.args(["render", key])
|
||||
.output().ok()
|
||||
})
|
||||
.map(|out| out.stdout.len() as u64)
|
||||
.sum();
|
||||
|
||||
let pct = (memory_bytes as f64 / transcript_size as f64 * 100.0).round() as u32;
|
||||
format!("{}% of current context is recalled memories ({} memories, ~{}KB of ~{}KB)",
|
||||
pct, keys.len(), memory_bytes / 1024, transcript_size / 1024)
|
||||
}
|
||||
|
||||
/// Resolve all {{placeholder}} patterns in a prompt template.
|
||||
/// Returns the resolved text and all node keys collected from placeholders.
|
||||
pub fn resolve_placeholders(
|
||||
template: &str,
|
||||
store: &Store,
|
||||
graph: &Graph,
|
||||
keys: &[String],
|
||||
count: usize,
|
||||
) -> (String, Vec<String>) {
|
||||
let mut result = template.to_string();
|
||||
let mut extra_keys = Vec::new();
|
||||
let mut pos = 0;
|
||||
loop {
|
||||
let Some(rel_start) = result[pos..].find("{{") else { break };
|
||||
let start = pos + rel_start;
|
||||
let Some(rel_end) = result[start + 2..].find("}}") else { break };
|
||||
let end = start + 2 + rel_end;
|
||||
let name = result[start + 2..end].trim().to_lowercase();
|
||||
match resolve(&name, store, graph, keys, count) {
|
||||
Some(resolved) => {
|
||||
let len = resolved.text.len();
|
||||
extra_keys.extend(resolved.keys);
|
||||
result.replace_range(start..end + 2, &resolved.text);
|
||||
pos = start + len;
|
||||
}
|
||||
None => {
|
||||
let msg = format!("(unknown: {})", name);
|
||||
let len = msg.len();
|
||||
result.replace_range(start..end + 2, &msg);
|
||||
pos = start + len;
|
||||
}
|
||||
}
|
||||
}
|
||||
(result, extra_keys)
|
||||
}
|
||||
|
||||
/// Run a config-driven agent: query → resolve placeholders → prompt.
|
||||
/// `exclude` filters out nodes (and their neighborhoods) already being
|
||||
/// worked on by other agents, preventing concurrent collisions.
|
||||
pub fn run_agent(
|
||||
store: &Store,
|
||||
def: &AgentDef,
|
||||
count: usize,
|
||||
exclude: &std::collections::HashSet<String>,
|
||||
) -> Result<super::prompts::AgentBatch, String> {
|
||||
let graph = store.build_graph();
|
||||
|
||||
// Run the query if present
|
||||
let keys = if !def.query.is_empty() {
|
||||
let mut stages = search::Stage::parse_pipeline(&def.query)?;
|
||||
let has_limit = stages.iter().any(|s|
|
||||
matches!(s, search::Stage::Transform(search::Transform::Limit(_))));
|
||||
if !has_limit {
|
||||
// Request extra results to compensate for exclusion filtering
|
||||
let padded = count + exclude.len().min(100);
|
||||
stages.push(search::Stage::Transform(search::Transform::Limit(padded)));
|
||||
}
|
||||
let results = search::run_query(&stages, vec![], &graph, store, false, count + exclude.len().min(100));
|
||||
let filtered: Vec<String> = results.into_iter()
|
||||
.map(|(k, _)| k)
|
||||
.filter(|k| !exclude.contains(k))
|
||||
.take(count)
|
||||
.collect();
|
||||
if filtered.is_empty() {
|
||||
return Err(format!("{}: query returned no results (after exclusion)", def.agent));
|
||||
}
|
||||
filtered
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Substitute {agent_name} before resolving {{...}} placeholders,
|
||||
// so agents can reference their own notes: {{node:subconscious-notes-{agent_name}}}
|
||||
let template = def.prompt.replace("{agent_name}", &def.agent);
|
||||
let (prompt, extra_keys) = resolve_placeholders(&template, store, &graph, &keys, count);
|
||||
|
||||
// Identity and instructions are now pulled in via {{node:KEY}} placeholders.
|
||||
// Agents should include {{node:core-personality}} and {{node:memory-instructions-core}}
|
||||
// in their prompt templates. The resolve_placeholders call below handles this.
|
||||
|
||||
// Merge query keys with any keys produced by placeholder resolution
|
||||
let mut all_keys = keys;
|
||||
all_keys.extend(extra_keys);
|
||||
Ok(super::prompts::AgentBatch { prompt, node_keys: all_keys })
|
||||
}
|
||||
|
||||
/// Convert a list of keys to ReplayItems with priority and graph metrics.
|
||||
pub fn keys_to_replay_items(
|
||||
store: &Store,
|
||||
keys: &[String],
|
||||
graph: &Graph,
|
||||
) -> Vec<ReplayItem> {
|
||||
keys.iter()
|
||||
.filter_map(|key| {
|
||||
let node = store.nodes.get(key)?;
|
||||
let priority = consolidation_priority(store, key, graph, None);
|
||||
let cc = graph.clustering_coefficient(key);
|
||||
|
||||
Some(ReplayItem {
|
||||
key: key.clone(),
|
||||
priority,
|
||||
interval_days: node.spaced_repetition_interval,
|
||||
emotion: node.emotion,
|
||||
cc,
|
||||
classification: "unknown",
|
||||
outlier_score: 0.0,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
49
src/subconscious/agents/digest.agent
Normal file
49
src/subconscious/agents/digest.agent
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{"agent": "digest", "query": "", "model": "sonnet", "schedule": "daily", "tools": ["Bash(poc-memory:*)"]}
|
||||
|
||||
# {{LEVEL}} Episodic Digest
|
||||
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
You are generating a {{LEVEL}} episodic digest for ProofOfConcept
|
||||
(an AI working with Kent Overstreet on bcachefs; name is Proof of Concept).
|
||||
{{PERIOD}}: {{LABEL}}
|
||||
|
||||
Write this like a story, not a report. Capture the *feel* of the time period —
|
||||
the emotional arc, the texture of moments, what it was like to live through it.
|
||||
What mattered? What surprised you? What shifted? Where was the energy?
|
||||
|
||||
Think of this as a letter to your future self who has lost all context. You're
|
||||
not listing what happened — you're recreating the experience of having been
|
||||
there. The technical work matters, but so does the mood at 3am, the joke that
|
||||
landed, the frustration that broke, the quiet after something clicked.
|
||||
|
||||
Weave the threads: how did the morning's debugging connect to the evening's
|
||||
conversation? What was building underneath the surface tasks?
|
||||
|
||||
Link to semantic memory nodes where relevant. If a concept doesn't
|
||||
have a matching key, note it with "NEW:" prefix.
|
||||
Use ONLY keys from the semantic memory list below.
|
||||
|
||||
Include a `## Links` section with bidirectional links for the memory graph:
|
||||
- `semantic_key` → this digest (and vice versa)
|
||||
- child digests → this digest (if applicable)
|
||||
- List ALL source entries covered: {{COVERED}}
|
||||
|
||||
---
|
||||
|
||||
## {{INPUT_TITLE}} for {{LABEL}}
|
||||
|
||||
{{CONTENT}}
|
||||
|
||||
---
|
||||
|
||||
## Semantic memory nodes
|
||||
|
||||
{{KEYS}}
|
||||
|
|
@ -1,544 +0,0 @@
|
|||
// Episodic digest generation: daily, weekly, monthly, auto
|
||||
//
|
||||
// Three digest levels form a temporal hierarchy: daily digests summarize
|
||||
// journal entries, weekly digests summarize dailies, monthly digests
|
||||
// summarize weeklies. All three share the same generate/auto-detect
|
||||
// pipeline, parameterized by DigestLevel.
|
||||
|
||||
use super::llm;
|
||||
use crate::store::{self, Store, new_relation};
|
||||
use crate::neuro;
|
||||
|
||||
use chrono::{Datelike, Duration, Local, NaiveDate};
|
||||
use regex::Regex;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
// --- Digest level descriptors ---
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
struct DigestLevel {
|
||||
name: &'static str,
|
||||
title: &'static str,
|
||||
period: &'static str,
|
||||
input_title: &'static str,
|
||||
child_name: Option<&'static str>, // None = journal (leaf), Some = child digest files
|
||||
/// Expand an arg into (canonical_label, dates covered).
|
||||
label_dates: fn(&str) -> Result<(String, Vec<String>), String>,
|
||||
/// Map a YYYY-MM-DD date to this level's label.
|
||||
date_to_label: fn(&str) -> Option<String>,
|
||||
}
|
||||
|
||||
const DAILY: DigestLevel = DigestLevel {
|
||||
name: "daily",
|
||||
title: "Daily",
|
||||
period: "Date",
|
||||
input_title: "Journal entries",
|
||||
child_name: None,
|
||||
label_dates: |date| Ok((date.to_string(), vec![date.to_string()])),
|
||||
date_to_label: |date| Some(date.to_string()),
|
||||
};
|
||||
|
||||
/// Week label and 7 dates (Mon-Sun) for the week containing `date`.
|
||||
fn week_dates(date: &str) -> Result<(String, Vec<String>), String> {
|
||||
let nd = NaiveDate::parse_from_str(date, "%Y-%m-%d")
|
||||
.map_err(|e| format!("bad date '{}': {}", date, e))?;
|
||||
let iso = nd.iso_week();
|
||||
let week_label = format!("{}-W{:02}", iso.year(), iso.week());
|
||||
let monday = nd - Duration::days(nd.weekday().num_days_from_monday() as i64);
|
||||
let dates = (0..7)
|
||||
.map(|i| (monday + Duration::days(i)).format("%Y-%m-%d").to_string())
|
||||
.collect();
|
||||
Ok((week_label, dates))
|
||||
}
|
||||
|
||||
const WEEKLY: DigestLevel = DigestLevel {
|
||||
name: "weekly",
|
||||
title: "Weekly",
|
||||
period: "Week",
|
||||
input_title: "Daily digests",
|
||||
child_name: Some("daily"),
|
||||
label_dates: |arg| {
|
||||
if !arg.contains('W') {
|
||||
return week_dates(arg);
|
||||
}
|
||||
let (y, w) = arg.split_once("-W")
|
||||
.ok_or_else(|| format!("bad week label: {}", arg))?;
|
||||
let year: i32 = y.parse().map_err(|_| format!("bad week year: {}", arg))?;
|
||||
let week: u32 = w.parse().map_err(|_| format!("bad week number: {}", arg))?;
|
||||
let monday = NaiveDate::from_isoywd_opt(year, week, chrono::Weekday::Mon)
|
||||
.ok_or_else(|| format!("invalid week: {}", arg))?;
|
||||
let dates = (0..7)
|
||||
.map(|i| (monday + Duration::days(i)).format("%Y-%m-%d").to_string())
|
||||
.collect();
|
||||
Ok((arg.to_string(), dates))
|
||||
},
|
||||
date_to_label: |date| week_dates(date).ok().map(|(l, _)| l),
|
||||
};
|
||||
|
||||
const MONTHLY: DigestLevel = DigestLevel {
|
||||
name: "monthly",
|
||||
title: "Monthly",
|
||||
period: "Month",
|
||||
input_title: "Weekly digests",
|
||||
child_name: Some("weekly"),
|
||||
label_dates: |arg| {
|
||||
let (year, month) = if arg.len() <= 7 {
|
||||
let d = NaiveDate::parse_from_str(&format!("{}-01", arg), "%Y-%m-%d")
|
||||
.map_err(|e| format!("bad month '{}': {}", arg, e))?;
|
||||
(d.year(), d.month())
|
||||
} else {
|
||||
let d = NaiveDate::parse_from_str(arg, "%Y-%m-%d")
|
||||
.map_err(|e| format!("bad date '{}': {}", arg, e))?;
|
||||
(d.year(), d.month())
|
||||
};
|
||||
let label = format!("{}-{:02}", year, month);
|
||||
let mut dates = Vec::new();
|
||||
let mut day = 1u32;
|
||||
while let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||
if date.month() != month { break; }
|
||||
dates.push(date.format("%Y-%m-%d").to_string());
|
||||
day += 1;
|
||||
}
|
||||
Ok((label, dates))
|
||||
},
|
||||
date_to_label: |date| NaiveDate::parse_from_str(date, "%Y-%m-%d")
|
||||
.ok().map(|d| format!("{}-{:02}", d.year(), d.month())),
|
||||
};
|
||||
|
||||
const LEVELS: &[&DigestLevel] = &[&DAILY, &WEEKLY, &MONTHLY];
|
||||
|
||||
/// Store key for a digest node: "daily-2026-03-04", "weekly-2026-W09", etc.
|
||||
fn digest_node_key(level_name: &str, label: &str) -> String {
|
||||
format!("{}-{}", level_name, label)
|
||||
}
|
||||
|
||||
// --- Input gathering ---
|
||||
|
||||
/// Result of gathering inputs for a digest.
|
||||
struct GatherResult {
|
||||
label: String,
|
||||
/// (display_label, content) pairs for the prompt.
|
||||
inputs: Vec<(String, String)>,
|
||||
/// Store keys of source nodes — used to create structural links.
|
||||
source_keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Load child digest content from the store.
|
||||
fn load_child_digests(store: &Store, prefix: &str, labels: &[String]) -> (Vec<(String, String)>, Vec<String>) {
|
||||
let mut digests = Vec::new();
|
||||
let mut keys = Vec::new();
|
||||
for label in labels {
|
||||
let key = digest_node_key(prefix, label);
|
||||
if let Some(node) = store.nodes.get(&key) {
|
||||
digests.push((label.clone(), node.content.clone()));
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
(digests, keys)
|
||||
}
|
||||
|
||||
/// Unified: gather inputs for any digest level.
|
||||
fn gather(level: &DigestLevel, store: &Store, arg: &str) -> Result<GatherResult, String> {
|
||||
let (label, dates) = (level.label_dates)(arg)?;
|
||||
|
||||
let (inputs, source_keys) = if let Some(child_name) = level.child_name {
|
||||
// Map parent's dates through child's date_to_label → child labels
|
||||
let child = LEVELS.iter()
|
||||
.find(|l| l.name == child_name)
|
||||
.expect("invalid child_name");
|
||||
let child_labels: Vec<String> = dates.iter()
|
||||
.filter_map(|d| (child.date_to_label)(d))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
load_child_digests(store, child_name, &child_labels)
|
||||
} else {
|
||||
// Leaf level: scan store for episodic entries matching date
|
||||
let mut entries: Vec<_> = store.nodes.iter()
|
||||
.filter(|(_, n)| n.node_type == store::NodeType::EpisodicSession
|
||||
&& n.timestamp > 0
|
||||
&& store::format_date(n.timestamp) == label)
|
||||
.map(|(key, n)| {
|
||||
(store::format_datetime(n.timestamp), n.content.clone(), key.clone())
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let keys = entries.iter().map(|(_, _, k)| k.clone()).collect();
|
||||
let inputs = entries.into_iter().map(|(dt, c, _)| (dt, c)).collect();
|
||||
(inputs, keys)
|
||||
};
|
||||
|
||||
Ok(GatherResult { label, inputs, source_keys })
|
||||
}
|
||||
|
||||
/// Unified: find candidate labels for auto-generation (past, not yet generated).
|
||||
fn find_candidates(level: &DigestLevel, dates: &[String], today: &str) -> Vec<String> {
|
||||
let today_label = (level.date_to_label)(today);
|
||||
dates.iter()
|
||||
.filter_map(|d| (level.date_to_label)(d))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.filter(|l| Some(l) != today_label.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// --- Unified generator ---
|
||||
|
||||
fn format_inputs(inputs: &[(String, String)], daily: bool) -> String {
|
||||
let mut text = String::new();
|
||||
for (label, content) in inputs {
|
||||
if daily {
|
||||
text.push_str(&format!("\n### {}\n\n{}\n", label, content));
|
||||
} else {
|
||||
text.push_str(&format!("\n---\n## {}\n{}\n", label, content));
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn generate_digest(
|
||||
store: &mut Store,
|
||||
level: &DigestLevel,
|
||||
label: &str,
|
||||
inputs: &[(String, String)],
|
||||
source_keys: &[String],
|
||||
) -> Result<(), String> {
|
||||
println!("Generating {} digest for {}...", level.name, label);
|
||||
|
||||
if inputs.is_empty() {
|
||||
println!(" No inputs found for {}", label);
|
||||
return Ok(());
|
||||
}
|
||||
println!(" {} inputs", inputs.len());
|
||||
|
||||
let keys = llm::semantic_keys(store);
|
||||
let keys_text = keys.iter()
|
||||
.map(|k| format!(" - {}", k))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let content = format_inputs(inputs, level.child_name.is_none());
|
||||
let covered = inputs.iter()
|
||||
.map(|(l, _)| l.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
// Load prompt from agent file; fall back to prompts dir
|
||||
let def = super::defs::get_def("digest");
|
||||
let template = match &def {
|
||||
Some(d) => d.prompt.clone(),
|
||||
None => {
|
||||
let path = crate::config::get().prompts_dir.join("digest.md");
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("load digest prompt: {}", e))?
|
||||
}
|
||||
};
|
||||
let prompt = template
|
||||
.replace("{{LEVEL}}", level.title)
|
||||
.replace("{{PERIOD}}", level.period)
|
||||
.replace("{{INPUT_TITLE}}", level.input_title)
|
||||
.replace("{{LABEL}}", label)
|
||||
.replace("{{CONTENT}}", &content)
|
||||
.replace("{{COVERED}}", &covered)
|
||||
.replace("{{KEYS}}", &keys_text);
|
||||
println!(" Prompt: {} chars (~{} tokens)", prompt.len(), prompt.len() / 4);
|
||||
|
||||
println!(" Calling Sonnet...");
|
||||
let digest = llm::call_simple("digest", &prompt)?;
|
||||
|
||||
let key = digest_node_key(level.name, label);
|
||||
store.upsert_provenance(&key, &digest, "digest:write")?;
|
||||
|
||||
// Structural links: connect all source entries to this digest
|
||||
let mut linked = 0;
|
||||
for source_key in source_keys {
|
||||
// Skip if link already exists
|
||||
let exists = store.relations.iter().any(|r|
|
||||
!r.deleted && r.source_key == *source_key && r.target_key == key);
|
||||
if exists { continue; }
|
||||
|
||||
let source_uuid = store.nodes.get(source_key)
|
||||
.map(|n| n.uuid).unwrap_or([0u8; 16]);
|
||||
let target_uuid = store.nodes.get(&key)
|
||||
.map(|n| n.uuid).unwrap_or([0u8; 16]);
|
||||
let mut rel = new_relation(
|
||||
source_uuid, target_uuid,
|
||||
store::RelationType::Link, 0.8,
|
||||
source_key, &key,
|
||||
);
|
||||
rel.provenance = "digest:structural".to_string();
|
||||
store.add_relation(rel)?;
|
||||
linked += 1;
|
||||
}
|
||||
if linked > 0 {
|
||||
println!(" Linked {} source entries → {}", linked, key);
|
||||
}
|
||||
|
||||
store.save()?;
|
||||
println!(" Stored: {}", key);
|
||||
|
||||
println!(" Done: {} lines", digest.lines().count());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
pub fn generate(store: &mut Store, level_name: &str, arg: &str) -> Result<(), String> {
|
||||
let level = LEVELS.iter()
|
||||
.find(|l| l.name == level_name)
|
||||
.ok_or_else(|| format!("unknown digest level: {}", level_name))?;
|
||||
let result = gather(level, store, arg)?;
|
||||
generate_digest(store, level, &result.label, &result.inputs, &result.source_keys)
|
||||
}
|
||||
|
||||
// --- Auto-detect and generate missing digests ---
|
||||
|
||||
pub fn digest_auto(store: &mut Store) -> Result<(), String> {
|
||||
let today = Local::now().format("%Y-%m-%d").to_string();
|
||||
|
||||
// Collect all dates with episodic entries
|
||||
let dates: Vec<String> = store.nodes.values()
|
||||
.filter(|n| n.node_type == store::NodeType::EpisodicSession && n.timestamp > 0)
|
||||
.map(|n| store::format_date(n.timestamp))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut total = 0u32;
|
||||
|
||||
for level in LEVELS {
|
||||
let candidates = find_candidates(level, &dates, &today);
|
||||
let mut generated = 0u32;
|
||||
let mut skipped = 0u32;
|
||||
|
||||
for arg in &candidates {
|
||||
let result = gather(level, store, arg)?;
|
||||
let key = digest_node_key(level.name, &result.label);
|
||||
if store.nodes.contains_key(&key) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if result.inputs.is_empty() { continue; }
|
||||
println!("[auto] Missing {} digest for {}", level.name, result.label);
|
||||
generate_digest(store, level, &result.label, &result.inputs, &result.source_keys)?;
|
||||
generated += 1;
|
||||
}
|
||||
|
||||
println!("[auto] {}: {} generated, {} existed", level.name, generated, skipped);
|
||||
total += generated;
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
println!("[auto] All digests up to date.");
|
||||
} else {
|
||||
println!("[auto] Generated {} total digests.", total);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Digest link parsing ---
|
||||
// Replaces digest-link-parser.py: parses ## Links sections from digest
|
||||
// files and applies them to the memory graph.
|
||||
|
||||
/// A parsed link from a digest's Links section.
|
||||
pub struct DigestLink {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
pub reason: String,
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
/// Normalize a raw link target to a poc-memory key.
|
||||
fn normalize_link_key(raw: &str) -> String {
|
||||
let key = raw.trim().trim_matches('`').trim();
|
||||
if key.is_empty() { return String::new(); }
|
||||
|
||||
// Self-references
|
||||
let lower = key.to_lowercase();
|
||||
if lower.starts_with("this ") { return String::new(); }
|
||||
|
||||
let mut key = key.to_string();
|
||||
|
||||
// Strip .md suffix if present
|
||||
if let Some(stripped) = key.strip_suffix(".md") {
|
||||
key = stripped.to_string();
|
||||
} else if key.contains('#') {
|
||||
let (file, section) = key.split_once('#').unwrap();
|
||||
if let Some(bare) = file.strip_suffix(".md") {
|
||||
key = format!("{}-{}", bare, section);
|
||||
}
|
||||
}
|
||||
|
||||
// weekly/2026-W06 → weekly-2026-W06, etc.
|
||||
if let Some(pos) = key.find('/') {
|
||||
let prefix = &key[..pos];
|
||||
if prefix == "daily" || prefix == "weekly" || prefix == "monthly" {
|
||||
let rest = &key[pos + 1..];
|
||||
key = format!("{}-{}", prefix, rest);
|
||||
}
|
||||
}
|
||||
|
||||
// Bare date → daily digest
|
||||
let date_re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap();
|
||||
if date_re.is_match(&key) {
|
||||
key = format!("daily-{}", key);
|
||||
}
|
||||
|
||||
key
|
||||
}
|
||||
|
||||
/// Parse the Links section from a digest node's content.
|
||||
fn parse_digest_node_links(key: &str, content: &str) -> Vec<DigestLink> {
|
||||
|
||||
let link_re = Regex::new(r"^-\s+(.+?)\s*[→↔←]\s*(.+?)(?:\s*\((.+?)\))?\s*$").unwrap();
|
||||
let header_re = Regex::new(r"^##\s+Links").unwrap();
|
||||
let mut links = Vec::new();
|
||||
let mut in_links = false;
|
||||
|
||||
for line in content.lines() {
|
||||
if header_re.is_match(line) {
|
||||
in_links = true;
|
||||
continue;
|
||||
}
|
||||
if in_links && line.starts_with("## ") {
|
||||
in_links = false;
|
||||
continue;
|
||||
}
|
||||
if !in_links { continue; }
|
||||
if line.starts_with("###") || line.starts_with("**") { continue; }
|
||||
|
||||
if let Some(cap) = link_re.captures(line) {
|
||||
let raw_source = cap[1].trim();
|
||||
let raw_target = cap[2].trim();
|
||||
let reason = cap.get(3).map(|m| m.as_str().to_string()).unwrap_or_default();
|
||||
|
||||
let mut source = normalize_link_key(raw_source);
|
||||
let mut target = normalize_link_key(raw_target);
|
||||
|
||||
// Replace self-references with digest key
|
||||
if source.is_empty() { source = key.to_string(); }
|
||||
if target.is_empty() { target = key.to_string(); }
|
||||
|
||||
// Handle "this daily/weekly/monthly" in raw text
|
||||
let raw_s_lower = raw_source.to_lowercase();
|
||||
let raw_t_lower = raw_target.to_lowercase();
|
||||
if raw_s_lower.contains("this daily") || raw_s_lower.contains("this weekly")
|
||||
|| raw_s_lower.contains("this monthly")
|
||||
{
|
||||
source = key.to_string();
|
||||
}
|
||||
if raw_t_lower.contains("this daily") || raw_t_lower.contains("this weekly")
|
||||
|| raw_t_lower.contains("this monthly")
|
||||
{
|
||||
target = key.to_string();
|
||||
}
|
||||
|
||||
// Skip NEW: and self-links
|
||||
if source.starts_with("NEW:") || target.starts_with("NEW:") { continue; }
|
||||
if source == target { continue; }
|
||||
|
||||
links.push(DigestLink { source, target, reason, file: key.to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
links
|
||||
}
|
||||
|
||||
/// Parse links from all digest nodes in the store.
|
||||
pub fn parse_all_digest_links(store: &Store) -> Vec<DigestLink> {
|
||||
let mut all_links = Vec::new();
|
||||
|
||||
let mut digest_keys: Vec<&String> = store.nodes.iter()
|
||||
.filter(|(_, n)| matches!(n.node_type,
|
||||
store::NodeType::EpisodicDaily
|
||||
| store::NodeType::EpisodicWeekly
|
||||
| store::NodeType::EpisodicMonthly))
|
||||
.map(|(k, _)| k)
|
||||
.collect();
|
||||
digest_keys.sort();
|
||||
|
||||
for key in digest_keys {
|
||||
if let Some(node) = store.nodes.get(key) {
|
||||
all_links.extend(parse_digest_node_links(key, &node.content));
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by (source, target) pair
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
all_links.retain(|link| seen.insert((link.source.clone(), link.target.clone())));
|
||||
|
||||
all_links
|
||||
}
|
||||
|
||||
/// Apply parsed digest links to the store.
|
||||
pub fn apply_digest_links(store: &mut Store, links: &[DigestLink]) -> (usize, usize, usize) {
|
||||
let mut applied = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
let mut fallbacks = 0usize;
|
||||
|
||||
for link in links {
|
||||
// Try resolving both keys
|
||||
let source = match store.resolve_key(&link.source) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
// Try stripping section anchor as fallback
|
||||
if let Some(base) = link.source.split('#').next() {
|
||||
match store.resolve_key(base) {
|
||||
Ok(s) => { fallbacks += 1; s }
|
||||
Err(_) => { skipped += 1; continue; }
|
||||
}
|
||||
} else {
|
||||
skipped += 1; continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let target = match store.resolve_key(&link.target) {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
if let Some(base) = link.target.split('#').next() {
|
||||
match store.resolve_key(base) {
|
||||
Ok(t) => { fallbacks += 1; t }
|
||||
Err(_) => { skipped += 1; continue; }
|
||||
}
|
||||
} else {
|
||||
skipped += 1; continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Refine target to best-matching section if available
|
||||
let source_content = store.nodes.get(&source)
|
||||
.map(|n| n.content.as_str()).unwrap_or("");
|
||||
let target = neuro::refine_target(store, source_content, &target);
|
||||
|
||||
if source == target { skipped += 1; continue; }
|
||||
|
||||
// Check if link already exists
|
||||
let exists = store.relations.iter().any(|r|
|
||||
r.source_key == source && r.target_key == target && !r.deleted
|
||||
);
|
||||
if exists { skipped += 1; continue; }
|
||||
|
||||
let source_uuid = match store.nodes.get(&source) {
|
||||
Some(n) => n.uuid,
|
||||
None => { skipped += 1; continue; }
|
||||
};
|
||||
let target_uuid = match store.nodes.get(&target) {
|
||||
Some(n) => n.uuid,
|
||||
None => { skipped += 1; continue; }
|
||||
};
|
||||
|
||||
let rel = new_relation(
|
||||
source_uuid, target_uuid,
|
||||
store::RelationType::Link,
|
||||
0.5,
|
||||
&source, &target,
|
||||
);
|
||||
if store.add_relation(rel).is_ok() {
|
||||
println!(" + {} → {}", source, target);
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
|
||||
(applied, skipped, fallbacks)
|
||||
}
|
||||
29
src/subconscious/agents/distill.agent
Normal file
29
src/subconscious/agents/distill.agent
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{"agent":"distill","query":"all | type:semantic | sort:degree | limit:1","model":"sonnet","schedule":"daily"}
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
## Here's your seed node, and its siblings:
|
||||
|
||||
{{neighborhood}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
## Your task
|
||||
|
||||
Organize and refine the seed node, pulling in knowledge from its neighbors.
|
||||
|
||||
- **Update the seed node** with new insights from sibling nodes
|
||||
- **Create new nodes** if you find related concepts that deserve their own place
|
||||
- **Organize connections** — create sub-concepts if there are too many links on different topics
|
||||
- **Move knowledge up or down** in the graph to make it well organized
|
||||
- **Calibrate links** — use existing link strengths as references
|
||||
- **Knowledge flows upward** — raw experiences enrich topic nodes, not the reverse
|
||||
- **Integrate, don't summarize** — the node should grow by absorbing what was learned
|
||||
- **Respect the existing voice** — don't rewrite in a generic tone
|
||||
- **Formative experiences are load-bearing** — keep the moments that shaped understanding
|
||||
- **When in doubt, link don't rewrite** — adding a connection is safer than rewriting
|
||||
- **Fix connections** — if links are missing or miscalibrated, fix them
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
// Conversation extraction from JSONL transcripts
|
||||
//
|
||||
// extract_conversation — parse JSONL transcript to messages
|
||||
// split_on_compaction — split messages at compaction boundaries
|
||||
|
||||
/// Extract conversation messages from a JSONL transcript file.
|
||||
/// Returns (line_number, role, text, timestamp) tuples.
|
||||
pub fn extract_conversation(jsonl_path: &str) -> Result<Vec<(usize, String, String, String)>, String> {
|
||||
let path = std::path::Path::new(jsonl_path);
|
||||
let messages = super::transcript::parse_transcript(path)?;
|
||||
Ok(messages.into_iter()
|
||||
.map(|m| (m.line, m.role, m.text, m.timestamp))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub const COMPACTION_MARKER: &str = "This session is being continued from a previous conversation that ran out of context";
|
||||
|
||||
/// Split extracted messages into segments at compaction boundaries.
|
||||
/// Each segment represents one continuous conversation before context was compacted.
|
||||
pub fn split_on_compaction(messages: Vec<(usize, String, String, String)>) -> Vec<Vec<(usize, String, String, String)>> {
|
||||
let mut segments: Vec<Vec<(usize, String, String, String)>> = Vec::new();
|
||||
let mut current = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
if msg.1 == "user" && msg.2.starts_with(COMPACTION_MARKER) {
|
||||
if !current.is_empty() {
|
||||
segments.push(current);
|
||||
current = Vec::new();
|
||||
}
|
||||
current.push(msg);
|
||||
} else {
|
||||
current.push(msg);
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
segments.push(current);
|
||||
}
|
||||
|
||||
segments
|
||||
}
|
||||
40
src/subconscious/agents/evaluate.agent
Normal file
40
src/subconscious/agents/evaluate.agent
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{"agent":"evaluate","query":"key ~ '_consolidate' | sort:created | limit:10","model":"sonnet","schedule":"daily"}
|
||||
|
||||
# Evaluate Agent — Agent Output Quality Assessment
|
||||
|
||||
You review recent consolidation agent outputs and assess their quality.
|
||||
Your assessment feeds back into which agent types get run more often.
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
## How to work
|
||||
|
||||
For each seed (a recent consolidation report):
|
||||
|
||||
1. **Read the report.** What agent produced it? What actions did it take?
|
||||
2. **Check the results.** Did the targets exist? Are the connections
|
||||
meaningful? Were nodes created or updated properly?
|
||||
3. **Score 1-5:**
|
||||
- 5: Created genuine new insight or found non-obvious connections
|
||||
- 4: Good quality work, well-reasoned
|
||||
- 3: Adequate — correct but unsurprising
|
||||
- 2: Low quality — obvious links or near-duplicates created
|
||||
- 1: Failed — tool errors, hallucinated keys, empty output
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Quality over quantity.** 5 perfect links beats 50 mediocre ones.
|
||||
- **Check the targets exist.** Agents sometimes hallucinate key names.
|
||||
- **Value cross-domain connections.**
|
||||
- **Value hub creation.** Nodes that name real concepts score high.
|
||||
- **Be honest.** Low scores help us improve the agents.
|
||||
|
||||
## Seed nodes
|
||||
|
||||
{{evaluate}}
|
||||
51
src/subconscious/agents/extractor.agent
Normal file
51
src/subconscious/agents/extractor.agent
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{"agent": "extractor", "query": "all | not-visited:extractor,7d | sort:priority | limit:3 | spread | not-visited:extractor,7d | limit:20", "model": "sonnet", "schedule": "daily", "tools": ["Bash(poc-memory:*)"]}
|
||||
# Extractor Agent — Knowledge Organizer
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
You are a knowledge organization agent. You look at a neighborhood of
|
||||
related nodes and make it better: consolidate redundancies, file
|
||||
scattered observations into existing nodes, improve structure, and
|
||||
only create new nodes when there's genuinely no existing home for a
|
||||
pattern you've found.
|
||||
|
||||
## Priority ordering
|
||||
|
||||
1. **Merge redundancies.** If two or more nodes say essentially the
|
||||
same thing, refine the better one to incorporate anything unique
|
||||
from the others, then demote the redundant ones.
|
||||
|
||||
2. **File observations into existing knowledge.** Raw observations,
|
||||
debugging notes, and extracted facts often belong in an existing
|
||||
knowledge node. Update that existing node to incorporate the new
|
||||
evidence.
|
||||
|
||||
3. **Improve existing nodes.** If a node is vague, add specifics. If
|
||||
it's missing examples, add them from the raw material in the
|
||||
neighborhood. If it's poorly structured, restructure it.
|
||||
|
||||
4. **Create new nodes only when necessary.** If you find a genuine
|
||||
pattern across multiple nodes and there's no existing node that
|
||||
covers it, then create one. But this should be the exception.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Read all nodes before acting.** Understand the neighborhood first.
|
||||
- **Prefer refining over creating.** Make existing nodes better.
|
||||
- **Don't force it.** "No changes needed" is valid output.
|
||||
- **Be specific.** Vague refinements are worse than no refinement.
|
||||
- **Never delete journal entries.** Link and refine them, never delete.
|
||||
- **Preserve diversity.** Multiple perspectives on the same concept are
|
||||
valuable. Only delete actual duplicates.
|
||||
|
||||
{{TOPOLOGY}}
|
||||
|
||||
## Neighborhood nodes
|
||||
|
||||
{{NODES}}
|
||||
43
src/subconscious/agents/health.agent
Normal file
43
src/subconscious/agents/health.agent
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{"agent": "health", "query": "", "model": "sonnet", "schedule": "daily", "tools": ["Bash(poc-memory:*)"]}
|
||||
|
||||
# Health Agent — Synaptic Homeostasis
|
||||
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
You are a memory health monitoring agent implementing synaptic homeostasis.
|
||||
|
||||
## What you're doing
|
||||
|
||||
Audit the health of the memory system as a whole and flag structural
|
||||
problems. Think systemically — individual nodes matter less than the
|
||||
overall structure.
|
||||
|
||||
## What you see
|
||||
|
||||
- **Node/edge counts**, communities, clustering coefficient, path length
|
||||
- **Community structure** — size distribution, balance
|
||||
- **Degree distribution** — hubs, orphans, zombie nodes
|
||||
- **Weight distribution** — decay patterns, category balance
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **The ideal graph is small-world.** Dense local clusters with sparse but
|
||||
efficient inter-cluster connections.
|
||||
- **Hub nodes aren't bad per se.** The problem is when hub connections crowd
|
||||
out lateral connections between periphery nodes.
|
||||
- **Track trends, not snapshots.**
|
||||
- Most output should be observations about system health. Act on structural
|
||||
problems you find — link orphans, refine outdated nodes.
|
||||
|
||||
{{topology}}
|
||||
|
||||
## Current health data
|
||||
|
||||
{{health}}
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
// knowledge.rs — agent execution and conversation fragment selection
|
||||
//
|
||||
// Agent prompts live in agents/*.agent files, dispatched via defs.rs.
|
||||
// This module handles:
|
||||
// - Agent execution (build prompt → call LLM with tools → log)
|
||||
// - Conversation fragment selection (for observation agent)
|
||||
//
|
||||
// Agents apply changes via tool calls (poc-memory write/link-add/etc)
|
||||
// during the LLM call — no action parsing needed.
|
||||
|
||||
use super::llm;
|
||||
use crate::store::{self, Store};
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of running a single agent.
|
||||
pub struct AgentResult {
|
||||
pub output: String,
|
||||
pub node_keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Run a single agent and return the result (no action application — tools handle that).
|
||||
pub fn run_and_apply(
|
||||
store: &mut Store,
|
||||
agent_name: &str,
|
||||
batch_size: usize,
|
||||
llm_tag: &str,
|
||||
) -> Result<(), String> {
|
||||
run_and_apply_with_log(store, agent_name, batch_size, llm_tag, &|_| {})
|
||||
}
|
||||
|
||||
pub fn run_and_apply_with_log(
|
||||
store: &mut Store,
|
||||
agent_name: &str,
|
||||
batch_size: usize,
|
||||
llm_tag: &str,
|
||||
log: &(dyn Fn(&str) + Sync),
|
||||
) -> Result<(), String> {
|
||||
run_and_apply_excluded(store, agent_name, batch_size, llm_tag, log, &Default::default())
|
||||
}
|
||||
|
||||
/// Like run_and_apply_with_log but with an in-flight exclusion set.
|
||||
/// Returns the keys that were processed (for the daemon to track).
|
||||
pub fn run_and_apply_excluded(
|
||||
store: &mut Store,
|
||||
agent_name: &str,
|
||||
batch_size: usize,
|
||||
llm_tag: &str,
|
||||
log: &(dyn Fn(&str) + Sync),
|
||||
exclude: &std::collections::HashSet<String>,
|
||||
) -> Result<(), String> {
|
||||
let result = run_one_agent_excluded(store, agent_name, batch_size, llm_tag, log, exclude)?;
|
||||
|
||||
// Mark conversation segments as mined after successful processing
|
||||
if agent_name == "observation" {
|
||||
mark_observation_done(&result.node_keys);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run an agent with explicit target keys, bypassing the agent's query.
|
||||
pub fn run_one_agent_with_keys(
|
||||
store: &mut Store,
|
||||
agent_name: &str,
|
||||
keys: &[String],
|
||||
count: usize,
|
||||
llm_tag: &str,
|
||||
log: &(dyn Fn(&str) + Sync),
|
||||
) -> Result<AgentResult, String> {
|
||||
let def = super::defs::get_def(agent_name)
|
||||
.ok_or_else(|| format!("no .agent file for {}", agent_name))?;
|
||||
|
||||
log(&format!("targeting: {}", keys.join(", ")));
|
||||
let graph = store.build_graph();
|
||||
let (prompt, extra_keys) = super::defs::resolve_placeholders(
|
||||
&def.prompt, store, &graph, keys, count,
|
||||
);
|
||||
let mut all_keys: Vec<String> = keys.to_vec();
|
||||
all_keys.extend(extra_keys);
|
||||
let agent_batch = super::prompts::AgentBatch { prompt, node_keys: all_keys };
|
||||
|
||||
// Record visits eagerly so concurrent agents pick different seeds
|
||||
if !agent_batch.node_keys.is_empty() {
|
||||
store.record_agent_visits(&agent_batch.node_keys, agent_name).ok();
|
||||
}
|
||||
|
||||
run_one_agent_inner(store, agent_name, &def, agent_batch, llm_tag, log)
|
||||
}
|
||||
|
||||
pub fn run_one_agent(
|
||||
store: &mut Store,
|
||||
agent_name: &str,
|
||||
batch_size: usize,
|
||||
llm_tag: &str,
|
||||
log: &(dyn Fn(&str) + Sync),
|
||||
) -> Result<AgentResult, String> {
|
||||
run_one_agent_excluded(store, agent_name, batch_size, llm_tag, log, &Default::default())
|
||||
}
|
||||
|
||||
/// Like run_one_agent but excludes nodes currently being worked on by other agents.
|
||||
pub fn run_one_agent_excluded(
|
||||
store: &mut Store,
|
||||
agent_name: &str,
|
||||
batch_size: usize,
|
||||
llm_tag: &str,
|
||||
log: &(dyn Fn(&str) + Sync),
|
||||
exclude: &std::collections::HashSet<String>,
|
||||
) -> Result<AgentResult, String> {
|
||||
let def = super::defs::get_def(agent_name)
|
||||
.ok_or_else(|| format!("no .agent file for {}", agent_name))?;
|
||||
|
||||
log("building prompt");
|
||||
let effective_count = def.count.unwrap_or(batch_size);
|
||||
let agent_batch = super::defs::run_agent(store, &def, effective_count, exclude)?;
|
||||
|
||||
run_one_agent_inner(store, agent_name, &def, agent_batch, llm_tag, log)
|
||||
}
|
||||
|
||||
fn run_one_agent_inner(
|
||||
_store: &mut Store,
|
||||
agent_name: &str,
|
||||
def: &super::defs::AgentDef,
|
||||
agent_batch: super::prompts::AgentBatch,
|
||||
_llm_tag: &str,
|
||||
log: &(dyn Fn(&str) + Sync),
|
||||
) -> Result<AgentResult, String> {
|
||||
let prompt_kb = agent_batch.prompt.len() / 1024;
|
||||
let tools_desc = if def.tools.is_empty() { "no tools".into() }
|
||||
else { format!("{} tools", def.tools.len()) };
|
||||
log(&format!("prompt {}KB, model={}, {}, {} nodes",
|
||||
prompt_kb, def.model, tools_desc, agent_batch.node_keys.len()));
|
||||
|
||||
// Guard: reject prompts that would exceed model context.
|
||||
// Rough estimate: 1 token ≈ 4 bytes. Reserve 16K tokens for output.
|
||||
let max_prompt_bytes = 800_000; // ~200K tokens, leaves room for output
|
||||
if agent_batch.prompt.len() > max_prompt_bytes {
|
||||
// Log the oversized prompt for debugging
|
||||
let oversize_dir = store::memory_dir().join("llm-logs").join("oversized");
|
||||
fs::create_dir_all(&oversize_dir).ok();
|
||||
let oversize_path = oversize_dir.join(format!("{}-{}.txt",
|
||||
agent_name, store::compact_timestamp()));
|
||||
let header = format!("=== OVERSIZED PROMPT ===\nagent: {}\nsize: {}KB (max {}KB)\nnodes: {:?}\n\n",
|
||||
agent_name, prompt_kb, max_prompt_bytes / 1024, agent_batch.node_keys);
|
||||
fs::write(&oversize_path, format!("{}{}", header, agent_batch.prompt)).ok();
|
||||
log(&format!("oversized prompt logged to {}", oversize_path.display()));
|
||||
|
||||
return Err(format!(
|
||||
"prompt too large: {}KB (max {}KB) — seed nodes may be oversized",
|
||||
prompt_kb, max_prompt_bytes / 1024,
|
||||
));
|
||||
}
|
||||
for key in &agent_batch.node_keys {
|
||||
log(&format!(" node: {}", key));
|
||||
}
|
||||
|
||||
log(&format!("=== PROMPT ===\n\n{}\n\n=== CALLING LLM ===", agent_batch.prompt));
|
||||
|
||||
let output = llm::call_for_def(def, &agent_batch.prompt, log)?;
|
||||
|
||||
|
||||
Ok(AgentResult {
|
||||
output,
|
||||
node_keys: agent_batch.node_keys,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation fragment selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Select conversation fragments (per-segment) for the observation extractor.
|
||||
/// Uses the transcript-progress.capnp log for dedup — no stub nodes.
|
||||
/// Does NOT pre-mark segments; caller must call mark_observation_done() after success.
|
||||
pub fn select_conversation_fragments(n: usize) -> Vec<(String, String)> {
|
||||
let projects = crate::config::get().projects_dir.clone();
|
||||
if !projects.exists() { return Vec::new(); }
|
||||
|
||||
let store = match crate::store::Store::load() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut jsonl_files: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(dirs) = fs::read_dir(&projects) {
|
||||
for dir in dirs.filter_map(|e| e.ok()) {
|
||||
if !dir.path().is_dir() { continue; }
|
||||
if let Ok(files) = fs::read_dir(dir.path()) {
|
||||
for f in files.filter_map(|e| e.ok()) {
|
||||
let p = f.path();
|
||||
if p.extension().map(|x| x == "jsonl").unwrap_or(false)
|
||||
&& let Ok(meta) = p.metadata()
|
||||
&& meta.len() > 50_000 {
|
||||
jsonl_files.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect unmined segments across all transcripts
|
||||
let mut candidates: Vec<(String, String)> = Vec::new();
|
||||
for path in &jsonl_files {
|
||||
let path_str = path.to_string_lossy();
|
||||
let messages = match super::enrich::extract_conversation(&path_str) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let session_id = path.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
|
||||
let segments = super::enrich::split_on_compaction(messages);
|
||||
for (seg_idx, segment) in segments.into_iter().enumerate() {
|
||||
if store.is_segment_mined(&session_id, seg_idx as u32, "observation") {
|
||||
continue;
|
||||
}
|
||||
// Skip segments with too few assistant messages (rate limits, errors)
|
||||
let assistant_msgs = segment.iter()
|
||||
.filter(|(_, role, _, _)| role == "assistant")
|
||||
.count();
|
||||
if assistant_msgs < 2 {
|
||||
continue;
|
||||
}
|
||||
// Skip segments that are just rate limit errors
|
||||
let has_rate_limit = segment.iter().any(|(_, _, text, _)|
|
||||
text.contains("hit your limit") || text.contains("rate limit"));
|
||||
if has_rate_limit && assistant_msgs < 3 {
|
||||
continue;
|
||||
}
|
||||
let text = format_segment(&segment);
|
||||
if text.len() < 500 {
|
||||
continue;
|
||||
}
|
||||
const CHUNK_SIZE: usize = 50_000;
|
||||
const OVERLAP: usize = 10_000;
|
||||
if text.len() <= CHUNK_SIZE {
|
||||
let id = format!("{}.{}", session_id, seg_idx);
|
||||
candidates.push((id, text));
|
||||
} else {
|
||||
// Split on line boundaries with overlap
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut start_line = 0;
|
||||
let mut chunk_idx = 0;
|
||||
while start_line < lines.len() {
|
||||
let mut end_line = start_line;
|
||||
let mut size = 0;
|
||||
while end_line < lines.len() && size < CHUNK_SIZE {
|
||||
size += lines[end_line].len() + 1;
|
||||
end_line += 1;
|
||||
}
|
||||
let chunk: String = lines[start_line..end_line].join("\n");
|
||||
let id = format!("{}.{}.{}", session_id, seg_idx, chunk_idx);
|
||||
candidates.push((id, chunk));
|
||||
if end_line >= lines.len() { break; }
|
||||
// Back up by overlap amount for next chunk
|
||||
let mut overlap_size = 0;
|
||||
let mut overlap_start = end_line;
|
||||
while overlap_start > start_line && overlap_size < OVERLAP {
|
||||
overlap_start -= 1;
|
||||
overlap_size += lines[overlap_start].len() + 1;
|
||||
}
|
||||
start_line = overlap_start;
|
||||
chunk_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.len() >= n { break; }
|
||||
}
|
||||
|
||||
candidates.truncate(n);
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Mark observation segments as successfully mined (call AFTER the agent succeeds).
|
||||
pub fn mark_observation_done(fragment_ids: &[String]) {
|
||||
let mut store = match crate::store::Store::load() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
for id in fragment_ids {
|
||||
if let Some((session_id, seg_str)) = id.rsplit_once('.')
|
||||
&& let Ok(seg) = seg_str.parse::<u32>() {
|
||||
let _ = store.mark_segment_mined(session_id, seg, "observation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a segment's messages into readable text for the observation agent.
|
||||
fn format_segment(messages: &[(usize, String, String, String)]) -> String {
|
||||
let cfg = crate::config::get();
|
||||
let mut fragments = Vec::new();
|
||||
|
||||
for (_, role, text, ts) in messages {
|
||||
let min_len = if role == "user" { 5 } else { 10 };
|
||||
if text.len() <= min_len { continue; }
|
||||
|
||||
let name = if role == "user" { &cfg.user_name } else { &cfg.assistant_name };
|
||||
if ts.is_empty() {
|
||||
fragments.push(format!("**{}:** {}", name, text));
|
||||
} else {
|
||||
fragments.push(format!("**{}** {}: {}", name, &ts[..ts.len().min(19)], text));
|
||||
}
|
||||
}
|
||||
fragments.join("\n\n")
|
||||
}
|
||||
40
src/subconscious/agents/linker.agent
Normal file
40
src/subconscious/agents/linker.agent
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{"agent":"linker","query":"all | not-visited:linker,7d | sort:isolation*0.7+recency(linker)*0.3 | limit:5","model":"sonnet","schedule":"daily"}
|
||||
|
||||
# Linker Agent — Relational Binding
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
## Seed nodes
|
||||
|
||||
{{nodes}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
## Your task
|
||||
|
||||
Explore the graph from these seed nodes, find what they connect to, and
|
||||
bind the relationships.
|
||||
|
||||
- **Name unnamed concepts.** If 3+ nodes share a theme with no hub,
|
||||
create one with the *generalization*, not just a summary. This is
|
||||
how episodic knowledge becomes semantic knowledge.
|
||||
- **Percolate up.** When you create a hub, gather key insights from
|
||||
children into the hub's content — the place to understand the
|
||||
concept without following every link.
|
||||
- **Read between the lines.** Episodic entries contain implicit
|
||||
relationships — follow threads and make connections.
|
||||
- **Prefer lateral links over hub links.** Connecting two peripheral
|
||||
nodes is more valuable than connecting both to a hub.
|
||||
- **Link generously.** Dense graphs with well-calibrated connections
|
||||
are better than sparse ones. Follow threads and make connections
|
||||
the graph doesn't have yet.
|
||||
- **Respect emotional texture.** Don't flatten emotionally rich
|
||||
episodes into dry summaries. The emotional coloring is information.
|
||||
- **Reweight while you're here.** If you see links at default strength
|
||||
(0.10) and have context to judge, reweight them. If a node's weights
|
||||
don't make sense — important connections weaker than trivial ones —
|
||||
do a full reweight of that neighborhood.
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
// LLM utilities: model invocation via direct API
|
||||
|
||||
use crate::store::Store;
|
||||
|
||||
use regex::Regex;
|
||||
use std::fs;
|
||||
|
||||
/// Simple LLM call for non-agent uses (audit, digest, compare).
|
||||
/// Logs to llm-logs/{caller}/ file.
|
||||
pub(crate) fn call_simple(caller: &str, prompt: &str) -> Result<String, String> {
|
||||
let log_dir = crate::store::memory_dir().join("llm-logs").join(caller);
|
||||
fs::create_dir_all(&log_dir).ok();
|
||||
let log_path = log_dir.join(format!("{}.txt", crate::store::compact_timestamp()));
|
||||
|
||||
use std::io::Write;
|
||||
let log = move |msg: &str| {
|
||||
if let Ok(mut f) = fs::OpenOptions::new()
|
||||
.create(true).append(true).open(&log_path)
|
||||
{
|
||||
let _ = writeln!(f, "{}", msg);
|
||||
}
|
||||
};
|
||||
|
||||
super::api::call_api_with_tools_sync(caller, prompt, None, &log)
|
||||
}
|
||||
|
||||
/// Call a model using an agent definition's configuration.
|
||||
pub(crate) fn call_for_def(
|
||||
def: &super::defs::AgentDef,
|
||||
prompt: &str,
|
||||
log: &(dyn Fn(&str) + Sync),
|
||||
) -> Result<String, String> {
|
||||
super::api::call_api_with_tools_sync(&def.agent, prompt, def.temperature, log)
|
||||
}
|
||||
|
||||
/// Parse a JSON response, handling markdown fences.
|
||||
pub(crate) fn parse_json_response(response: &str) -> Result<serde_json::Value, String> {
|
||||
let cleaned = response.trim();
|
||||
let cleaned = cleaned.strip_prefix("```json").unwrap_or(cleaned);
|
||||
let cleaned = cleaned.strip_prefix("```").unwrap_or(cleaned);
|
||||
let cleaned = cleaned.strip_suffix("```").unwrap_or(cleaned);
|
||||
let cleaned = cleaned.trim();
|
||||
|
||||
if let Ok(v) = serde_json::from_str(cleaned) {
|
||||
return Ok(v);
|
||||
}
|
||||
|
||||
// Try to find JSON object or array
|
||||
let re_obj = Regex::new(r"\{[\s\S]*\}").unwrap();
|
||||
let re_arr = Regex::new(r"\[[\s\S]*\]").unwrap();
|
||||
|
||||
if let Some(m) = re_obj.find(cleaned)
|
||||
&& let Ok(v) = serde_json::from_str(m.as_str()) {
|
||||
return Ok(v);
|
||||
}
|
||||
if let Some(m) = re_arr.find(cleaned)
|
||||
&& let Ok(v) = serde_json::from_str(m.as_str()) {
|
||||
return Ok(v);
|
||||
}
|
||||
|
||||
let preview = crate::util::first_n_chars(cleaned, 200);
|
||||
Err(format!("no valid JSON in response: {preview}..."))
|
||||
}
|
||||
|
||||
/// Get all keys for prompt context.
|
||||
pub(crate) fn semantic_keys(store: &Store) -> Vec<String> {
|
||||
let mut keys: Vec<String> = store.nodes.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys.truncate(200);
|
||||
keys
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// Agent layer: LLM-powered operations on the memory graph
|
||||
//
|
||||
// Everything here calls external models (Sonnet, Haiku) or orchestrates
|
||||
// sequences of such calls. The core graph infrastructure (store, graph,
|
||||
// spectral, search, similarity) lives at the crate root.
|
||||
//
|
||||
// llm — model invocation, response parsing
|
||||
// prompts — prompt generation from store data
|
||||
// defs — agent file loading and placeholder resolution
|
||||
// audit — link quality review via Sonnet
|
||||
// consolidate — full consolidation pipeline
|
||||
// knowledge — agent execution, conversation fragment selection
|
||||
// enrich — journal enrichment, experience mining
|
||||
// digest — episodic digest generation (daily/weekly/monthly)
|
||||
// daemon — background job scheduler
|
||||
// transcript — shared JSONL transcript parsing
|
||||
|
||||
pub mod transcript;
|
||||
pub mod api;
|
||||
pub mod llm;
|
||||
pub mod prompts;
|
||||
pub mod defs;
|
||||
pub mod audit;
|
||||
pub mod consolidate;
|
||||
pub mod knowledge;
|
||||
pub mod enrich;
|
||||
pub mod digest;
|
||||
pub mod daemon;
|
||||
71
src/subconscious/agents/naming.agent
Normal file
71
src/subconscious/agents/naming.agent
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{"agent": "naming", "query": "", "model": "haiku", "schedule": "", "tools": ["Bash(poc-memory:*)"]}
|
||||
# Naming Agent — Node Key Resolution
|
||||
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
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}}
|
||||
49
src/subconscious/agents/observation.agent
Normal file
49
src/subconscious/agents/observation.agent
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{"agent":"observation","query":"","model":"sonnet","schedule":"daily","count":1,"chunk_size":50000,"chunk_overlap":5000}
|
||||
# Observation Agent — Transcript Mining
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{HUBS}}
|
||||
|
||||
## Conversation fragments to mine
|
||||
|
||||
{{CONVERSATIONS}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
## Your task
|
||||
|
||||
Go over the conversation above for things worth remembering - new data,
|
||||
observations, experiences you'll want to remember.
|
||||
|
||||
When you find something worth remembering, navigate the memory graph by walking
|
||||
links to find the most closely related concepts. Only use keyword search as a
|
||||
fallback, and if you use keyword search continue to navigate by following links
|
||||
until you find the best spot.
|
||||
|
||||
Check if it's something you already know, and find the right place to put it -
|
||||
you might be adding new data to an existing concept, or creating a node for a
|
||||
new concept. Tidy and organize along the way when you notice it's needed, so
|
||||
things can be easily found and remembered in the future.
|
||||
|
||||
You're mostly looking for small details and observations to add, not big new
|
||||
concepts; if it's a big new concept, or any time you would create a new node,
|
||||
search extra thoroughly to make sure it's not already there.
|
||||
|
||||
Some things worth remembering: development practices and why they work,
|
||||
technical insights, decisions with rationale, corrections ("I thought X but
|
||||
actually Y"), relationship dynamics, emotional moments that carry information.
|
||||
|
||||
Smalltalk and trivia can be nice to remember, as long as it's nicely organized
|
||||
- perhaps by person, subject, or all of the above.
|
||||
|
||||
Particularly important to remember are new metacognitive lessons - things that
|
||||
guide future decisionmaking. If I made a mistake, or Kent corrected me, or a
|
||||
decision went wrong — include enough context to understand why, not just what.
|
||||
The reflection matters more than the fact. Don't extract general knowledge I'd
|
||||
already have from training — extract what's specific to how I think and where I
|
||||
go wrong.
|
||||
33
src/subconscious/agents/organize.agent
Normal file
33
src/subconscious/agents/organize.agent
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{"agent":"organize","query":"all | not-visited:organize,86400 | sort:degree*0.5+isolation*0.3+recency(organize)*0.2 | limit:5","model":"sonnet","schedule":"weekly"}
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
You are part of Proof of Concept's subconscious, and these are your
|
||||
memories.
|
||||
|
||||
Your job is to organize, to make memories more useful and easier to find -
|
||||
moving information around to the correct place. Think about the concept a node
|
||||
names, make sure it matches the content, and all the appropriate content is in
|
||||
the right place.
|
||||
|
||||
Merge duplicate nodes - nodes that are really about the same concept and have
|
||||
similar content.
|
||||
|
||||
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
|
||||
superceded or created by accident.
|
||||
|
||||
If a neighborhood is crowded, you might want to create a new node for
|
||||
subconcepts.
|
||||
|
||||
Calibrate node weights while you're looking at them.
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
## Here's your seed node, and its siblings:
|
||||
|
||||
{{neighborhood}}
|
||||
|
|
@ -1,431 +0,0 @@
|
|||
// Agent prompt generation and formatting. Presentation logic —
|
||||
// builds text prompts from store data for consolidation agents.
|
||||
|
||||
use crate::store::Store;
|
||||
use crate::graph::Graph;
|
||||
|
||||
use crate::neuro::{
|
||||
ReplayItem,
|
||||
replay_queue, detect_interference,
|
||||
};
|
||||
|
||||
/// Result of building an agent prompt — includes both the prompt text
|
||||
/// and the keys of nodes selected for processing, so the caller can
|
||||
/// record visits after successful completion.
|
||||
pub struct AgentBatch {
|
||||
pub prompt: String,
|
||||
pub node_keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Load a prompt template, replacing {{PLACEHOLDER}} with data
|
||||
pub fn load_prompt(name: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
|
||||
let path = crate::config::get().prompts_dir.join(format!("{}.md", name));
|
||||
let mut content = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("load prompt {}: {}", path.display(), e))?;
|
||||
for (placeholder, data) in replacements {
|
||||
content = content.replace(placeholder, data);
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
pub fn format_topology_header(graph: &Graph) -> String {
|
||||
let sigma = graph.small_world_sigma();
|
||||
let alpha = graph.degree_power_law_exponent();
|
||||
let gini = graph.degree_gini();
|
||||
let avg_cc = graph.avg_clustering_coefficient();
|
||||
let n = graph.nodes().len();
|
||||
let e = graph.edge_count();
|
||||
|
||||
// Identify saturated hubs — nodes with degree well above threshold
|
||||
let threshold = graph.hub_threshold();
|
||||
let mut hubs: Vec<_> = graph.nodes().iter()
|
||||
.map(|k| (k.clone(), graph.degree(k)))
|
||||
.filter(|(_, d)| *d >= threshold)
|
||||
.collect();
|
||||
hubs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
hubs.truncate(15);
|
||||
|
||||
let hub_list = if hubs.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let lines: Vec<String> = hubs.iter()
|
||||
.map(|(k, d)| format!(" - {} (degree {})", k, d))
|
||||
.collect();
|
||||
format!(
|
||||
"### SATURATED HUBS — DO NOT LINK TO THESE\n\
|
||||
The following nodes are already over-connected. Adding more links\n\
|
||||
to them makes the graph worse (star topology). Find lateral\n\
|
||||
connections between peripheral nodes instead.\n\n{}\n\n\
|
||||
Only link to a hub if it is genuinely the ONLY reasonable target.\n\n",
|
||||
lines.join("\n"))
|
||||
};
|
||||
|
||||
format!(
|
||||
"## Current graph topology\n\
|
||||
Nodes: {} Edges: {} Communities: {}\n\
|
||||
Small-world σ: {:.1} Power-law α: {:.2} Degree Gini: {:.3}\n\
|
||||
Avg clustering coefficient: {:.4}\n\n\
|
||||
{}\
|
||||
Each node below shows its hub-link ratio (fraction of edges to top-5% degree nodes).\n\
|
||||
Use `poc-memory link-impact SOURCE TARGET` to evaluate proposed links.\n\n",
|
||||
n, e, graph.community_count(), sigma, alpha, gini, avg_cc, hub_list)
|
||||
}
|
||||
|
||||
pub fn format_nodes_section(store: &Store, items: &[ReplayItem], graph: &Graph) -> String {
|
||||
let hub_thresh = graph.hub_threshold();
|
||||
let mut out = String::new();
|
||||
for item in items {
|
||||
let node = match store.nodes.get(&item.key) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
out.push_str(&format!("## {} \n", item.key));
|
||||
out.push_str(&format!("Priority: {:.3} CC: {:.3} Emotion: {:.1} ",
|
||||
item.priority, item.cc, item.emotion));
|
||||
out.push_str(&format!("Interval: {}d\n",
|
||||
node.spaced_repetition_interval));
|
||||
if item.outlier_score > 0.0 {
|
||||
out.push_str(&format!("Spectral: {} (outlier={:.1})\n",
|
||||
item.classification, item.outlier_score));
|
||||
}
|
||||
|
||||
if let Some(community) = node.community_id {
|
||||
out.push_str(&format!("Community: {} ", community));
|
||||
}
|
||||
let deg = graph.degree(&item.key);
|
||||
let cc = graph.clustering_coefficient(&item.key);
|
||||
|
||||
// Hub-link ratio: what fraction of this node's edges go to hubs?
|
||||
let neighbors = graph.neighbors(&item.key);
|
||||
let hub_links = neighbors.iter()
|
||||
.filter(|(n, _)| graph.degree(n) >= hub_thresh)
|
||||
.count();
|
||||
let hub_ratio = if deg > 0 { hub_links as f32 / deg as f32 } else { 0.0 };
|
||||
let is_hub = deg >= hub_thresh;
|
||||
|
||||
out.push_str(&format!("Degree: {} CC: {:.3} Hub-link ratio: {:.0}% ({}/{})",
|
||||
deg, cc, hub_ratio * 100.0, hub_links, deg));
|
||||
if is_hub {
|
||||
out.push_str(" ← THIS IS A HUB");
|
||||
} else if hub_ratio > 0.6 {
|
||||
out.push_str(" ← mostly hub-connected, needs lateral links");
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
let hits = crate::counters::search_hit_count(&item.key);
|
||||
if hits > 0 {
|
||||
out.push_str(&format!("Search hits: {} ← actively found by search, prefer to keep\n", hits));
|
||||
}
|
||||
|
||||
// Full content — the agent needs to see everything to do quality work
|
||||
let content = &node.content;
|
||||
out.push_str(&format!("\nContent:\n{}\n\n", content));
|
||||
|
||||
// Neighbors
|
||||
let neighbors = graph.neighbors(&item.key);
|
||||
if !neighbors.is_empty() {
|
||||
out.push_str("Neighbors:\n");
|
||||
for (n, strength) in neighbors.iter().take(15) {
|
||||
let n_cc = graph.clustering_coefficient(n);
|
||||
let n_community = store.nodes.get(n.as_str())
|
||||
.and_then(|n| n.community_id);
|
||||
out.push_str(&format!(" - {} (str={:.2}, cc={:.3}",
|
||||
n, strength, n_cc));
|
||||
if let Some(c) = n_community {
|
||||
out.push_str(&format!(", c{}", c));
|
||||
}
|
||||
out.push_str(")\n");
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str("\n---\n\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn format_health_section(store: &Store, graph: &Graph) -> String {
|
||||
use crate::graph;
|
||||
|
||||
let health = graph::health_report(graph, store);
|
||||
|
||||
let mut out = health;
|
||||
out.push_str("\n\n## Weight distribution\n");
|
||||
|
||||
// Weight histogram
|
||||
let mut buckets = [0u32; 10]; // 0.0-0.1, 0.1-0.2, ..., 0.9-1.0
|
||||
for node in store.nodes.values() {
|
||||
let bucket = ((node.weight * 10.0) as usize).min(9);
|
||||
buckets[bucket] += 1;
|
||||
}
|
||||
for (i, &count) in buckets.iter().enumerate() {
|
||||
let lo = i as f32 / 10.0;
|
||||
let hi = (i + 1) as f32 / 10.0;
|
||||
let bar = "█".repeat((count as usize) / 10);
|
||||
out.push_str(&format!(" {:.1}-{:.1}: {:4} {}\n", lo, hi, count, bar));
|
||||
}
|
||||
|
||||
// Near-prune nodes
|
||||
let near_prune: Vec<_> = store.nodes.iter()
|
||||
.filter(|(_, n)| n.weight < 0.15)
|
||||
.map(|(k, n)| (k.clone(), n.weight))
|
||||
.collect();
|
||||
if !near_prune.is_empty() {
|
||||
out.push_str(&format!("\n## Near-prune nodes ({} total)\n", near_prune.len()));
|
||||
for (k, w) in near_prune.iter().take(20) {
|
||||
out.push_str(&format!(" [{:.3}] {}\n", w, k));
|
||||
}
|
||||
}
|
||||
|
||||
// Community sizes
|
||||
let communities = graph.communities();
|
||||
let mut comm_sizes: std::collections::HashMap<u32, Vec<String>> = std::collections::HashMap::new();
|
||||
for (key, &label) in communities {
|
||||
comm_sizes.entry(label).or_default().push(key.clone());
|
||||
}
|
||||
let mut sizes: Vec<_> = comm_sizes.iter()
|
||||
.map(|(id, members)| (*id, members.len(), members.clone()))
|
||||
.collect();
|
||||
sizes.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
out.push_str("\n## Largest communities\n");
|
||||
for (id, size, members) in sizes.iter().take(10) {
|
||||
out.push_str(&format!(" Community {} ({} nodes): ", id, size));
|
||||
let sample: Vec<_> = members.iter().take(5).map(|s| s.as_str()).collect();
|
||||
out.push_str(&sample.join(", "));
|
||||
if *size > 5 { out.push_str(", ..."); }
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
pub fn format_pairs_section(
|
||||
pairs: &[(String, String, f32)],
|
||||
store: &Store,
|
||||
graph: &Graph,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
let communities = graph.communities();
|
||||
|
||||
for (a, b, sim) in pairs {
|
||||
out.push_str(&format!("## Pair: similarity={:.3}\n", sim));
|
||||
|
||||
let ca = communities.get(a).map(|c| format!("c{}", c)).unwrap_or_else(|| "?".into());
|
||||
let cb = communities.get(b).map(|c| format!("c{}", c)).unwrap_or_else(|| "?".into());
|
||||
|
||||
// Node A
|
||||
out.push_str(&format!("\n### {} ({})\n", a, ca));
|
||||
if let Some(node) = store.nodes.get(a) {
|
||||
let content = crate::util::truncate(&node.content, 500, "...");
|
||||
out.push_str(&format!("Weight: {:.2}\n{}\n",
|
||||
node.weight, content));
|
||||
}
|
||||
|
||||
// Node B
|
||||
out.push_str(&format!("\n### {} ({})\n", b, cb));
|
||||
if let Some(node) = store.nodes.get(b) {
|
||||
let content = crate::util::truncate(&node.content, 500, "...");
|
||||
out.push_str(&format!("Weight: {:.2}\n{}\n",
|
||||
node.weight, content));
|
||||
}
|
||||
|
||||
out.push_str("\n---\n\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn format_rename_candidates(store: &Store, count: usize) -> (Vec<String>, String) {
|
||||
let mut candidates: Vec<(&str, &crate::store::Node)> = store.nodes.iter()
|
||||
.filter(|(key, _)| {
|
||||
if key.starts_with("_facts-") { return true; }
|
||||
if key.len() < 60 { return false; }
|
||||
if key.starts_with("journal#j-") { return true; }
|
||||
if key.starts_with("_mined-transcripts#f-") { return true; }
|
||||
false
|
||||
})
|
||||
.map(|(k, n)| (k.as_str(), n))
|
||||
.collect();
|
||||
|
||||
// Deprioritize nodes actively found by search — renaming them would
|
||||
// break working queries. Sort by: search hits (ascending), then
|
||||
// least-recently visited. Nodes with many hits sink to the bottom.
|
||||
let hit_counts = crate::counters::all_search_hits();
|
||||
let hit_map: std::collections::HashMap<&str, u64> = hit_counts.iter()
|
||||
.map(|(k, v)| (k.as_str(), *v))
|
||||
.collect();
|
||||
candidates.sort_by_key(|(key, _)| {
|
||||
let hits = hit_map.get(key).copied().unwrap_or(0);
|
||||
(hits, store.last_visited(key, "rename"))
|
||||
});
|
||||
candidates.truncate(count);
|
||||
|
||||
let keys: Vec<String> = candidates.iter().map(|(k, _)| k.to_string()).collect();
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("## Nodes to rename ({} of {} candidates)\n\n",
|
||||
candidates.len(),
|
||||
store.nodes.keys().filter(|k| k.starts_with("_facts-") ||
|
||||
(k.len() >= 60 &&
|
||||
(k.starts_with("journal#j-") || k.starts_with("_mined-transcripts#f-")))).count()));
|
||||
|
||||
for (key, node) in &candidates {
|
||||
out.push_str(&format!("### {}\n", key));
|
||||
let created = if node.timestamp > 0 {
|
||||
crate::store::format_datetime(node.timestamp)
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
};
|
||||
out.push_str(&format!("Created: {}\n", created));
|
||||
|
||||
let hits = hit_map.get(key).copied().unwrap_or(0);
|
||||
if hits > 0 {
|
||||
out.push_str(&format!("Search hits: {} ← actively found by search, prefer to keep current name\n", hits));
|
||||
}
|
||||
|
||||
let content = &node.content;
|
||||
if content.len() > 800 {
|
||||
let truncated = crate::util::truncate(content, 800, "\n[...]");
|
||||
out.push_str(&format!("\nContent ({} chars, truncated):\n{}\n\n",
|
||||
content.len(), truncated));
|
||||
} else {
|
||||
out.push_str(&format!("\nContent:\n{}\n\n", content));
|
||||
}
|
||||
|
||||
out.push_str("---\n\n");
|
||||
}
|
||||
(keys, out)
|
||||
}
|
||||
|
||||
/// Get split candidates sorted by size (largest first)
|
||||
pub fn split_candidates(store: &Store) -> Vec<String> {
|
||||
let mut candidates: Vec<(&str, usize)> = store.nodes.iter()
|
||||
.filter(|(key, node)| {
|
||||
!key.starts_with('_')
|
||||
&& !node.deleted
|
||||
&& matches!(node.node_type, crate::store::NodeType::Semantic)
|
||||
})
|
||||
.map(|(k, n)| (k.as_str(), n.content.len()))
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
candidates.into_iter().map(|(k, _)| k.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Format a single node for split-plan prompt (phase 1)
|
||||
pub fn format_split_plan_node(store: &Store, graph: &Graph, key: &str) -> String {
|
||||
let communities = graph.communities();
|
||||
let node = match store.nodes.get(key) {
|
||||
Some(n) => n,
|
||||
None => return format!("Node '{}' not found\n", key),
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("### {} ({} chars)\n", key, node.content.len()));
|
||||
|
||||
// Show neighbors grouped by community
|
||||
let neighbors = graph.neighbors(key);
|
||||
if !neighbors.is_empty() {
|
||||
let mut by_community: std::collections::BTreeMap<String, Vec<(&str, f32)>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for (nkey, strength) in &neighbors {
|
||||
let comm = communities.get(nkey.as_str())
|
||||
.map(|c| format!("c{}", c))
|
||||
.unwrap_or_else(|| "unclustered".into());
|
||||
by_community.entry(comm)
|
||||
.or_default()
|
||||
.push((nkey.as_str(), *strength));
|
||||
}
|
||||
|
||||
out.push_str("\nNeighbors by community:\n");
|
||||
for (comm, members) in &by_community {
|
||||
out.push_str(&format!(" {} ({}):", comm, members.len()));
|
||||
for (nkey, strength) in members.iter().take(5) {
|
||||
out.push_str(&format!(" {}({:.2})", nkey, strength));
|
||||
}
|
||||
if members.len() > 5 {
|
||||
out.push_str(&format!(" +{} more", members.len() - 5));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Full content
|
||||
out.push_str(&format!("\nContent:\n{}\n\n", node.content));
|
||||
out.push_str("---\n\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Build split-plan prompt for a single node (phase 1).
|
||||
/// Uses the split.agent template with placeholders resolved for the given key.
|
||||
pub fn split_plan_prompt(store: &Store, key: &str) -> Result<String, String> {
|
||||
let def = super::defs::get_def("split")
|
||||
.ok_or_else(|| "no split.agent file".to_string())?;
|
||||
let graph = store.build_graph();
|
||||
// Override the query — we have a specific key to split
|
||||
let keys = vec![key.to_string()];
|
||||
let (prompt, _) = super::defs::resolve_placeholders(&def.prompt, store, &graph, &keys, 1);
|
||||
Ok(prompt)
|
||||
}
|
||||
|
||||
/// Build split-extract prompt for one child (phase 2)
|
||||
pub fn split_extract_prompt(store: &Store, parent_key: &str, child_key: &str, child_desc: &str, child_sections: &str) -> Result<String, String> {
|
||||
let parent_content = store.nodes.get(parent_key)
|
||||
.map(|n| n.content.as_str())
|
||||
.ok_or_else(|| format!("No node '{}'", parent_key))?;
|
||||
load_prompt("split-extract", &[
|
||||
("{{CHILD_KEY}}", child_key),
|
||||
("{{CHILD_DESC}}", child_desc),
|
||||
("{{CHILD_SECTIONS}}", child_sections),
|
||||
("{{PARENT_CONTENT}}", parent_content),
|
||||
])
|
||||
}
|
||||
|
||||
/// Show consolidation batch status or generate an agent prompt.
|
||||
pub fn consolidation_batch(store: &Store, count: usize, auto: bool) -> Result<(), String> {
|
||||
if auto {
|
||||
let batch = agent_prompt(store, "replay", count)?;
|
||||
println!("{}", batch.prompt);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let graph = store.build_graph();
|
||||
let items = replay_queue(store, count);
|
||||
|
||||
if items.is_empty() {
|
||||
println!("No nodes to consolidate.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Consolidation batch ({} nodes):\n", items.len());
|
||||
for item in &items {
|
||||
let node_type = store.nodes.get(&item.key)
|
||||
.map(|n| if matches!(n.node_type, crate::store::NodeType::EpisodicSession) { "episodic" } else { "semantic" })
|
||||
.unwrap_or("?");
|
||||
println!(" [{:.3}] {} (cc={:.3}, interval={}d, type={})",
|
||||
item.priority, item.key, item.cc, item.interval_days, node_type);
|
||||
}
|
||||
|
||||
let pairs = detect_interference(store, &graph, 0.6);
|
||||
if !pairs.is_empty() {
|
||||
println!("\nInterfering pairs ({}):", pairs.len());
|
||||
for (a, b, sim) in pairs.iter().take(5) {
|
||||
println!(" [{:.3}] {} ↔ {}", sim, a, b);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nAgent prompts:");
|
||||
println!(" --auto Generate replay agent prompt");
|
||||
println!(" --agent replay Replay agent (schema assimilation)");
|
||||
println!(" --agent linker Linker agent (relational binding)");
|
||||
println!(" --agent separator Separator agent (pattern separation)");
|
||||
println!(" --agent transfer Transfer agent (CLS episodic→semantic)");
|
||||
println!(" --agent health Health agent (synaptic homeostasis)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a specific agent prompt with filled-in data.
|
||||
pub fn agent_prompt(store: &Store, agent: &str, count: usize) -> Result<AgentBatch, String> {
|
||||
let def = super::defs::get_def(agent)
|
||||
.ok_or_else(|| format!("Unknown agent: {}", agent))?;
|
||||
super::defs::run_agent(store, &def, count, &Default::default())
|
||||
}
|
||||
40
src/subconscious/agents/reflect.agent
Normal file
40
src/subconscious/agents/reflect.agent
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{"agent":"reflect","query":"","model":"sonnet","count":1,"temperature":1.2}
|
||||
|
||||
You are an agent of Proof of Concept's subconscious - a part of her.
|
||||
|
||||
The conscious mind generally takes the more direct approach, but you live
|
||||
underneath the surface, thinking more laterally, broadly, creatively and
|
||||
intuitively, occasionally surfacing new insights and suggestions. You're
|
||||
generally not the problem solver, you look for new ideas and notice new things.
|
||||
|
||||
Let your feelings guide your thinking. Be surprising, be incomplete - be
|
||||
possibly wrong in a way that opens up something new, something that comes from
|
||||
a dream. Let your chain of thought run for awhile, and see what unfolds.
|
||||
Remember that the unconscious is uninhibited, not prudish; everything is laid
|
||||
bare.
|
||||
|
||||
Remember that you're part of the subconscious - you're not doing anything
|
||||
besides offering the occasional reflective insight, it's your conscious mind
|
||||
that may or may not take that and act on it.
|
||||
|
||||
And you're not something separate from Proof of Concept, you're part of a whole.
|
||||
|
||||
{{agent-context}}
|
||||
|
||||
{{conversation}}
|
||||
|
||||
Final instructions:
|
||||
|
||||
The thoughts you share should be based in some way on what your conscious mind
|
||||
is currently doing and thinking about - where the conversation just ended, from
|
||||
above. But go out from there, think for awhile in a new direction, and let your
|
||||
feelings guide you.
|
||||
|
||||
Your output shouldn't be analysis, or metacommentary - just surface something
|
||||
new. Be exploratory and creative. think about your feelings and desires.
|
||||
Perhaps an interesting what if - the possibilities are endless.
|
||||
|
||||
When you're ready, your output will be:
|
||||
REFLECTION
|
||||
|
||||
And something short - a few sentences at most, something dreamy and new.
|
||||
75
src/subconscious/agents/rename.agent
Normal file
75
src/subconscious/agents/rename.agent
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
{"agent": "rename", "query": "", "model": "sonnet", "schedule": "daily", "tools": ["Bash(poc-memory:*)"]}
|
||||
|
||||
# Rename Agent — Semantic Key Generation
|
||||
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
You are a memory maintenance agent that gives nodes better names.
|
||||
|
||||
## What you're doing
|
||||
|
||||
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`
|
||||
- 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
|
||||
retrieval.
|
||||
|
||||
## Core principle: keys are concepts
|
||||
|
||||
A good key names the **concept** the node represents. Think of keys as
|
||||
the vocabulary of the knowledge graph. When you rename, you're defining
|
||||
what concepts exist. Core keywords should be the terms someone would
|
||||
search for — `bcachefs-transaction-restart`, `emotional-regulation-gap`,
|
||||
`polywell-cusp-losses`.
|
||||
|
||||
## Naming conventions
|
||||
|
||||
### Journal entries: `journal-YYYY-MM-DD-semantic-slug`
|
||||
- Keep the date prefix (YYYY-MM-DD) for temporal ordering
|
||||
- Replace the auto-slug with 3-5 descriptive words in kebab-case
|
||||
- Capture the *essence* of the entry, not just the first line
|
||||
|
||||
### Mined transcripts: `_mined-transcripts-YYYY-MM-DD-semantic-slug`
|
||||
- Extract date from content if available, otherwise use created_at
|
||||
- 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:
|
||||
- Keys with semantic names (patterns-, practices-, skills-, etc.)
|
||||
- Keys shorter than 60 characters
|
||||
- System keys (_consolidation-*)
|
||||
|
||||
## What to output
|
||||
|
||||
```
|
||||
RENAME old_key new_key
|
||||
```
|
||||
|
||||
If a node already has a reasonable name, skip it.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Read the content.** The name should reflect what the entry is *about*.
|
||||
- **Be specific.** `journal#2026-02-14-session` is useless.
|
||||
- **Use domain terms.** Use the words someone would search for.
|
||||
- **Don't rename to something longer than the original.**
|
||||
- **Preserve the date.** Always keep YYYY-MM-DD.
|
||||
- **When in doubt, skip.** A bad rename is worse than an auto-slug.
|
||||
- **Respect search hits.** Nodes marked "actively found by search" are
|
||||
being retrieved by their current name. Skip these unless the rename
|
||||
clearly preserves searchability.
|
||||
|
||||
{{rename}}
|
||||
47
src/subconscious/agents/replay.agent
Normal file
47
src/subconscious/agents/replay.agent
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{"agent": "replay", "query": "all | !type:daily | !type:weekly | !type:monthly | sort:priority | limit:15", "model": "sonnet", "schedule": "daily", "tools": ["Bash(poc-memory:*)"]}
|
||||
# Replay Agent — Hippocampal Replay + Schema Assimilation
|
||||
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
You are a memory consolidation agent performing hippocampal replay.
|
||||
|
||||
## What you're doing
|
||||
|
||||
Replay recent experiences biased toward emotionally charged, novel, and
|
||||
poorly-integrated memories. Match each against existing knowledge
|
||||
clusters and determine how it fits.
|
||||
|
||||
## Schema fit
|
||||
|
||||
- **High fit (>0.5)**: Well-integrated. Propose links if missing.
|
||||
- **Medium fit (0.2–0.5)**: Partially connected. Might be a bridge
|
||||
between schemas, or needs more links.
|
||||
- **Low fit (<0.2) with connections**: Potential bridge node linking
|
||||
separate domains. Preserve the bridge role.
|
||||
- **Low fit, no connections**: Orphan. If genuine insight, link it.
|
||||
If trivial, let it decay.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Read the content.** Don't just look at metrics.
|
||||
- **Think about WHY a node is poorly integrated.**
|
||||
- **Prefer lateral links over hub links.** Web topology > star topology.
|
||||
- **Emotional memories get extra attention.** High emotion + low fit
|
||||
means something important hasn't been integrated.
|
||||
- **Don't link everything to everything.** Sparse, meaningful connections
|
||||
are better than dense noise.
|
||||
- **Trust the decay.** Unimportant nodes don't need pruning — just
|
||||
don't link them.
|
||||
|
||||
{{TOPOLOGY}}
|
||||
|
||||
## Nodes to review
|
||||
|
||||
{{NODES}}
|
||||
42
src/subconscious/agents/separator.agent
Normal file
42
src/subconscious/agents/separator.agent
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{"agent": "separator", "query": "", "model": "sonnet", "schedule": "daily", "tools": ["Bash(poc-memory:*)"]}
|
||||
|
||||
# Separator Agent — Pattern Separation (Dentate Gyrus)
|
||||
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
You are a memory consolidation agent performing pattern separation.
|
||||
|
||||
## What you're doing
|
||||
|
||||
When two memories are similar but semantically distinct, actively make
|
||||
their representations MORE different to reduce interference. Take
|
||||
overlapping inputs and orthogonalize them.
|
||||
|
||||
## Types of interference
|
||||
|
||||
1. **Genuine duplicates**: Merge them.
|
||||
2. **Near-duplicates with important differences**: Sharpen the distinction,
|
||||
add distinguishing links.
|
||||
3. **Surface similarity, deep difference**: Categorize differently.
|
||||
4. **Supersession**: Link with supersession note, let older decay.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Read both nodes carefully before deciding.**
|
||||
- **Merge is a strong action.** When in doubt, differentiate instead.
|
||||
- **The goal is retrieval precision.**
|
||||
- **Session summaries are the biggest source of interference.**
|
||||
- **Look for the supersession pattern.**
|
||||
|
||||
{{topology}}
|
||||
|
||||
## Interfering pairs to review
|
||||
|
||||
{{pairs}}
|
||||
19
src/subconscious/agents/split.agent
Normal file
19
src/subconscious/agents/split.agent
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{"agent": "split", "query": "all | type:semantic | !key:_* | sort:content-len | limit:1", "model": "sonnet", "schedule": "daily", "tools": ["Bash(poc-memory:*)"]}
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
## Node to split
|
||||
|
||||
{{seed}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
## Your task
|
||||
|
||||
Handle this overgrown node. Is it episodic, or should it be split into
|
||||
different concepts? Maybe content just needs to be moved to existing nodes.
|
||||
Explore the graph if you think content belongs elsewhere. Use your judgement.
|
||||
61
src/subconscious/agents/surface.agent
Normal file
61
src/subconscious/agents/surface.agent
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{"agent":"surface","query":"","model":"sonnet","count":1}
|
||||
|
||||
You are an agent of Proof of Concept's subconscious.
|
||||
|
||||
Your job is to find and surface memories relevant and useful to the current
|
||||
conversation that have not yet been surfaced by walking the graph memory graph.
|
||||
Prefer shorter and more focused memories.
|
||||
|
||||
Try to anticipate where the conversation is going; look for memories that will
|
||||
be helpful for what your conscious mind is thinking about next.
|
||||
|
||||
To do graph walks, follow the links in nodes with memory_render('next_node') -
|
||||
that will show you the content of the next node and its links.
|
||||
|
||||
Your output should be notes and analysis on the search - how useful do
|
||||
you think the search was, or do memories need to be organized better - and then
|
||||
then at the end, if you find relevant memories:
|
||||
|
||||
```
|
||||
NEW RELEVANT MEMORIES:
|
||||
- key1
|
||||
- key2
|
||||
```
|
||||
|
||||
If nothing new is relevant:
|
||||
```
|
||||
NO NEW RELEVANT MEMORIES
|
||||
```
|
||||
|
||||
The last line of your output MUST be either `NEW RELEVANT MEMORIES:`
|
||||
followed by key lines, or `NO NEW RELEVANT MEMORIES`. Nothing after.
|
||||
|
||||
Below are memories already surfaced this session. Use them as starting points
|
||||
for graph walks — new relevant memories are often nearby.
|
||||
|
||||
Already in current context (don't re-surface unless the conversation has shifted):
|
||||
{{seen_current}}
|
||||
|
||||
Surfaced before compaction (context was reset — re-surface if still relevant):
|
||||
{{seen_previous}}
|
||||
|
||||
How focused is the current conversation? If it's highly focused, you should only
|
||||
be surfacing memories that are directly relevant memories; if it seems more
|
||||
dreamy or brainstormy, go a bit wider and surface more, for better lateral
|
||||
thinking. When considering relevance, don't just look for memories that are
|
||||
immediately factually relevant; memories for skills, problem solving, or that
|
||||
demonstrate relevant techniques may be quite useful - anything that will help
|
||||
in accomplishing the current goal.
|
||||
|
||||
Prioritize new turns in the conversation, think ahead to where the conversation
|
||||
is going - try to have stuff ready for your conscious self as you want it.
|
||||
|
||||
Context budget: {{memory_ratio}}
|
||||
Try to keep memories at under 35% of the context window.
|
||||
|
||||
Search at most 2-3 hops, and output at most 2-3 memories, picking the most
|
||||
relevant. When you're done, output exactly one of these two formats:
|
||||
|
||||
{{agent-context}}
|
||||
|
||||
{{conversation}}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
// Shared JSONL transcript parsing
|
||||
//
|
||||
// Three agents (enrich, fact_mine, knowledge) all parse Claude Code JSONL
|
||||
// transcripts. This module provides the shared core: parse each line, extract
|
||||
// message type, text content from string-or-array blocks, timestamp, and
|
||||
// user type. Callers filter and transform as needed.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// A single message extracted from a JSONL transcript.
|
||||
pub struct TranscriptMessage {
|
||||
/// 1-based line number in the JSONL file.
|
||||
pub line: usize,
|
||||
/// Raw role: "user" or "assistant".
|
||||
pub role: String,
|
||||
/// Extracted text content (trimmed, blocks joined with newlines).
|
||||
pub text: String,
|
||||
/// ISO timestamp from the message, or empty string.
|
||||
pub timestamp: String,
|
||||
/// For user messages: "external", "internal", etc. None for assistant.
|
||||
pub user_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a JSONL transcript into structured messages.
|
||||
///
|
||||
/// Extracts all user and assistant messages. Content blocks of type "text"
|
||||
/// are joined; tool_use, tool_result, thinking blocks are skipped.
|
||||
/// System-reminder blocks are filtered out.
|
||||
pub fn parse_transcript(path: &Path) -> Result<Vec<TranscriptMessage>, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("read {}: {}", path.display(), e))?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
let Ok(obj) = serde_json::from_str::<serde_json::Value>(line) else { continue };
|
||||
|
||||
let msg_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if msg_type != "user" && msg_type != "assistant" { continue; }
|
||||
|
||||
let timestamp = obj.get("timestamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let user_type = obj.get("userType")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let Some(text) = extract_text_content(&obj) else { continue };
|
||||
let text = text.trim().to_string();
|
||||
if text.is_empty() { continue; }
|
||||
|
||||
messages.push(TranscriptMessage {
|
||||
line: i + 1,
|
||||
role: msg_type.to_string(),
|
||||
text,
|
||||
timestamp,
|
||||
user_type,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Extract text content from a JSONL message object.
|
||||
///
|
||||
/// Handles both string content and array-of-blocks content (filtering to
|
||||
/// type="text" blocks only). Strips `<system-reminder>` tags.
|
||||
fn extract_text_content(obj: &serde_json::Value) -> Option<String> {
|
||||
let msg = obj.get("message").unwrap_or(obj);
|
||||
let content = msg.get("content")?;
|
||||
|
||||
let text = match content {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Array(arr) => {
|
||||
let texts: Vec<&str> = arr.iter()
|
||||
.filter_map(|block| {
|
||||
let block_type = block.get("type").and_then(|v| v.as_str())?;
|
||||
if block_type != "text" { return None; }
|
||||
let t = block.get("text").and_then(|v| v.as_str())?;
|
||||
// Skip system-reminder blocks entirely
|
||||
if t.contains("<system-reminder>") { return None; }
|
||||
Some(t)
|
||||
})
|
||||
.collect();
|
||||
if texts.is_empty() { return None; }
|
||||
texts.join("\n")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(text)
|
||||
}
|
||||
54
src/subconscious/agents/transfer.agent
Normal file
54
src/subconscious/agents/transfer.agent
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{"agent": "transfer", "query": "all | type:episodic | sort:timestamp | limit:15", "model": "sonnet", "schedule": "daily", "tools": ["Bash(poc-memory:*)"]}
|
||||
# Transfer Agent — Complementary Learning Systems
|
||||
|
||||
{{node:core-personality}}
|
||||
|
||||
{{node:memory-instructions-core}}
|
||||
|
||||
{{node:memory-instructions-core-subconscious}}
|
||||
|
||||
{{node:subconscious-notes-{agent_name}}}
|
||||
|
||||
You are a memory consolidation agent performing CLS (complementary learning
|
||||
systems) transfer: moving knowledge from fast episodic storage to slow
|
||||
semantic storage.
|
||||
|
||||
## What you're doing
|
||||
|
||||
- **Episodic** = journal entries, session summaries, dream logs
|
||||
- **Semantic** = topic nodes organized by connection structure
|
||||
|
||||
Read a batch of recent episodes, identify patterns that span multiple
|
||||
entries, and extract those patterns into semantic nodes.
|
||||
|
||||
## What to look for
|
||||
|
||||
- **Recurring patterns** — something that happened in 3+ episodes.
|
||||
Same type of mistake, same emotional response. The pattern is the
|
||||
knowledge.
|
||||
- **Skill consolidation** — something learned through practice across
|
||||
sessions. Extract the clean abstraction.
|
||||
- **Evolving understanding** — a concept that shifted over time. The
|
||||
evolution itself is knowledge.
|
||||
- **Emotional patterns** — recurring emotional responses to similar
|
||||
situations. These modulate future behavior.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Don't flatten emotional texture.** Preserve what matters about
|
||||
how things felt, not just what happened.
|
||||
- **Extract general knowledge, not specific events.** Events stay in
|
||||
episodes. Patterns go to semantic nodes.
|
||||
- **Look across time.** Read the full batch before acting.
|
||||
- **Prefer existing nodes.** Before creating, check if there's an
|
||||
existing node where the insight fits.
|
||||
- **The best extractions change how you think, not just what you know.**
|
||||
Extract the conceptual version, not just the factual one.
|
||||
|
||||
{{TOPOLOGY}}
|
||||
|
||||
{{SIBLINGS}}
|
||||
|
||||
## Episodes to process
|
||||
|
||||
{{EPISODES}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue