Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <did> --host <did> [--label L]` adds a room for recall to search.
rooms add --room <did> --host <did> [--member <did>] [--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 <agent DID> --contexts <memory context>,<persona's context>`).
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
Expand Down
21 changes: 19 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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("<agent DID>");
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}");
}
}
}
}
}
}
Expand Down
63 changes: 61 additions & 2 deletions src/rooms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hash>` 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!({
Expand Down Expand Up @@ -438,6 +445,39 @@ fn clip(s: &str) -> String {
format!("{}…", s.chars().take(MAX - 1).collect::<String>())
}

/// 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<String> {
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},<that 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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,<that context>")
);
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 {
Expand Down
13 changes: 12 additions & 1 deletion tests/eval/scenarios/22-sources-room-abstention.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
{
"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",
"query": "kubernetes ingress certificates",
"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",
Expand Down
Loading