Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

2 watching

Forks

Repository files navigation

vta-agent-memory

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-memory is descriptive, not a product name. If this ships under the Affinidi Agent[Capability] house style, the name is the last thing to decide, not the first.

What you get

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 SessionStart hook that loads your memories into a new session before you type anything, so the model does not have to remember to ask.

Install

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/bin

Then 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-memory

The 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.

Enrol this machine

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-memory
This 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:

  • init needs 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.
  • connect rotates. 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.

Or, if you hold admin on this machine

bin/vta-agent-memory setup              # your default `pnm` VTA
bin/vta-agent-memory setup --vta did:webvh:…   # a specific one, by DID

setup 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 doctor
config      ~/.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

Prerequisites

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.

What setup does

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.

Transports

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.

Design notes worth knowing

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 from memory_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 evidenced verified count fully, observed 0.9, unstated (pre-provenance records) 0.8, inferred and evidence-less verified 0.6. A nudge, not an override: a strong match on an inference still beats a passing mention of a statement. confidence is 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.

Commands

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.

Configuration

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:

{
  "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
}

Purpose of use

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,taskExecution

An entry created before this release grants any, under which personalisation is served without a change.

When the VTA is unreachable

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_context answers 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 setup run mid-session takes effect without a restart.
  • The SessionStart hook 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.

Tests

cargo test

tests/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.

Relationship to vta-mcp

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.

Licence

Apache-2.0.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages