diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7d21fdc..cf2be1f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vta-agent-memory", "description": "Durable agent memory stored in your own Verifiable Trust Agent, not in the tool. Save and recall facts across sessions, scoped to a VTA trust context you control and can revoke.", - "version": "0.8.1", + "version": "0.8.2", "keywords": [ "memory", "vta", diff --git a/Cargo.lock b/Cargo.lock index 5e6649b..1c24c99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5437,7 +5437,7 @@ checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" [[package]] name = "vta-agent-memory" -version = "0.8.1" +version = "0.8.2" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 57bc926..374d3ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vta-agent-memory" -version = "0.8.1" +version = "0.8.2" edition = "2024" rust-version = "1.95.0" description = "Agentic memory for Claude Code, stored in a Verifiable Trust Agent" diff --git a/README.md b/README.md index 8498fde..af0820b 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,15 @@ the "this was wrong" path; `forget` is still the hard delete, and takes the history with it. **Recall reaches the Data Rooms you belong to, read-only.** `vta-agent-memory -rooms add --room --host [--label L]` adds a room for recall to search. +rooms add --room --host [--member ] [--label L]` adds a room for +recall to search. Your VTA presents a room's credentials only for a member persona +in a context the memory agent's grant covers, so grant that persona's context too +(`pnm acl update --contexts ,`). +The room's credentials can be held under a *parent* of the persona's context; if +the persona's own context is not enough, grant the parent. +Pick a personal persona as `--member`, never the VTA's or a community's own DID, +which would hand the agent their contexts. `doctor` reads each room and prints +this command when the grant is missing. `memory_recall` then searches personal memory and those rooms together and ranks them on one scale. Every result says where it came from (`source`), who wrote it, its status and its basis. A sealed room record's in-body signature is checked diff --git a/src/main.rs b/src/main.rs index a66b9de..c6f8b4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -646,7 +646,11 @@ fn rooms(config_path: &std::path::Path, action: RoomsAction) -> anyhow::Result<( label, }); cfg.save(config_path)?; - println!("Recall now reads room {room}. `doctor` checks it can be read."); + println!( + "Recall now reads room {room}. The memory agent can read it only as a member \ + persona in a context its grant covers; `doctor` checks, and says what to grant \ + if it cannot." + ); } RoomsAction::Remove { room } => { let before = cfg.rooms.len(); @@ -724,7 +728,20 @@ async fn doctor(config_path: &std::path::Path) -> anyhow::Result<()> { read.records.len(), read.refused.len() ), - Err(e) => println!("room {name}: unavailable — {e:#}"), + Err(e) => { + println!("room {name}: unavailable — {e:#}"); + let agent = store.signer().map(|s| s.did()).unwrap_or(""); + if let Some(hint) = vta_agent_memory::rooms::grant_hint( + &format!("{e:#}"), + agent, + &cfg.context_id, + room.member.as_deref(), + ) { + for line in hint.lines() { + println!(" {line}"); + } + } + } } } } diff --git a/src/rooms.rs b/src/rooms.rs index f9bac49..77abba3 100644 --- a/src/rooms.rs +++ b/src/rooms.rs @@ -151,11 +151,18 @@ impl RoomRecord { basis * status } - /// The name to rank on and show: the title, or the key for an untitled one. + /// The name to show: the title, or the key for an untitled one. pub fn name(&self) -> &str { self.title.as_deref().unwrap_or(&self.key) } + /// The name to rank on: the title only. A key is not a name — on a sealed + /// room it MUST be opaque, and keys like `note/` made every untitled + /// record match the word "note" (found against a live room). + pub fn rank_name(&self) -> &str { + self.title.as_deref().unwrap_or("") + } + /// What recall returns: no body, like a memory's summary. pub fn summary(&self) -> Value { let mut v = json!({ @@ -438,6 +445,39 @@ fn clip(s: &str) -> String { format!("{}…", s.chars().take(MAX - 1).collect::()) } +/// What to tell somebody whose room read was refused because the agent may +/// not act as any member of the room, or `None` for any other failure. +/// +/// The VTA presents a room's credentials only for a persona in a context the +/// caller's grant covers (`room_oracle::principals`): the room capabilities an +/// `application` grant carries are not enough on their own. A memory agent is +/// granted its memory context, so reading a room means adding the context that +/// holds the member persona — or its parent: against a live VTA the room's +/// credentials were held under `openvtc-iiw1` while the persona's DID and key +/// were in `openvtc-iiw1/oss-developer`, and only the parent grant worked +/// (custody is checked by context, with ancestry, so a child grant does not +/// reach a parent's credentials). A personal persona is the one to pick — naming +/// the VTA's or a community's own identity would hand the agent their contexts. +pub fn grant_hint( + error: &str, + agent_did: &str, + memory_context: &str, + member: Option<&str>, +) -> Option { + if !error.contains("notAMember") { + return None; + } + let who = member.unwrap_or("the member persona you read as"); + Some(format!( + "The agent can act only as personas in the contexts it is granted. Add the context \ + that holds {who}\n(`pnm did-mgmt dids list --full-display` shows each DID's \ + context) — prefer a personal persona over the VTA's or a community's own DID:\n \ + pnm acl update {agent_did} --contexts {memory_context},\nThe room's \ + credentials can be held under a parent of the persona's context: if granting the \ + persona's own context is not enough, grant its parent." + )) +} + /// Which sources a recall reads. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Sources { @@ -606,7 +646,7 @@ pub async fn recall( for record in read.records { let score = crate::record::relevance( &terms, - record.name(), + record.rank_name(), &record.description, &record.body, ) * record.weight(); @@ -701,6 +741,25 @@ mod tests { assert!(err.contains("deals"), "{err}"); } + #[test] + fn a_not_a_member_refusal_names_the_grant_that_fixes_it() { + let hint = grant_hint( + "trust task failed [rooms/keys/browse:notAMember]: this VTA holds no …", + "did:key:zAgent", + "agent-memory", + Some("did:webvh:x:once-rug"), + ) + .expect("a hint"); + assert!( + hint.contains("pnm acl update did:key:zAgent --contexts agent-memory,") + ); + assert!(hint.contains("did:webvh:x:once-rug")); + assert_eq!( + grant_hint("hostUnreachable", "did:key:zAgent", "agent-memory", None), + None + ); + } + #[test] fn a_deprecated_room_record_weighs_half() { let mut r = RoomRecord { diff --git a/tests/eval/scenarios/22-sources-room-abstention.json b/tests/eval/scenarios/22-sources-room-abstention.json index e38d07a..fa12a47 100644 --- a/tests/eval/scenarios/22-sources-room-abstention.json +++ b/tests/eval/scenarios/22-sources-room-abstention.json @@ -1,11 +1,13 @@ { "name": "sources-room-abstention", "dimension": "sources", - "description": "With rooms configured, a question nothing answers still gets nothing, and an open room's record is recalled marked as only host-reported.", + "description": "With rooms configured, a question nothing answers still gets nothing; an untitled record does not match on its storage key; and an open room's record is recalled marked as only host-reported.", "rooms": ["wiki"], "steps": [ { "save": { "type": "user", "name": "Editor", "description": "The user edits in Helix" } }, { "room": { "room": "wiki", "key": "style", "open": true, "title": "Code style", "description": "Rust code is formatted with rustfmt defaults" } }, + { "room": { "room": "wiki", "key": "note/5e87dc2b0c00", "body": "lunch is at noon" } }, + { "room": { "room": "wiki", "key": "note/28da83712071", "title": "IIW session notes", "body": "Agent memory demo went well" } }, { "probe": { "label": "unrelated", @@ -13,6 +15,15 @@ "expectEmpty": true } }, + { + "probe": { + "label": "an untitled record's key is not its name", + "query": "IIW notes", + "relevant": ["room:wiki/note/28da83712071"], + "top": "room:wiki/note/28da83712071", + "absent": ["room:wiki/note/5e87dc2b0c00"] + } + }, { "probe": { "label": "open room",