diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -83,3 +83,4 @@ {"id":"int-9f29f590","kind":"field_change","created_at":"2026-06-30T22:01:41.091400446Z","actor":"dawn","issue_id":"klbr-noi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Replaced the missing ./default.nix package with config.nci.outputs.\"klbr-daemon\".packages.release. Verified nix eval .#packages.x86_64-linux.default.name returns klbr-daemon and nix flake show succeeds without the default.nix error."}} {"id":"int-686e2908","kind":"field_change","created_at":"2026-06-30T22:03:16.088748369Z","actor":"dawn","issue_id":"klbr-zue","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Replaced shell stdout/stderr byte slicing with char-boundary truncation helper and added UTF-8 regression tests. Verified cargo test -p klbr-core shell_truncation, full cargo test -p klbr-core (168 passed, 1 ignored), cargo fmt --check, git diff --check, and rg confirms the unsafe stdout/stderr slices are gone."}} {"id":"int-eabe816d","kind":"field_change","created_at":"2026-06-30T22:04:39.33696926Z","actor":"dawn","issue_id":"klbr-41z","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Changed daemon default websocket bind from 0.0.0.0:8765 to 127.0.0.1:8765 and replaced DumpMemories std::fs::write with tokio::fs::write(...).await. Verified rg for old patterns, cargo fmt --check, and cargo test -p klbr-daemon."}} +{"id":"int-a150f81b","kind":"field_change","created_at":"2026-06-30T22:08:23.925241993Z","actor":"dawn","issue_id":"klbr-7e0","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Removed unregistered old memory tool modules and updated memory docs"}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -54,7 +54,7 @@ {"_type":"issue","id":"klbr-647","title":"core: migrate Message.role from String to Role enum","description":"Message.role is currently represented as a raw String, which leads to fragile string comparisons ('assistant', 'tool', etc.) all over the codebase. Migrate to a proper Role enum.","status":"open","priority":2,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-30T21:39:36Z","created_by":"dawn","updated_at":"2026-06-30T21:39:36Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"klbr-so2","title":"core: decompose agent.rs run_turn() god method","description":"agent.rs run_turn() is 500+ lines, managing execution, tool loops, preemption, and discord integration. Break down tool execution loops and phase handlers into separate functions.","status":"open","priority":2,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-30T21:39:29Z","created_by":"dawn","updated_at":"2026-06-30T21:39:29Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"klbr-ow7","title":"core: refactor agent.rs run() loop failure-handling duplication","description":"agent.rs run() loop copy-pastes the exact same ~30-line backoff/failure-handling parsing block 4 times across different event stream branches. Extract to a helper function.","status":"open","priority":2,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-30T21:39:23Z","created_by":"dawn","updated_at":"2026-06-30T21:39:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"klbr-7e0","title":"core: clean up dead tool files in src/tools/","description":"There are 12 unused tool files in klbr-core/src/tools/ (remember.rs, recall.rs, tag_memory.rs, etc.) that are not declared as submodules or registered. They should be deleted.","status":"open","priority":2,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-30T21:39:16Z","created_by":"dawn","updated_at":"2026-06-30T21:39:16Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"klbr-7e0","title":"core: clean up dead tool files in src/tools/","description":"There are 12 unused tool files in klbr-core/src/tools/ (remember.rs, recall.rs, tag_memory.rs, etc.) that are not declared as submodules or registered. They should be deleted.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-30T21:39:16Z","created_by":"dawn","updated_at":"2026-06-30T22:08:24Z","started_at":"2026-06-30T22:05:01Z","closed_at":"2026-06-30T22:08:24Z","close_reason":"Removed unregistered old memory tool modules and updated memory docs","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"klbr-q53","title":"workspace: consolidate dependency versions via workspace.dependencies","description":"Workspace is not utilizing cargo workspace dependencies, causing version drift between core, daemon, discord, and ipc. Consolidate them in the root Cargo.toml.","status":"open","priority":2,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-30T21:39:10Z","created_by":"dawn","updated_at":"2026-06-30T21:39:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"klbr-6m2","title":"web: refactor and split App.svelte monolith","description":"App.svelte is currently a 3000+ line file holding all UI, WS client, and styles. Needs to be split into modular components like Sidebar, ChatPane, Metrics, and XmlVisualizer.","status":"open","priority":2,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-30T21:39:03Z","created_by":"dawn","updated_at":"2026-06-30T21:39:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"klbr-ds9","title":"refactor and simplify core agent loop without losing behaviour","status":"open","priority":2,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-30T21:20:30Z","created_by":"dawn","updated_at":"2026-06-30T21:20:30Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/docs/long-term-memory-arch.md b/docs/long-term-memory-arch.md --- a/docs/long-term-memory-arch.md +++ b/docs/long-term-memory-arch.md @@ -204,7 +204,7 @@ 6. make the agent memory tool surface folgezettel-native. initial implementation landed as `mk_index`, `mk_search`, `mk_follow`, `mk_recall`, `mk_remember`, `mk_revise`, `mk_link`, and `mk_fleeting`; old exposed memory - tool names are intentionally removed from the registry. + tool modules are removed from the tree. retrieval/assembly work is tracked in beads as `klbr-u0q`. the folgezettel agent surface is tracked as `klbr-7yo`. diff --git a/docs/memory-implementation-status.md b/docs/memory-implementation-status.md --- a/docs/memory-implementation-status.md +++ b/docs/memory-implementation-status.md @@ -50,10 +50,11 @@ stated facts/decisions, attachment markers, and source refs. - reflection now exposes only the folgezettel memory tool surface: `mk_index`, `mk_search`, `mk_follow`, `mk_recall`, `mk_remember`, - `mk_revise`, `mk_link`, and `mk_fleeting`. old public memory tools + `mk_revise`, `mk_link`, and `mk_fleeting`. old public memory tool modules (`remember`, `recall`, `context_for`, `fetch_memories`, - `memory_provenance`, `write_memory_note`, `edit_memory`, `list_memories`) are - intentionally not registered. + `memory_provenance`, `write_memory_note`, `edit_memory`, `list_memories`, + `pin_memory`, `tag_memory`, and `unpin_memory`) have been removed from the + tool tree instead of being left as unregistered dead code. - `mk_remember` writes sourced `zettel_note` artifacts, requires `source_refs`, links child notes to parents with `continues`, and records title/body/source policy frontmatter. `mk_revise` creates a sourced diff --git a/klbr-core/src/tools/context_for.rs b/klbr-core/src/tools/context_for.rs deleted file mode 100644 --- a/klbr-core/src/tools/context_for.rs +++ /dev/null @@ -1,114 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use serde_json::json; - -use crate::{memory::MemoryStore, models::ToolDef}; - -use super::{Tool, ToolContext}; - -pub fn tool() -> Tool { - Tool::new(definition(), exec) -} - -fn definition() -> ToolDef { - ToolDef::function( - "context_for", - "fetch all memories associated with a tag — a person, project, topic, etc. \ - use this to load everything you know about someone or something before responding. \ - no semantic ranking; returns newest first.", - json!({ - "type": "object", - "properties": { - "tags": { - "type": "array", - "items": { "type": "string" }, - "description": "tags to fetch, e.g. [\"person:mayer\"] or [\"project:klbr\", \"preference\"]" - }, - "tag_mode": { - "type": "string", - "enum": ["and", "or"], - "description": "\"and\" = all tags must match, \"or\" = any tag matches (default: \"or\")" - }, - "limit": { - "type": "integer", - "description": "max results (default 20)" - } - }, - "required": ["tags"] - }), - ) -} - -fn exec(args: serde_json::Value, ctx: ToolContext) -> Pin + Send>> { - Box::pin(execute(args, ctx)) -} - -async fn execute(args: serde_json::Value, ctx: ToolContext) -> String { - let tags: Vec = match args["tags"].as_array() { - Some(arr) => arr - .iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect(), - None => return "error: missing required arg 'tags'".into(), - }; - let tag_and = args["tag_mode"].as_str() == Some("and"); - let limit = args["limit"].as_u64().unwrap_or(20) as usize; - - match ctx.memory.context_for(&tags, tag_and, limit) { - Ok(results) if results.is_empty() => { - format!("no memories found for tags: {}", tags.join(", ")) - } - Ok(results) => results - .into_iter() - .enumerate() - .map(|(idx, e)| { - let tag_str = if e.tags.is_empty() { - String::new() - } else { - format!(" [{}]", e.tags.join(", ")) - }; - let content = if idx >= ctx.verbatim_count { - if e.content.chars().count() <= 120 { - format!("[snippet] {}", e.content) - } else { - let truncated: String = e.content.chars().take(120).collect(); - format!("[snippet] {truncated}...") - } - } else { - e.content.clone() - }; - format!( - "[id:{}]{tag_str}{} {}", - e.id, - provenance_hint(&ctx.memory, e.id), - content - ) - }) - .collect::>() - .join("\n"), - Err(err) => format!("error: {err}"), - } -} - -fn provenance_hint(memory: &MemoryStore, memory_id: i64) -> String { - let hints = memory - .provenance_counts(memory_id) - .unwrap_or_default() - .into_iter() - .filter(|(_, count)| *count > 0) - .map(|(edge_type, count)| { - let name = match edge_type { - crate::mvp::MemoryEdgeType::DerivedFrom => "derived_from", - crate::mvp::MemoryEdgeType::Supersedes => "supersedes", - crate::mvp::MemoryEdgeType::Supports => "supports", - }; - format!("{name}:{count}") - }) - .collect::>(); - if hints.is_empty() { - String::new() - } else { - format!(" [{}]", hints.join(",")) - } -} diff --git a/klbr-core/src/tools/edit_memory.rs b/klbr-core/src/tools/edit_memory.rs deleted file mode 100644 --- a/klbr-core/src/tools/edit_memory.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use serde_json::json; - -use crate::models::ToolDef; - -use super::{Tool, ToolContext}; - -pub fn tool() -> Tool { - Tool::new(definition(), exec) -} - -fn definition() -> ToolDef { - ToolDef::function( - "edit_memory", - "update an existing memory. use this to edit content, retag, pin, unpin, or edit the special \ - soul memory. for normal memories, you can change content, tags, and pinned state. \ - you can also archive, suppress, tombstone, restore, or supersede a normal memory. \ - archived memories stop surfacing in recall but remain available through provenance; \ - tombstoned memories are redacted and cannot be restored. \ - for the special soul memory, set special=\"soul\" and provide content.", - json!({ - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "memory id for a normal memory" - }, - "special": { - "type": "string", - "enum": ["soul"], - "description": "special memory target. use \"soul\" to edit your soul" - }, - "content": { - "type": "string", - "description": "replacement content for a normal memory or special=\"soul\"" - }, - "pinned": { - "type": "boolean", - "description": "set pinned/unpinned state for a normal memory" - }, - "tags": { - "type": "array", - "items": { "type": "string" }, - "description": "replace the tags on a normal memory" - }, - "status": { - "type": "string", - "enum": ["active", "archived", "suppressed", "tombstoned"], - "description": "set lifecycle status for a normal memory" - }, - "reason": { - "type": "string", - "description": "optional reason when status=\"tombstoned\"" - }, - "superseded_by": { - "type": "integer", - "description": "newer memory id that replaces this memory. creates a supersedes provenance edge and archives this memory" - } - } - }), - ) -} - -fn exec(args: serde_json::Value, ctx: ToolContext) -> Pin + Send>> { - Box::pin(execute(args, ctx)) -} - -async fn execute(args: serde_json::Value, ctx: ToolContext) -> String { - let special = args["special"].as_str(); - if special == Some("soul") { - let Some(content) = args["content"].as_str() else { - return "error: special=\"soul\" requires 'content'".into(); - }; - return match ctx.memory.set_soul_text(content) { - Ok(_) => "updated soul memory".into(), - Err(err) => format!("error: {err}"), - }; - } - - let Some(id) = args["id"].as_i64() else { - return "error: provide either 'id' or special=\"soul\"".into(); - }; - - let mut changed = Vec::new(); - - if let Some(content) = args["content"].as_str() { - match ctx.llm.embed(content).await { - Ok(emb) => { - if let Err(err) = ctx.memory.set_content(id, content, &emb) { - return format!("error: {err}"); - } - } - Err(err) => return format!("error: content embedding failed: {err}"), - } - changed.push("content".to_string()); - } - - if let Some(tags) = args["tags"].as_array() { - let tags: Vec = tags - .iter() - .filter_map(|value| value.as_str().map(String::from)) - .collect(); - if let Err(err) = ctx.memory.set_tags(id, &tags) { - return format!("error: {err}"); - } - changed.push(format!("tags={}", tags.join(", "))); - } - - if let Some(pinned) = args["pinned"].as_bool() { - if let Err(err) = ctx.memory.set_pinned(id, pinned) { - return format!("error: {err}"); - } - changed.push(format!("pinned={pinned}")); - } - - if let Some(status) = args["status"].as_str() { - let result = match status { - "active" => ctx.memory.restore_memory(id), - "archived" => ctx.memory.archive_memory(id), - "suppressed" => ctx.memory.suppress_memory(id), - "tombstoned" => ctx.memory.tombstone_memory(id, args["reason"].as_str()), - _ => return "error: invalid status".into(), - }; - if let Err(err) = result { - return format!("error: {err}"); - } - changed.push(format!("status={status}")); - } - - if let Some(new_id) = args["superseded_by"].as_i64() { - if let Err(err) = ctx.memory.supersede_memory(id, new_id) { - return format!("error: {err}"); - } - changed.push(format!("superseded_by={new_id}")); - } - - if changed.is_empty() { - return "error: no changes requested".into(); - } - - format!("updated memory {id}: {}", changed.join("; ")) -} diff --git a/klbr-core/src/tools/fetch_memories.rs b/klbr-core/src/tools/fetch_memories.rs deleted file mode 100644 --- a/klbr-core/src/tools/fetch_memories.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use serde_json::json; - -use crate::{memory::MemoryStore, models::ToolDef, mvp::MemoryStatus}; - -use super::{Tool, ToolContext}; - -pub fn tool() -> Tool { - Tool::new(definition(), exec) -} - -fn definition() -> ToolDef { - ToolDef::function( - "fetch_memories", - "fetch the full verbatim text of specific memories by their IDs. \ - use this to read the complete details of memories that were returned as search snippets (marked with [snippet]).", - json!({ - "type": "object", - "properties": { - "ids": { - "type": "array", - "items": { "type": "integer" }, - "description": "the list of memory IDs to fetch" - } - }, - "required": ["ids"] - }), - ) -} - -fn exec(args: serde_json::Value, ctx: ToolContext) -> Pin + Send>> { - Box::pin(execute(args, ctx)) -} - -async fn execute(args: serde_json::Value, ctx: ToolContext) -> String { - let ids: Vec = match args["ids"].as_array() { - Some(arr) => arr.iter().filter_map(|v| v.as_i64()).collect(), - None => return "error: missing required arg 'ids'".into(), - }; - - if ids.is_empty() { - return "error: empty 'ids' array".into(); - } - - let mut out = Vec::new(); - for id in ids { - match ctx.memory.get_memory(id) { - Ok(Some(memory)) => { - let tag_str = if memory.tags.is_empty() { - String::new() - } else { - format!(" [{}]", memory.tags.join(", ")) - }; - let status_str = match memory.status { - MemoryStatus::Active => "active", - MemoryStatus::Archived => "archived", - MemoryStatus::Suppressed => "suppressed", - MemoryStatus::Tombstoned => "tombstoned", - }; - out.push(format!( - "[id:{}] [status:{}]{}{} {}", - memory.memory_id, - status_str, - tag_str, - provenance_hint(&ctx.memory, memory.memory_id), - memory.text - )); - } - Ok(None) => { - out.push(format!("error: memory {id} not found")); - } - Err(err) => { - out.push(format!("error fetching memory {id}: {err}")); - } - } - } - out.join("\n") -} - -fn provenance_hint(memory: &MemoryStore, memory_id: i64) -> String { - let hints = memory - .provenance_counts(memory_id) - .unwrap_or_default() - .into_iter() - .filter(|(_, count)| *count > 0) - .map(|(edge_type, count)| { - let name = match edge_type { - crate::mvp::MemoryEdgeType::DerivedFrom => "derived_from", - crate::mvp::MemoryEdgeType::Supersedes => "supersedes", - crate::mvp::MemoryEdgeType::Supports => "supports", - }; - format!("{name}:{count}") - }) - .collect::>(); - if hints.is_empty() { - String::new() - } else { - format!(" [{}]", hints.join(",")) - } -} diff --git a/klbr-core/src/tools/list_memories.rs b/klbr-core/src/tools/list_memories.rs deleted file mode 100644 --- a/klbr-core/src/tools/list_memories.rs +++ /dev/null @@ -1,188 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use serde_json::json; - -use crate::{memory::MemoryStore, models::ToolDef, mvp::MemoryStatus}; - -use super::{Tool, ToolContext}; - -pub fn tool() -> Tool { - Tool::new(definition(), exec) -} - -fn definition() -> ToolDef { - ToolDef::function( - "list_memories", - "list current pinned memories and recent unpinned memories with their ids. \ - useful before a reflection pass to see what's stored. set include_inactive=true \ - to inspect archived, suppressed, and tombstoned memories too. set include_soul=true \ - to include the special system soul memory.", - json!({ - "type": "object", - "properties": { - "include_soul": { - "type": "boolean", - "description": "include the special system soul memory (default false)" - }, - "include_inactive": { - "type": "boolean", - "description": "include archived, suppressed, and tombstoned records (default false)" - }, - "limit": { - "type": "integer", - "description": "number of recent memories to list when include_inactive=true (default 20)" - } - }, - "required": [] - }), - ) -} - -fn exec(args: serde_json::Value, ctx: ToolContext) -> Pin + Send>> { - Box::pin(execute(args, ctx)) -} - -async fn execute(args: serde_json::Value, ctx: ToolContext) -> String { - let include_soul = args["include_soul"].as_bool().unwrap_or(false); - let include_inactive = args["include_inactive"].as_bool().unwrap_or(false); - let limit = args["limit"].as_u64().unwrap_or(20).clamp(1, 100) as usize; - - let mut out = String::new(); - if include_soul { - let soul = ctx.memory.soul_text().unwrap_or_default(); - out.push_str("## soul\n"); - match soul { - Some(content) => out.push_str(&format!("[special:soul] {content}\n")), - None => out.push_str("(none)\n"), - } - out.push('\n'); - } - - if include_inactive { - let memories = ctx - .memory - .list_recent_memories(limit, true) - .unwrap_or_default(); - out.push_str("## recent memories\n"); - if memories.is_empty() { - out.push_str("(none)\n"); - } else { - for memory in memories { - out.push_str(&format!( - "{}\n", - format_memory_line( - &ctx.memory, - memory.id, - &memory.content, - &memory.tags, - &memory.status, - memory.pinned, - memory.source_ref.as_deref(), - ) - )); - } - } - return out; - } - - let pinned = ctx.memory.pinned_memory_entries().unwrap_or_default(); - let unpinned = ctx.memory.recent_unpinned(10).unwrap_or_default(); - - out.push_str("## pinned\n"); - if pinned.is_empty() { - out.push_str("(none)\n"); - } else { - for memory in &pinned { - out.push_str(&format!( - "{}\n", - format_memory_line( - &ctx.memory, - memory.id, - &memory.content, - &memory.tags, - &MemoryStatus::Active, - true, - None, - ) - )); - } - } - out.push_str("\n## recent unpinned\n"); - if unpinned.is_empty() { - out.push_str("(none)\n"); - } else { - for (id, content, tags) in &unpinned { - out.push_str(&format!( - "{}\n", - format_memory_line( - &ctx.memory, - *id, - content, - tags, - &MemoryStatus::Active, - false, - None, - ) - )); - } - } - out -} - -fn format_memory_line( - memory: &MemoryStore, - id: i64, - content: &str, - tags: &[String], - status: &MemoryStatus, - pinned: bool, - source_ref: Option<&str>, -) -> String { - let tag_str = if tags.is_empty() { - String::new() - } else { - format!(" [{}]", tags.join(", ")) - }; - let pinned_str = if pinned { " [pinned]" } else { "" }; - let source_str = source_ref - .filter(|value| !value.is_empty()) - .map(|value| format!(" [source:{value}]")) - .unwrap_or_default(); - format!( - "[id:{id}] [{}]{pinned_str}{}{tag_str}{source_str} {content}", - memory_status(status), - edge_hints(memory, id), - ) -} - -fn edge_hints(memory: &MemoryStore, id: i64) -> String { - let hints = memory - .provenance_counts(id) - .unwrap_or_default() - .into_iter() - .filter(|(_, count)| *count > 0) - .map(|(edge_type, count)| { - let name = match edge_type { - crate::mvp::MemoryEdgeType::DerivedFrom => "derived_from", - crate::mvp::MemoryEdgeType::Supersedes => "supersedes", - crate::mvp::MemoryEdgeType::Supports => "supports", - }; - format!("{name}:{count}") - }) - .collect::>(); - if hints.is_empty() { - String::new() - } else { - format!(" [{}]", hints.join(",")) - } -} - -fn memory_status(status: &MemoryStatus) -> &'static str { - match status { - MemoryStatus::Active => "active", - MemoryStatus::Archived => "archived", - MemoryStatus::Suppressed => "suppressed", - MemoryStatus::Tombstoned => "tombstoned", - } -} diff --git a/klbr-core/src/tools/memory_provenance.rs b/klbr-core/src/tools/memory_provenance.rs deleted file mode 100644 --- a/klbr-core/src/tools/memory_provenance.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use serde_json::json; - -use crate::models::ToolDef; - -use super::{Tool, ToolContext}; - -pub fn tool() -> Tool { - Tool::new(definition(), exec) -} - -fn definition() -> ToolDef { - ToolDef::function( - "memory_provenance", - "inspect provenance sources for a memory id. use this when a recalled memory is a \ - summary, derived fact, or superseding fact and you need to verify where it came from. \ - archived source memories can appear here even though normal recall hides them.", - json!({ - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "memory id to inspect" - }, - "depth": { - "type": "integer", - "description": "max provenance traversal depth (default 1, max 3)" - } - }, - "required": ["id"] - }), - ) -} - -fn exec(args: serde_json::Value, ctx: ToolContext) -> Pin + Send>> { - Box::pin(execute(args, ctx)) -} - -async fn execute(args: serde_json::Value, ctx: ToolContext) -> String { - let Some(id) = args["id"].as_i64() else { - return "error: missing required arg 'id'".into(); - }; - let depth = args["depth"].as_u64().unwrap_or(1).clamp(1, 3) as usize; - - let memory = match ctx.memory.get_memory(id) { - Ok(Some(memory)) => memory, - Ok(None) => return format!("memory {id} not found"), - Err(err) => return format!("error: {err}"), - }; - - let sources = match ctx.memory.provenance_sources(id, depth) { - Ok(sources) => sources, - Err(err) => return format!("error: {err}"), - }; - - let mut out = String::new(); - out.push_str("## memory\n"); - out.push_str(&format!( - "[id:{}] [status:{}]{} {}\n", - memory.memory_id, - memory_status(&memory.status), - format_tags(&memory.tags), - memory.text - )); - - out.push_str("\n## provenance sources\n"); - if sources.is_empty() { - out.push_str("(none)\n"); - } else { - for source in sources { - out.push_str(&format!( - "[id:{}] [status:{}]{} {}\n", - source.memory_id, - memory_status(&source.status), - format_tags(&source.tags), - source.text - )); - } - } - out -} - -fn format_tags(tags: &[String]) -> String { - if tags.is_empty() { - String::new() - } else { - format!(" [tags:{}]", tags.join(", ")) - } -} - -fn memory_status(status: &crate::mvp::MemoryStatus) -> &'static str { - match status { - crate::mvp::MemoryStatus::Active => "active", - crate::mvp::MemoryStatus::Archived => "archived", - crate::mvp::MemoryStatus::Suppressed => "suppressed", - crate::mvp::MemoryStatus::Tombstoned => "tombstoned", - } -} diff --git a/klbr-core/src/tools/pin_memory.rs b/klbr-core/src/tools/pin_memory.rs deleted file mode 100644 --- a/klbr-core/src/tools/pin_memory.rs +++ /dev/null @@ -1,45 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use serde_json::json; - -use crate::llm::ToolDef; - -use super::{Tool, ToolContext}; - -pub fn tool() -> Tool { - Tool::new(definition(), exec) -} - -fn definition() -> ToolDef { - ToolDef::function( - "pin_memory", - "pin an existing memory so it appears at every startup. use during reflection to \ - promote unpinned memories that turned out to be long-term important.", - json!({ - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "memory id (shown in list_memories or recall results)" - } - }, - "required": ["id"] - }), - ) -} - -fn exec(args: serde_json::Value, ctx: ToolContext) -> Pin + Send>> { - Box::pin(execute(args, ctx)) -} - -async fn execute(args: serde_json::Value, ctx: ToolContext) -> String { - let id = match args["id"].as_i64() { - Some(i) => i, - None => return "error: missing required arg 'id'".into(), - }; - match ctx.memory.set_pinned(id, true) { - Ok(_) => format!("pinned memory {id}"), - Err(e) => format!("error: {e}"), - } -} diff --git a/klbr-core/src/tools/recall.rs b/klbr-core/src/tools/recall.rs deleted file mode 100644 --- a/klbr-core/src/tools/recall.rs +++ /dev/null @@ -1,218 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use serde_json::json; - -use crate::{memory::MemoryStore, models::ToolDef, mvp::SimilarityMetric, retrieval}; - -use super::{Tool, ToolContext}; - -pub fn tool() -> Tool { - Tool::new(definition(), exec) -} - -fn definition() -> ToolDef { - ToolDef::function( - "recall", - "semantic search over long-term memory (facts stored across sessions). \ - does NOT search the current conversation — use this only for things you \ - might have stored previously with remember(). \ - use a specific, noun-heavy query like \"user's preferred editor\" not \ - \"what we just discussed\". \ - by default, filters out low-similarity hits using the runtime memory threshold. \ - with no tags: searches all memories by meaning. \ - with tags: searches only within memories that match those tags, \ - ranked by semantic similarity.", - json!({ - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "what to search for by meaning" - }, - "tags": { - "type": "array", - "items": { "type": "string" }, - "description": "restrict search to memories with these tags, e.g. [\"person:mayer\"] or [\"preference\"]" - }, - "tag_mode": { - "type": "string", - "enum": ["and", "or"], - "description": "\"and\" = all tags must match, \"or\" = any tag matches (default: \"or\")" - }, - "limit": { - "type": "integer", - "description": "max results (default 5)" - }, - "max_distance": { - "type": "number", - "description": "optional cosine-distance cutoff (lower is stricter). defaults to the runtime memory similarity threshold" - } - }, - "required": ["query"] - }), - ) -} - -fn exec(args: serde_json::Value, ctx: ToolContext) -> Pin + Send>> { - Box::pin(execute(args, ctx)) -} - -async fn execute(args: serde_json::Value, ctx: ToolContext) -> String { - let query = match args["query"].as_str() { - Some(q) => q.to_string(), - None => return "error: missing required arg 'query'".into(), - }; - let tags: Vec = args["tags"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - let tag_and = args["tag_mode"].as_str() == Some("and"); - let limit = args["limit"].as_u64().unwrap_or(5) as usize; - let max_distance = args["max_distance"] - .as_f64() - .map(|value| value as f32) - .unwrap_or(ctx.memory_sim_threshold); - - let emb = match ctx.llm.embed(&query).await { - Ok(e) => e, - Err(err) => return format!("error embedding query: {err}"), - }; - - if tags.is_empty() { - let corpus = match ctx.memory.get_searchable() { - Ok(corpus) => corpus, - Err(err) => return format!("error loading memories: {err}"), - }; - let outcome = retrieval::retrieve_exact( - &corpus, - &emb, - &retrieval::RetrievalConfig { - namespace: "default".to_string(), - top_k: limit, - initial_window_days: None, - expansion_window_days: Vec::new(), - expand_distance_threshold: None, - similarity_metric: SimilarityMetric::CosineDistance, - reference_time: Some(unix_timestamp()), - }, - None, - ); - let candidates: Vec<_> = outcome - .top_candidates - .into_iter() - .filter(|candidate| candidate.score < max_distance) - .collect(); - if candidates.is_empty() { - return "no memories found".into(); - } - return candidates - .into_iter() - .enumerate() - .map(|(idx, candidate)| { - let tag_str = if candidate.memory.tags.is_empty() { - String::new() - } else { - format!(" [{}]", candidate.memory.tags.join(", ")) - }; - let content = if idx >= ctx.verbatim_count { - if candidate.memory.text.chars().count() <= 120 { - format!("[snippet] {}", candidate.memory.text) - } else { - let truncated: String = candidate.memory.text.chars().take(120).collect(); - format!("[snippet] {truncated}...") - } - } else { - candidate.memory.text.clone() - }; - format!( - "[dist:{:.3}][id:{}]{tag_str}{} {}", - candidate.score, - candidate.memory.memory_id, - provenance_hint(&ctx.memory, candidate.memory.memory_id), - content - ) - }) - .collect::>() - .join("\n"); - } - - match ctx.memory.recall(Some(&emb), &tags, tag_and, limit) { - Ok(results) => { - let filtered: Vec<_> = results - .into_iter() - .filter(|entry| { - entry - .distance - .is_none_or(|distance| distance < max_distance) - }) - .collect(); - if filtered.is_empty() { - return "no memories found".into(); - } - filtered - .into_iter() - .enumerate() - .map(|(idx, e)| { - let tag_str = if e.tags.is_empty() { - String::new() - } else { - format!(" [{}]", e.tags.join(", ")) - }; - let content = if idx >= ctx.verbatim_count { - if e.content.chars().count() <= 120 { - format!("[snippet] {}", e.content) - } else { - let truncated: String = e.content.chars().take(120).collect(); - format!("[snippet] {truncated}...") - } - } else { - e.content.clone() - }; - format!( - "[dist:{:.3}][id:{}]{tag_str}{} {}", - e.distance.unwrap_or(0.0), - e.id, - provenance_hint(&ctx.memory, e.id), - content - ) - }) - .collect::>() - .join("\n") - } - Err(err) => format!("error: {err}"), - } -} - -fn provenance_hint(memory: &MemoryStore, memory_id: i64) -> String { - let hints = memory - .provenance_counts(memory_id) - .unwrap_or_default() - .into_iter() - .filter(|(_, count)| *count > 0) - .map(|(edge_type, count)| { - let name = match edge_type { - crate::mvp::MemoryEdgeType::DerivedFrom => "derived_from", - crate::mvp::MemoryEdgeType::Supersedes => "supersedes", - crate::mvp::MemoryEdgeType::Supports => "supports", - }; - format!("{name}:{count}") - }) - .collect::>(); - if hints.is_empty() { - String::new() - } else { - format!(" [{}]", hints.join(",")) - } -} - -fn unix_timestamp() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs() as i64) - .unwrap_or_default() -} diff --git a/klbr-core/src/tools/remember.rs b/klbr-core/src/tools/remember.rs deleted file mode 100644 --- a/klbr-core/src/tools/remember.rs +++ /dev/null @@ -1,132 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use serde_json::json; - -use crate::{ - models::ToolDef, - mvp::{MemoryLayer, MemoryRecordInput, MemoryStatus}, -}; - -use super::{Tool, ToolContext}; - -pub fn tool() -> Tool { - Tool::new(definition(), exec) -} - -fn definition() -> ToolDef { - ToolDef::function( - "remember", - "store something in long-term memory. use whenever you learn something worth keeping \ - across sessions — user preferences, facts about projects, decisions, names, etc. \ - set important=true to pin it so it's always visible at startup.", - json!({ - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "the fact or note to remember, written concisely" - }, - "important": { - "type": "boolean", - "description": "if true, pin this memory so it appears at every startup" - }, - "tags": { - "type": "array", - "items": { "type": "string" }, - "description": "optional category tags, e.g. [\"preference\", \"project\", \"person\"]" - }, - "source_refs": { - "type": "array", - "items": { "type": "string" }, - "description": "optional references to conversational chunk IDs this memory is derived from, e.g. [\"d1a\"]" - } - }, - "required": ["content"] - }), - ) -} - -fn exec(args: serde_json::Value, ctx: ToolContext) -> Pin + Send>> { - Box::pin(execute(args, ctx)) -} - -async fn execute(args: serde_json::Value, ctx: ToolContext) -> String { - let content = match args["content"].as_str() { - Some(c) => c.to_string(), - None => return "error: missing required arg 'content'".into(), - }; - let important = args["important"].as_bool().unwrap_or(false); - let tags: Vec = args["tags"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - let source_refs: Vec = args["source_refs"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - match ctx.llm.embed(&content).await { - Ok(emb) => match ctx.memory.store_with_metadata(&MemoryRecordInput { - memory_id: None, - namespace: "default".to_string(), - layer: MemoryLayer::L1, - text: content.clone(), - event_time: unix_timestamp(), - ingest_time: unix_timestamp(), - embedding_model: ctx.llm.config.embedder.model.clone(), - embedding_dim: emb.len(), - embedding_version: "runtime".to_string(), - status: MemoryStatus::Active, - source_ref: Some("tool:remember".to_string()), - tags: tags.clone(), - pinned: important, - embedding: emb, - }) { - Ok(id) => { - let mem_ref = format!("m{}", crate::memory::to_base36(id as u64)); - let mut edge_results = Vec::new(); - for src in &source_refs { - match ctx.memory.add_reflink_edge(&mem_ref, src, "derived_from") { - Ok(_) => {} - Err(e) => edge_results.push(format!("(link to {} failed: {})", src, e)), - } - } - let edge_suffix = if edge_results.is_empty() { - String::new() - } else { - format!("; warnings: {}", edge_results.join(", ")) - }; - - let tag_info = if tags.is_empty() { - String::new() - } else { - format!(", tags: {}", tags.join(", ")) - }; - - if important { - format!("stored and pinned (id:{id}{tag_info}){edge_suffix}") - } else { - format!("stored (id:{id}{tag_info}){edge_suffix}") - } - } - Err(e) => format!("error storing memory: {e}"), - }, - Err(e) => format!("error embedding memory: {e}"), - } -} - -fn unix_timestamp() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs() as i64) - .unwrap_or_default() -} diff --git a/klbr-core/src/tools/tag_memory.rs b/klbr-core/src/tools/tag_memory.rs deleted file mode 100644 --- a/klbr-core/src/tools/tag_memory.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use serde_json::json; - -use crate::llm::ToolDef; - -use super::{Tool, ToolContext}; - -pub fn tool() -> Tool { - Tool::new(definition(), exec) -} - -fn definition() -> ToolDef { - ToolDef::function( - "tag_memory", - "set or replace the tags on an existing memory", - json!({ - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "memory id" - }, - "tags": { - "type": "array", - "items": { "type": "string" }, - "description": "new tag list (replaces existing tags)" - } - }, - "required": ["id", "tags"] - }), - ) -} - -fn exec(args: serde_json::Value, ctx: ToolContext) -> Pin + Send>> { - Box::pin(execute(args, ctx)) -} - -async fn execute(args: serde_json::Value, ctx: ToolContext) -> String { - let id = match args["id"].as_i64() { - Some(i) => i, - None => return "error: missing required arg 'id'".into(), - }; - let tags: Vec = args["tags"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - match ctx.memory.set_tags(id, &tags) { - Ok(_) => format!("tagged memory {id} with: {}", tags.join(", ")), - Err(e) => format!("error: {e}"), - } -} diff --git a/klbr-core/src/tools/unpin_memory.rs b/klbr-core/src/tools/unpin_memory.rs deleted file mode 100644 --- a/klbr-core/src/tools/unpin_memory.rs +++ /dev/null @@ -1,45 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use serde_json::json; - -use crate::llm::ToolDef; - -use super::{Tool, ToolContext}; - -pub fn tool() -> Tool { - Tool::new(definition(), exec) -} - -fn definition() -> ToolDef { - ToolDef::function( - "unpin_memory", - "unpin a memory so it's no longer shown at startup (still searchable). use during \ - reflection to demote pinned memories that are no longer important or accurate.", - json!({ - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "memory id" - } - }, - "required": ["id"] - }), - ) -} - -fn exec(args: serde_json::Value, ctx: ToolContext) -> Pin + Send>> { - Box::pin(execute(args, ctx)) -} - -async fn execute(args: serde_json::Value, ctx: ToolContext) -> String { - let id = match args["id"].as_i64() { - Some(i) => i, - None => return "error: missing required arg 'id'".into(), - }; - match ctx.memory.set_pinned(id, false) { - Ok(_) => format!("unpinned memory {id}"), - Err(e) => format!("error: {e}"), - } -} diff --git a/klbr-core/src/tools/write_memory_note.rs b/klbr-core/src/tools/write_memory_note.rs deleted file mode 100644 --- a/klbr-core/src/tools/write_memory_note.rs +++ /dev/null @@ -1,338 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use serde_json::json; - -use crate::{ - memory::{MarkdownNoteInput, MemoryLane}, - models::ToolDef, -}; - -use super::{Tool, ToolContext}; - -pub fn tool() -> Tool { - Tool::new(definition(), exec) -} - -fn definition() -> ToolDef { - ToolDef::function( - "write_memory_note", - "write a source-grounded profile or procedural markdown note. use this during reflection when source refs support a stable preference, standing instruction, or workflow. source_refs are required; sensitive profile changes should be user-confirmed.", - json!({ - "type": "object", - "properties": { - "lane": { - "type": "string", - "enum": ["profile", "procedural"], - "description": "which durable lane to write" - }, - "title": { - "type": "string", - "description": "short human-readable note title" - }, - "body": { - "type": "string", - "description": "markdown body, grounded only in the cited source refs" - }, - "source_refs": { - "type": "array", - "items": { "type": "string" }, - "description": "refs this note is derived from" - }, - "entities": { - "type": "array", - "items": { "type": "string" }, - "description": "optional entity labels such as person:dawn or project:klbr" - }, - "note_ref": { - "type": "string", - "description": "optional new note ref; omit for a generated stable ref" - }, - "update_of": { - "type": "string", - "description": "optional existing note ref this new note supersedes" - }, - "user_confirmed": { - "type": "boolean", - "description": "true when the user explicitly confirmed this stable profile/procedural note" - } - }, - "required": ["lane", "title", "body", "source_refs"] - }), - ) -} - -fn exec(args: serde_json::Value, ctx: ToolContext) -> Pin + Send>> { - Box::pin(execute(args, ctx)) -} - -async fn execute(args: serde_json::Value, ctx: ToolContext) -> String { - let lane = match args["lane"].as_str().map(MemoryLane::parse) { - Some(MemoryLane::Profile) => MemoryLane::Profile, - Some(MemoryLane::Procedural) => MemoryLane::Procedural, - Some(other) => { - return format!( - "error: write_memory_note only supports profile/procedural lanes, got {}", - other.as_str() - ); - } - None => return "error: missing required arg 'lane'".to_string(), - }; - let title = match args["title"] - .as_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - Some(title) => title.to_string(), - None => return "error: missing required arg 'title'".to_string(), - }; - let body = match args["body"] - .as_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - Some(body) => body.to_string(), - None => return "error: missing required arg 'body'".to_string(), - }; - let source_refs = args["source_refs"] - .as_array() - .map(|items| { - items - .iter() - .filter_map(|item| item.as_str().map(str::trim)) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .collect::>() - }) - .unwrap_or_default(); - if source_refs.is_empty() { - return "error: source_refs are required for profile/procedural notes".to_string(); - } - let entities = args["entities"] - .as_array() - .map(|items| { - items - .iter() - .filter_map(|item| item.as_str().map(str::trim)) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .collect::>() - }) - .unwrap_or_default(); - let note_ref = args["note_ref"] - .as_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let update_of = args["update_of"] - .as_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - if let Some(note_ref) = ¬e_ref { - match ctx.memory.get_resolved_ref(note_ref) { - Ok(Some(_)) if update_of.is_none() => { - return format!("error: note_ref {note_ref} already exists; write a new note with update_of instead of overwriting"); - } - Ok(_) => {} - Err(error) => return format!("error checking note_ref {note_ref}: {error}"), - } - } - - let kind = match lane { - MemoryLane::Profile => "profile_note", - MemoryLane::Procedural => "procedural_note", - _ => unreachable!("lane checked above"), - }; - let user_confirmed = args["user_confirmed"].as_bool().unwrap_or(false); - let frontmatter = json!({ - "source_policy": "source_refs_required", - "update_policy": if lane == MemoryLane::Profile { - "user_confirmation_recommended" - } else { - "source_grounded_reflection" - }, - "user_confirmed": user_confirmed, - "supersedes": update_of, - }); - let record = match ctx.memory.upsert_markdown_note(&MarkdownNoteInput { - note_ref, - lane, - kind: kind.to_string(), - title, - path: None, - body, - sources: source_refs, - follow: None, - entities, - status: "active".to_string(), - frontmatter, - }) { - Ok(record) => record, - Err(error) => return format!("error writing note: {error}"), - }; - if let Some(old_ref) = update_of { - if let Err(error) = ctx.memory.supersede_ref(&old_ref, &record.note_ref) { - return format!( - "wrote note {}, but failed to supersede {}: {}", - record.note_ref, old_ref, error - ); - } - } - format!( - "wrote {} note {} at {} with {} chunks", - lane.as_str(), - record.note_ref, - record.path, - record.chunk_refs.len() - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use anyhow::Result; - use tempfile::NamedTempFile; - - use crate::{ - memory::{to_base36, MemoryStore}, - models::{LlmClient, ModelsConfig}, - }; - - fn tool_context(store: MemoryStore) -> ToolContext { - let llm = LlmClient::new(ModelsConfig::default()); - ToolContext::new(store, llm.clone(), llm, 0.3, 2) - } - - #[tokio::test] - async fn writes_profile_note_with_sources_path_chunks_and_policy() -> Result<()> { - let tmp = NamedTempFile::new()?; - let store = MemoryStore::open(tmp.path().to_str().unwrap(), 4)?; - let turn = store.log_turn("user", "dawn prefers late-night walks.", None)?; - let source_ref = format!("d{}_a", to_base36(turn.id as u64)); - let result = execute( - json!({ - "lane": "profile", - "title": "walk preference", - "body": "dawn prefers late-night walks over crowded parties.", - "source_refs": [source_ref], - "entities": ["person:dawn"], - "note_ref": "pwalks", - "user_confirmed": true - }), - tool_context(store.clone()), - ) - .await; - - assert!(result.contains("wrote profile note pwalks")); - let resolved = store.get_resolved_ref("pwalks")?.unwrap(); - assert_eq!(resolved.entity_type, "profile_note"); - assert_eq!(resolved.status, "active"); - assert!(resolved - .body - .as_deref() - .is_some_and(|body| body.contains("late-night walks"))); - let hits = store.search_refs_fts("late night walks", &[MemoryLane::Profile], 5)?; - assert!(hits - .iter() - .any(|hit| hit.alias.as_deref() == Some("pwalks"))); - - let conn = store.conn().lock().unwrap(); - let (path, frontmatter): (String, String) = conn.query_row( - "SELECT path, frontmatter FROM markdown_notes WHERE note_ref = 'pwalks'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - assert_eq!(path, "profile/pwalks.md"); - assert!(frontmatter.contains("user_confirmation_recommended")); - assert!(frontmatter.contains("\"user_confirmed\":true")); - let edge_count: i64 = conn.query_row( - "SELECT COUNT(*) FROM edges WHERE src_ref_id = ( - SELECT ref_id FROM ref_aliases WHERE alias = 'pwalks' - ) AND rel_type = 'derived_from'", - [], - |row| row.get(0), - )?; - assert_eq!(edge_count, 1); - Ok(()) - } - - #[tokio::test] - async fn writes_procedural_note_and_supersedes_profile_updates() -> Result<()> { - let tmp = NamedTempFile::new()?; - let store = MemoryStore::open(tmp.path().to_str().unwrap(), 4)?; - let turn = store.log_turn( - "user", - "when updating release notes, cite the source issue.", - None, - )?; - let source_ref = format!("d{}_a", to_base36(turn.id as u64)); - let ctx = tool_context(store.clone()); - - let procedural = execute( - json!({ - "lane": "procedural", - "title": "release note workflow", - "body": "when updating release notes, cite the source issue.", - "source_refs": [source_ref], - "note_ref": "prrelease" - }), - ctx.clone(), - ) - .await; - assert!(procedural.contains("wrote procedural note prrelease")); - let procedural_ref = store.get_resolved_ref("prrelease")?.unwrap(); - assert_eq!(procedural_ref.entity_type, "procedural_note"); - let procedural_hits = - store.search_refs_fts("release notes source issue", &[MemoryLane::Procedural], 5)?; - assert!(procedural_hits - .iter() - .any(|hit| hit.alias.as_deref() == Some("prrelease"))); - - let original = execute( - json!({ - "lane": "profile", - "title": "editor preference", - "body": "dawn prefers compact release notes.", - "source_refs": [source_ref], - "note_ref": "peditor" - }), - ctx.clone(), - ) - .await; - assert!(original.contains("wrote profile note peditor")); - let updated = execute( - json!({ - "lane": "profile", - "title": "editor preference", - "body": "dawn prefers compact release notes with source issue links.", - "source_refs": [source_ref], - "update_of": "peditor", - "user_confirmed": true - }), - ctx, - ) - .await; - assert!(updated.contains("wrote profile note")); - - let old = store.get_resolved_ref("peditor")?.unwrap(); - assert_eq!(old.status, "superseded"); - assert!(old.replacement_ref_id.is_some()); - let replacement = old.replacement_ref_id.unwrap(); - let replacement_ref = store.get_resolved_ref(&replacement)?.unwrap(); - assert_eq!(replacement_ref.status, "active"); - assert!(replacement_ref - .body - .as_deref() - .is_some_and(|body| body.contains("source issue links"))); - - let conn = store.conn().lock().unwrap(); - let supersedes_edges: i64 = conn.query_row( - "SELECT COUNT(*) FROM edges WHERE rel_type = 'supersedes'", - [], - |row| row.get(0), - )?; - assert_eq!(supersedes_edges, 1); - Ok(()) - } -}