Durable memory for Claude Code, stored in your own Verifiable Trust Agent rather than in the tool.
Memories are ordinary vta/memory/* Trust Tasks (put, delete and list at 0.3, get at 0.2) against a
VTA you control. They are scoped to one trust context, audited on the VTA, and
included in its encrypted backups. Revoking this machine's access is one
pnm acl delete.
Name note:
vta-agent-memoryis descriptive, not a product name. If this ships under the AffinidiAgent[Capability]house style, the name is the last thing to decide, not the first.
A Claude Code plugin with:
- Nine MCP tools —
memory_recall,memory_get,memory_save,memory_retract,memory_dispute,memory_deprecate,memory_forget,memory_list,memory_context. - A skill (
agent-memory) that says when to save and recall, what is worth keeping, and what must never go in. This is most of the value; the tools are the easy part. - Four slash commands —
/remember,/recall,/forget,/memories. - A
SessionStarthook that loads your memories into a new session before you type anything, so the model does not have to remember to ask.
Needs Rust 1.95+, and a VTA you are already logged into with
pnm.
git clone https://github.com/OpenVTC/vta-agent-memory
cd vta-agent-memory
scripts/install.sh # cargo install, into ~/.cargo/binThen add it to Claude Code. Two steps — claude plugin install <name>
resolves from configured marketplaces, so on its own it reports
"not found in any configured marketplace":
claude plugin marketplace add OpenVTC/vta-agent-memory
claude plugin install vta-agent-memory@vta-agent-memoryThe plugin ships Rust source, not a compiled binary, so bin/vta-agent-memory
in the repo is a shim: .mcp.json and the hook invoke it, and it execs
whichever real binary cargo install produced. That is what makes a
marketplace install work, where the plugin directory is a fresh clone with no
build artifacts in it. Override the lookup with $VTA_AGENT_MEMORY_BIN.
Two phases, because the machine holding your memories should not also hold an operator credential — and because the identity you paste into a ticket should stop working once it has been used.
bin/vta-agent-memory init --vta-did did:webvh:… --context agent-memoryThis machine minted a temporary identity and needs it authorized.
temp DID did:key:z6Mkoqb2y3jbu6zoPDBoYGzA4j34JwsnToSUNqBgh9W7VebT
vta did:webvh:…
context agent-memory
Run these wherever you hold VTA admin — it does not have to be this machine:
pnm contexts create --id agent-memory --name agent-memory # skip if it exists
pnm acl create --did did:key:z6Mkoqb… --role application --contexts agent-memory --purposes personalisation --label vta-agent-memory
Then, back here:
vta-agent-memory connect
The temporary DID above is rotated away on that first connect, so it stops
being an authenticator once it has done its job.
That last line is the point. This is the same
ephemeral did:key → ACL grant → auto-rotate on first connect flow the
mediator, PNM and the DID-hosting services use, driven through the shared
vti_secrets::IntegrationOnboarding helper rather than reinvented:
initneeds no credential. It mints a throwaway key, parks it in the session store, and prints the grant. Nothing authenticated is called.- The grant runs anywhere — another laptop, CI, a colleague reading it off a ticket.
connectrotates. On the first successful authentication the session store atomically swaps the throwaway key for a fresh one, mirrors the ACL entry onto the new DID, and drops the temp one. The DID that travelled through a low-trust channel is no longer live.- No private key is ever written to this tool's config. It lives in the session backend — the OS keyring, normally — and the config records only which session to open.
connect then proves the rotated identity can actually read the context before
writing anything.
bin/vta-agent-memory setup # your default `pnm` VTA
bin/vta-agent-memory setup --vta did:webvh:… # a specific one, by DIDsetup does both phases at once, making the grant itself through your pnm
login. It runs the same onboarding and the same rotation — the convenience
path is not the less safe one. It does need an operator credential here,
though, so prefer init + connect anywhere that matters. --vta also accepts the local pnm
name, but prefer the DID: a pnm name is a nickname chosen on one machine and
means nothing on any other.
Check it any time:
bin/vta-agent-memory doctorconfig ~/.config/vta-agent-memory/config.json
vta did:webvh:abc:vta.example.com:mine
context my-project
identity dedicated agent did:key
transport Didcomm
memories 12
For init + connect: the VTA's DID, a trust context, and somebody who can run
one pnm acl create. Nothing else — no pnm on this machine.
For setup: a VTA you have already logged into with pnm here. It reads
pnm's own config (dirs::config_dir()/pnm — ~/Library/Application Support/pnm
on macOS, not ~/.config) and reuses that login's keyring session. If
pnm auth status works, so will this.
Everything rides a flow the VTI stack already has:
| Step | Existing asset |
|---|---|
| Mint, park, rotate | vti_secrets::IntegrationOnboarding — the flow the mediator, PNM and DID-hosting share |
| Find its way in | resolve_vta_endpoint against the VTA's DID document |
| Authorize it | acl/create, role application, scoped to one context |
| Hold the key | the SDK session store (OS keyring), never this tool's config |
| Prove it | a real vta/memory/list/0.3 as the rotated identity |
setup additionally uses pnm's config and keyring session to find the VTA and
make the grant without a second person.
Nothing is written to disk until that last step passes. A config that has never been exercised is a config that fails later, in a hook, with nobody watching.
By default setup mints a dedicated did:key whose ACL entry names exactly
one context and the least-privileged role that can reach memory. That is the
point: the memory service is not you. Revoke it with
pnm acl delete --did <the agent DID setup printed>--use-session skips the minting and reuses your own login instead. It stores no
key, but the memory service then inherits your whole reach, and revoking it means
revoking your login. For that reason the MCP server, the SessionStart hook and
the recall/list/forget/doctor commands refuse a config written this
way unless VTA_AGENT_MEMORY_ALLOW_OPERATOR_LOGIN=1 is set in the environment
they run in (for the plugin, the environment Claude Code is started from). The
default dedicated agent needs no such setting.
Reach for it when the VTA advertises no DIDComm mediator — a REST-only VTA — the
one case where a did:key agent has no way in. If that VTA has a mediator that
simply isn't in its DID document (a did:key VTA, an airgapped one), the better
fix is to tell pnm about it once:
# ~/.config/pnm/config.toml
[vtas.mine]
mediator_did = "did:web:mediator.example.com"Setup prefers what pnm was configured with over DID-document discovery,
precisely because discovery cannot find the deployments that need it.
Online only. There is no offline/air-gapped setup path.
None of this code is transport-aware. VtaClient::memory_{put,list,delete} go
through the generic Trust-Task dispatcher, which selects TSP > DIDComm > REST
from what your DID document and the VTA's both advertise. doctor prints which
one you ended up on.
Retrieval happens on this side of the wire. memory/list/0.3 pages a
context in key order and narrows by key prefix, but does not search. So:
- Keys are structured
<type>/<slug>, which lets a type filter run on the raw key before any record is decoded. - Records carry a one-line
description, and recall ranks and returns descriptions, not bodies. Bodies come back only frommemory_get. A memory service that pastes every body into the context window has made the session worse. - Ranking is weighted whole-word matching (name 4, description 2, body 1), with plurals folded to their singular and a short list of English stopwords dropped from the query — so "run" does not match "runner", and "what is the" matches nothing. Crude and predictable, chosen over embeddings because the VTA offers no vector index and a scoring function nobody can predict is worse than one everybody can.
- Relevance is then weighted by basis — what kind of claim the memory is:
declared(the user said it) and evidencedverifiedcount fully,observed0.9,unstated(pre-provenance records) 0.8,inferredand evidence-lessverified0.6. A nudge, not an override: a strong match on an inference still beats a passing mention of a statement.confidenceis shown but never ranked on — two authors' estimates are not on one scale. The vocabulary (basis,confidence,evidence) is the one Data Rooms publish, so a record moved between memory and a room keeps its meaning.
Cost is a full read of the context per recall. Fine at the hundreds of memories a
person accumulates. If that stops being true, the fix is upstream — a
vta/memory/query with a prefix and a cursor — and it starts with a spec PR in
trustoverip/dtgwg-trust-tasks-tf, because the VTA's dispatcher refuses URIs the
published registry has no schema for.
What a save can hold, and how much of it a session gets. A memory is a note,
not a document: memory_save refuses a name over 120 characters, a description
over 300, a body over 16 KiB, more than 32 links, or a link over 120 characters.
memory_list returns one page — 50 by default, at most 200 — with total and a
nextOffset for the next one. The SessionStart hook caps what it injects at
32 KiB and says inside the fence when it had to truncate, because that text lands
in the context before the user has typed anything. The limits are checked where a
record comes in, never on decode, so a memory stored before they existed — or by
another tool — stays readable and forgettable.
Every memory names its author, and the VTA is not trusted to keep it honest.
A trust context can be granted to several DIDs — other devices, other agents —
and the VTA stores whatever any of them puts. So each record is signed by the
writer's own did:key (a Data Integrity proof, the same primitive Data Rooms put
on a sealed record body), naming its author and the (contextId, key) it was
written to. Every read checks it. A record that was edited after signing, copied
to another key or context, or claims an author other than its signer is
refused: it is not returned as memory, and recall, list and the session-start
hook name it and say why. Records from before signing (v1) still read, marked
unattributed; a v2 record with its signature stripped is refused rather than
downgraded. Verification is did:key-only and does no network I/O, so a stored
record cannot make recall fetch anything. doctor shows the DID this machine
signs as.
Nothing a save replaces is lost, and two writers cannot silently overwrite
each other. A save reads the current version, copies it verbatim — still signed
by its own author — to archive/<type>/<slug>/v<n> (outside every type prefix, so
recall never sees it; at most five per memory), and then writes conditionally
on the version it read. A writer that got in first wins, and the losing save
comes back as saved: false with the winner, not as an overwrite. A caller that
read a memory earlier passes that version as expectedVersion. Memories also
have a lifecycle — active, deprecated, disputed (both recalled lower, with a
reason) or retracted (a tombstone that never recalls) — plus validUntil
(recall drops it after) and reviewAfter (recall flags it stale). Retract is
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> [--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
the same way pnm rooms read checks it: the signature must hold, the author must
be the signer, the record must be at the room, key and version it was signed for,
and the author must match the one the host recorded. A record that fails any of
these is left out and reported in refused. An open room's records carry no such
signature and are marked hostReported. Deprecated room records rank lower;
retracted and expired ones are left out. A room that can't be read is listed as
unavailable, and recall carries on without it. sources narrows where recall
looks. Recall never writes to a room. The session-start hook still reads
personal memory only, so starting a session never waits on a room's host.
Memories can leave the VTA without losing who wrote them. export writes a
signed bundle of a context's memories — optionally narrowed by --type,
--basis or --status — with each record's own author signature intact and the
exporter's signature over the whole. import checks both and refuses a bundle
that fails either, whole. An imported memory is signed by the importer for where
it now is and carries the original, so it is still attributed to whoever first
wrote it. The format is documented in docs/memory-bundle.md
for readers that are not this crate.
Memory is not application state. "Forget everything" has to stay a safe thing
for a user to ask, which it stops being the moment account state lives here. The
VTA's vta/app-state/*/1.0 family — versioned, namespaced, with a change feed —
is where that belongs.
Memory is not a secret store. The VTA has a secrets vault and a credential vault, gated on different capabilities on purpose.
vta-agent-memory setup [--vta <did:… | pnm-name>] [--context <id>] [--use-session] [--force]
vta-agent-memory serve # MCP over stdio (the default)
vta-agent-memory recall [--query Q] [--type T] [--limit N] [--full] [--format text|json]
vta-agent-memory list [--type T]
vta-agent-memory forget <key>
vta-agent-memory export [--out FILE] [--type T] [--basis B] [--status S]
vta-agent-memory import FILE|- [--replace] [--dry-run] [--type T] [--basis B] [--status S]
vta-agent-memory rooms add --room DID --host DID [--member DID] [--label L] | remove ROOM | list
vta-agent-memory doctor
recall --format json emits the hookSpecificOutput.additionalContext envelope
Claude Code reads from a SessionStart hook. That is why this is a CLI as well
as an MCP server: hooks are shell commands, not MCP calls.
dirs::config_dir()/vta-agent-memory/config.json (override with
$VTA_AGENT_MEMORY_CONFIG), written 0600. It holds no key material — the
key lives in the session store — only which session to open:
Every memory operation names the purpose the assistant will use what it reads
for, and the VTA holds it to the purposes the agent's access entry grants and
records it with the read (VTI-ACL-100 – 110). The default is
personalisation: adapting the assistant's help to you — preferences, context
carried between sessions — which is what recalling your memories is for.
setup and init grant exactly that purpose, rather than any.
To name another, set "purpose" in the config (or $VTA_AGENT_MEMORY_PURPOSE
for one process). It must be one the entry grants; a purpose it does not grant
is refused before anything is read or written, and the error names the fix:
pnm acl update <agent DID> --purposes personalisation,taskExecutionAn entry created before this release grants any, under which
personalisation is served without a change.
The plugin does not disappear. The MCP server starts without contacting the VTA and connects on the first memory call, so:
- The nine tools are always present, and a failure comes back as a tool error the
model can relay — "run
vta-agent-memory setup" rather than silence. memory_contextanswers without connecting, so you can still ask which VTA and context you are pointed at while it is down.- Failures are not latched: a VTA that comes back is picked up on the next call,
and
setuprun mid-session takes effect without a restart. - The
SessionStarthook stays quiet and exits 0, so a session never fails to start because memory was unavailable.
A session that never touches memory never opens a mediator socket at all.
cargo testtests/mcp_starts_without_a_vta.rs drives the real binary over stdio with no
config at all, asserting it completes an MCP handshake, offers all nine tools, and
returns an actionable error from a tool call. That is a property about process
lifetime, so it can only be observed by running the thing.
tests/eval.rs is the memory quality evaluation: 18 scripted multi-session
scenarios — precision, staleness, contradiction, minimisation, repairability,
poisoning — replayed against the real store and scored (precision, recall, MRR,
repair operations). cargo test --test eval prints the report; CI puts it in the
job summary. See tests/eval/README.md.
tests/memory_roundtrip.rs runs the real VtaClient::memory_* bodies through
the SDK's in-process loopback transport into a fake keyspace with the VTA's own
storage semantics — upsert on (contextId, key), whole-context list,
not found on an absent delete. No VTA, no mediator, no socket, but the
payloads asserted on are the ones that ship. Framing (TSP sealing, DIDComm
authcrypt, mediator routing) sits below that point and needs a live VTA.
vta-mcp (in the VTI workspace) bridges the VTA's whole management console to
MCP, and its generic vta_call tool can already reach vta/memory/*. This is
not a smaller copy of it — it is the memory product: tools shaped like
remembering rather than like a key/value store, plus the retrieval, skill, and
hook layers that the raw Trust Tasks have no opinion about.
Both share one connect ladder, vta_sdk::agent_connect::AgentConnect — did:webvh
bundle, scoped did:key, bearer token, pnm session — so the two agree about how
an agent-side bridge gets in.
Apache-2.0.
{ "version": 1, "contextId": "agent-memory", "identity": { "serviceName": "vta-agent-memory", "sessionKey": "agent:agent-memory", "sessionsDir": "…/vta-agent-memory", "vtaDid": "did:webvh:…", "operatorLogin": false }, "recallLimit": 8 }