@agent-custody/state 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -7
- package/dist/blast.d.ts +25 -0
- package/dist/blast.js +62 -0
- package/dist/cli.js +31 -3
- package/dist/http.d.ts +16 -0
- package/dist/http.js +46 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- package/dist/ledger.d.ts +35 -1
- package/dist/ledger.js +60 -3
- package/dist/server.d.ts +5 -0
- package/dist/server.js +57 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ ledger.retract({ factId: a.fact.factId, actor: "user:admin", reason: "poisoned b
|
|
|
22
22
|
ledger.asOf({ validAt: "2026-09-01T00:00:00Z", txAt: "2026-09-01T00:00:00Z" });
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
Five runnable examples, all executed by the test suite. [05-blast-radius.ts](examples/05-blast-radius.ts) walks from a retracted belief to everything that relied on it. [04-evals.ts](examples/04-evals.ts) scores the ledger and a naive store on the same memory incidents. [03-memory-behind-the-gateway.ts](examples/03-memory-behind-the-gateway.ts) runs the memory server as the gateway's upstream. [01-ledger.ts](examples/01-ledger.ts) walks through a wrong write and its undo. [02-receipt-to-belief.ts](examples/02-receipt-to-belief.ts) runs the whole loop with the receipts package: a tool call gets a signed receipt, the receipt is verified, the belief taken from it is recorded citing the receipt, and later retracted. Run them with `node examples/<file>` from this directory, after `bun run build` at the repository root.
|
|
26
26
|
|
|
27
27
|
## The memory server
|
|
28
28
|
|
|
@@ -44,14 +44,65 @@ In the gateway's config, the memory server is the upstream, and the grant names
|
|
|
44
44
|
| `memory.read` | the facts believed at a moment, by space, subject, predicate, valid time, transaction time | the query |
|
|
45
45
|
| `memory.confirm` | lifts a quarantined fact to attested; accepted only through the gateway | `factId` |
|
|
46
46
|
| `memory.retract` | undoes a belief, keeping it visible to questions about the past | `factId`, `reason` |
|
|
47
|
+
| `memory.forget` | erases the value from the ledger and every store, keeping the digest; the receipt is the certificate | `factId`, `reason` |
|
|
48
|
+
| `memory.get` | one fact by id in any state, for the gateway's policy lookups | `factId` |
|
|
47
49
|
| `memory.history` | every event that touched a fact | `factId` |
|
|
48
50
|
|
|
49
|
-
**Quarantine.** Every fact carries a provenance. A write that came through the gateway is `attested`: its actor is the agent named in a human-signed grant and its receipt exists. A write that arrived any other way is `claimed`, and claimed facts are quarantined: `memory.read` leaves them out unless the caller asks for `includeClaimed`, and the policy can refuse that. `memory.confirm`, accepted only through the gateway, lifts a claimed fact to attested with its own receipt and transaction time, so "was this fact still in quarantine on Tuesday" is answerable. A tool result an SDK-only agent wrote down cannot become something the rest of the fleet believes until an attested party says so.
|
|
51
|
+
**Quarantine.** Every fact carries a provenance. A write that came through the gateway is `attested`: its actor is the agent named in a human-signed grant and its receipt exists. A write that arrived any other way is `claimed`, and claimed facts are quarantined: `memory.read` leaves them out unless the caller asks for `includeClaimed`, and the policy can refuse that. `memory.confirm`, accepted only through the gateway, lifts a claimed fact to attested with its own receipt and transaction time, so "was this fact still in quarantine on Tuesday" is answerable. A tool result an SDK-only agent wrote down cannot become something the rest of the fleet believes until an attested party says so. Over stdio the server has one client; shared over HTTP, below, gateways and direct writers feed one ledger and quarantine does its job.
|
|
52
|
+
|
|
53
|
+
**Policy over the fact being changed.** The gateway can look up the fact a write supersedes or a retraction targets before deciding, through its fact-lookup mechanism and the `memory.get` tool, and the policy then sees that fact's space, actor, and provenance as observed facts. This is the second half of trust tiers: a self-reported note in the org space can be superseded by anyone the grant allows, while an attested org fact cannot be displaced or retracted except by whoever the policy names. In the gateway config:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
"facts": [
|
|
57
|
+
{ "name": "target", "tool": "memory.get", "args": { "factId": "$args.supersedes" }, "forTools": ["memory.write"], "optional": true },
|
|
58
|
+
{ "name": "target", "tool": "memory.get", "args": { "factId": "$args.factId" }, "forTools": ["memory.retract"] }
|
|
59
|
+
]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```cedar
|
|
63
|
+
forbid(principal, action in [Action::"memory.write", Action::"memory.retract"], resource)
|
|
64
|
+
when { context.facts has target && context.facts.target.space == "org" && context.facts.target.provenance == "attested" };
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The lookup for writes is optional, so a write that supersedes nothing needs no lookup; the one for retractions is required. The denial receipt records the fact the policy saw, observed by the gateway. The test suite runs exactly this configuration.
|
|
68
|
+
|
|
69
|
+
**Attested executions.** Started with `--key memory.key`, the server signs every result for the receipt the gateway is issuing. A verifier given `memory.pub` then reports the execution of each memory call as attested by the memory server, not only observed by the gateway; the test suite verifies a write this way.
|
|
70
|
+
|
|
71
|
+
**Shared over HTTP.** `agent-custody-memory serve --ledger ./ledger.jsonl --http --port 8790 --token-env MEMORY_TOKEN` serves the same tools over Streamable HTTP, so several gateways, one per agent host, and, with `--allow-direct`, SDK-only agents share one ledger. That is the deployment quarantine was built for: writes arriving through a gateway are attested, writes arriving directly are claimed and hidden until a gateway confirms them, and the ledger tells them apart. A gateway reaches it with `"upstream": { "url": "http://127.0.0.1:8790/mcp", "tokenEnv": "MEMORY_TOKEN" }` in its config. Bind wider than loopback only behind the token. The test suite runs exactly this: one gateway writer, one direct writer, one ledger.
|
|
50
72
|
|
|
51
73
|
Trust tiers are Cedar policies over the space and, through `includeClaimed`, over quarantine: `permit(principal, action == Action::"memory.write", resource) when { context.args.space == "team:support" };` lets this agent write team memory and nothing else. A read's receipt carries, as `observed`, the exact facts returned, so the ids the agent relied on are already on the record.
|
|
52
74
|
|
|
53
75
|
`--allow-direct` lets the server take calls without a gateway; then `source.receiptId` is null and `actor` is whatever the caller said, recorded as such. [examples/03-memory-behind-the-gateway.ts](examples/03-memory-behind-the-gateway.ts) runs the whole loop, including a denied write and a retraction that cites its own receipt.
|
|
54
76
|
|
|
77
|
+
## Certified forget
|
|
78
|
+
|
|
79
|
+
A deletion demand is different from a correction. Retract keeps the record; forget erases the value. `memory.forget` removes the fact's value from the ledger file itself, replacing it with the value's digest so the ledger can still prove what it held without holding it, stops believing the fact, and removes it from every store behind the server. The result says exactly what happened: erased from the ledger, removed from which stores, still held by which, with the digest, the actor, and the reason.
|
|
80
|
+
|
|
81
|
+
The certificate is the receipt. Through the gateway, `memory.forget` is a receipted call whose result the gateway observed, so the signed, logged receipt records that the erasure happened, who asked for it, and what the stores answered. Hand that receipt to whoever demanded the deletion; anyone with the gateway's public key can verify it.
|
|
82
|
+
|
|
83
|
+
What forget does not reach, and the docs will not pretend otherwise: receipts. The receipt that recorded the original write carries the value in its request arguments, and the receipts of reads carry it in their results; they are signed and in a Merkle log, so they cannot be edited. Erasure from the receipt log is a retention policy on the log, and selective redaction of receipts is on the receipts roadmap.
|
|
84
|
+
|
|
85
|
+
## Blast radius
|
|
86
|
+
|
|
87
|
+
When a belief turns out wrong, the next question is what relied on it. Two records answer it together. Every gateway receipt carries `consumed`: the fact ids the agent had been shown, through the gateway, before that call, which the memory server declares on each read. Every fact in the ledger carries the receipt that wrote it. Walking forward from a fact through those two links gives every later call and every belief written in those calls, transitively, and whether the root was retracted and which derived beliefs are still believed.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
agent-custody-memory blast --ledger ./ledger.jsonl --receipts ./receipts --fact <factId>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
fact 3f2a…: acct:42 plan = "enterprise" (space team:support, by support-agent, attested)
|
|
95
|
+
retracted at 2026-09-07T10:12:04.118Z by support-agent: CRM sync bug: account is on the free plan
|
|
96
|
+
3 call(s) made after the agent was shown it:
|
|
97
|
+
2026-09-07T10:12:03.902Z memory.write executed receipt 7c1e…
|
|
98
|
+
...
|
|
99
|
+
2 belief(s) written in those calls, 2 still believed:
|
|
100
|
+
acct:42 discount = "20%" fact 9b04… STILL BELIEVED
|
|
101
|
+
acct:42 support_tier = "priority" fact e77d… STILL BELIEVED
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
It is an upper bound by design: a call made after the agent had seen the fact is in the radius whether or not the agent used it, because no receipt can prove what a model attended to. What it never misses is the thing that matters, a downstream action or belief that did depend on the fact. [examples/05-blast-radius.ts](examples/05-blast-radius.ts) runs the whole loop.
|
|
105
|
+
|
|
55
106
|
## Write-through to the stores you already use
|
|
56
107
|
|
|
57
108
|
The ledger is not a retrieval store, and it does not try to be. `src/stores.ts` puts it under the ones teams already run: a fact written through the memory server also lands in every configured store, with its custody metadata (fact id, space, actor, provenance, receipt id), the store's own id is recorded on the fact, and a retraction reaches the store by that id. Certified forget will be built on this: a deletion is only real once it has reached the stores that serve recall.
|
|
@@ -102,7 +153,9 @@ The ledger refuses to supersede a fact that is unknown, already superseded, or r
|
|
|
102
153
|
```
|
|
103
154
|
src/ledger.ts the fact record, the two event kinds, as-of queries, supersession, retraction, JSONL persistence
|
|
104
155
|
src/server.ts the ledger as MCP tools; source and actor taken from the gateway's _meta
|
|
105
|
-
src/
|
|
156
|
+
src/http.ts the memory server over Streamable HTTP with bearer auth, for a shared ledger
|
|
157
|
+
src/cli.ts agent-custody-memory serve (stdio or --http), blast
|
|
158
|
+
src/blast.ts blast radius: from receipts' consumed facts and the ledger's source receipts, forward
|
|
106
159
|
src/stores.ts write-through adapters: Mem0 and Zep, and the Store interface for others
|
|
107
160
|
src/evals.ts the memory-mutation harness: scenarios, scoring, report
|
|
108
161
|
src/evals-ledger.ts the ledger and a naive overwrite store behind the harness interface
|
|
@@ -118,13 +171,19 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
|
|
|
118
171
|
|
|
119
172
|
- Bitemporal fact ledger with supersession, retraction, as-of and history queries, persisted as JSONL.
|
|
120
173
|
- The memory server: the ledger as MCP tools behind the receipts gateway, with the source receipt id and the attested actor supplied by the gateway, policy over spaces, and a denial receipt for every refused write.
|
|
174
|
+
- Attested executions: with a key, the memory server signs its results for the gateway's receipt, so a verifier holding its public key sees memory calls as attested.
|
|
175
|
+
- The memory server over HTTP: one ledger shared by several gateways and direct writers, bearer-token auth, quarantine live.
|
|
176
|
+
- Certified forget: `memory.forget` erases a value from the ledger file keeping its digest, stops believing it, removes it from every store, and reports exactly what happened; the gateway's receipt of that call is the certificate.
|
|
177
|
+
- Policy over provenance: the gateway looks up the fact a write supersedes or a retraction targets, so policy decides on its space, actor, and provenance; a claimed fact can be displaced, an attested org fact cannot.
|
|
178
|
+
- Consumed facts and blast radius: the memory server declares the facts it serves, the gateway records them on every later receipt, and `blast` walks from a fact to every downstream call and derived belief, transitively, with its retraction status.
|
|
121
179
|
- Write-through adapters for Mem0 and Zep: every write lands in the store with custody metadata, the store id is recorded on the fact, retractions reach the store, and failures are ordered so nothing is half-recorded.
|
|
122
180
|
- The memory-mutation eval harness: stale reads, contradictions, blast radius, and correct reads over scripted incidents, scored the same way for the ledger and for anything behind the same interface.
|
|
123
181
|
- Quarantine: facts carry `attested` or `claimed` provenance; claimed facts are hidden from reads by default and a gateway-only `memory.confirm` lifts them, as a recorded event.
|
|
124
182
|
|
|
125
183
|
**Next, in the order it pays off**
|
|
126
184
|
|
|
127
|
-
1.
|
|
128
|
-
2.
|
|
129
|
-
3.
|
|
130
|
-
4.
|
|
185
|
+
1. Value-level quarantine: a value that came from untrusted tool output stays quarantined even when the actor is attested, until a second source or a human agrees.
|
|
186
|
+
2. Retention windows and legal hold on the ledger: forget on a schedule, and a hold that refuses forget for named facts until lifted.
|
|
187
|
+
3. A gateway with several upstreams under one grant, so memory and the tools an agent acts with share a session and blast radius reaches the emails sent, not only the beliefs written.
|
|
188
|
+
4. The memory tools from Python, through the sidecar, so SDK-only Python agents can write claimed facts to a shared ledger.
|
|
189
|
+
|
package/dist/blast.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Fact, Ledger, RetractEvent } from "./ledger.ts";
|
|
2
|
+
/** The parts of a receipt this query needs. Decoded from a bundle's statement; nothing here is verified, so verify first. */
|
|
3
|
+
export interface ReceiptSummary {
|
|
4
|
+
receiptId: string;
|
|
5
|
+
timestamp: string;
|
|
6
|
+
tool: string;
|
|
7
|
+
status: string;
|
|
8
|
+
/** fact ids the agent had been shown before this call, from the receipt's consumed field */
|
|
9
|
+
consumed: string[];
|
|
10
|
+
}
|
|
11
|
+
/** Reads every bundle in a receipts directory. Order is by timestamp. */
|
|
12
|
+
export declare function loadReceipts(dir: string): ReceiptSummary[];
|
|
13
|
+
export interface BlastRadius {
|
|
14
|
+
fact: Fact | null;
|
|
15
|
+
/** every receipt for a call made after the agent had been shown this fact or one derived from it */
|
|
16
|
+
receipts: ReceiptSummary[];
|
|
17
|
+
/** every fact written in one of those calls, transitively */
|
|
18
|
+
derivedFacts: Fact[];
|
|
19
|
+
/** the retraction of the root fact, when it has been undone */
|
|
20
|
+
retraction: RetractEvent | null;
|
|
21
|
+
/** derived facts that are still believed; the ones a cleanup has to decide about */
|
|
22
|
+
stillBelieved: Fact[];
|
|
23
|
+
}
|
|
24
|
+
export declare function blastRadius(ledger: Ledger, receipts: ReceiptSummary[], factId: string): BlastRadius;
|
|
25
|
+
export declare function formatBlastRadius(b: BlastRadius, factId: string): string;
|
package/dist/blast.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Blast radius: given a fact, everything that relied on it. The receipts say which facts the agent had been shown
|
|
2
|
+
// before each call; the ledger says which facts were written in which call. Walking both, forward, gives every
|
|
3
|
+
// downstream action and every derived belief, transitively, and the retraction that undoes the belief if there is one.
|
|
4
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
/** Reads every bundle in a receipts directory. Order is by timestamp. */
|
|
7
|
+
export function loadReceipts(dir) {
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const f of readdirSync(dir)) {
|
|
10
|
+
if (!f.endsWith(".json"))
|
|
11
|
+
continue;
|
|
12
|
+
const bundle = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
13
|
+
if (!bundle.envelope?.payload)
|
|
14
|
+
continue;
|
|
15
|
+
const st = JSON.parse(Buffer.from(bundle.envelope.payload, "base64").toString());
|
|
16
|
+
const p = st.predicate;
|
|
17
|
+
if (!p?.receiptId)
|
|
18
|
+
continue;
|
|
19
|
+
out.push({ receiptId: p.receiptId, timestamp: p.timestamp, tool: p.tool?.name ?? "?", status: p.execution?.status ?? "?", consumed: Array.isArray(p.consumed?.factIds) ? p.consumed.factIds : [] });
|
|
20
|
+
}
|
|
21
|
+
return out.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
22
|
+
}
|
|
23
|
+
export function blastRadius(ledger, receipts, factId) {
|
|
24
|
+
const all = ledger.facts();
|
|
25
|
+
const byId = new Map(all.map((f) => [f.factId, f]));
|
|
26
|
+
const believed = new Set(ledger.asOf().map((f) => f.factId));
|
|
27
|
+
const seen = new Set([factId]);
|
|
28
|
+
const hit = new Map();
|
|
29
|
+
const derived = new Map();
|
|
30
|
+
let grew = true;
|
|
31
|
+
while (grew) {
|
|
32
|
+
grew = false;
|
|
33
|
+
for (const r of receipts) {
|
|
34
|
+
if (hit.has(r.receiptId) || !r.consumed.some((id) => seen.has(id)))
|
|
35
|
+
continue;
|
|
36
|
+
hit.set(r.receiptId, r);
|
|
37
|
+
grew = true;
|
|
38
|
+
}
|
|
39
|
+
for (const f of all) {
|
|
40
|
+
if (f.factId === factId || derived.has(f.factId) || !f.source.receiptId || !hit.has(f.source.receiptId))
|
|
41
|
+
continue;
|
|
42
|
+
derived.set(f.factId, f);
|
|
43
|
+
seen.add(f.factId);
|
|
44
|
+
grew = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const retraction = ledger.history(factId).find((e) => e.kind === "retract") ?? null;
|
|
48
|
+
const derivedFacts = [...derived.values()];
|
|
49
|
+
return { fact: byId.get(factId) ?? null, receipts: [...hit.values()].sort((a, b) => a.timestamp.localeCompare(b.timestamp)), derivedFacts, retraction, stillBelieved: derivedFacts.filter((f) => believed.has(f.factId)) };
|
|
50
|
+
}
|
|
51
|
+
export function formatBlastRadius(b, factId) {
|
|
52
|
+
const lines = [];
|
|
53
|
+
lines.push(b.fact ? `fact ${factId}: ${b.fact.subject} ${b.fact.predicate} = ${JSON.stringify(b.fact.value)} (space ${b.fact.space}, by ${b.fact.actor}, ${b.fact.provenance})` : `fact ${factId}: not in this ledger`);
|
|
54
|
+
lines.push(b.retraction ? `retracted at ${b.retraction.txTime} by ${b.retraction.actor}: ${b.retraction.reason}` : "not retracted");
|
|
55
|
+
lines.push(`${b.receipts.length} call(s) made after the agent was shown it:`);
|
|
56
|
+
for (const r of b.receipts)
|
|
57
|
+
lines.push(` ${r.timestamp} ${r.tool.padEnd(18)} ${r.status.padEnd(9)} receipt ${r.receiptId.slice(0, 8)}`);
|
|
58
|
+
lines.push(`${b.derivedFacts.length} belief(s) written in those calls, ${b.stillBelieved.length} still believed:`);
|
|
59
|
+
for (const f of b.derivedFacts)
|
|
60
|
+
lines.push(` ${f.subject} ${f.predicate} = ${JSON.stringify(f.value)} fact ${f.factId.slice(0, 8)} ${b.stillBelieved.includes(f) ? "STILL BELIEVED" : "no longer believed"}`);
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -2,23 +2,51 @@
|
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { Ledger } from "./ledger.js";
|
|
4
4
|
import { createMemoryServer, serveStdio } from "./server.js";
|
|
5
|
+
import { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
|
|
6
|
+
import { serveMemoryHttp } from "./http.js";
|
|
7
|
+
import { loadPrivateKey } from "@agent-custody/receipts";
|
|
5
8
|
const USAGE = `agent-custody-memory <command>
|
|
6
9
|
|
|
7
|
-
serve --ledger <ledger.jsonl> [--allow-direct]
|
|
10
|
+
serve --ledger <ledger.jsonl> [--allow-direct] [--key <memory.key>]
|
|
11
|
+
the memory server over stdio; run it as the receipts gateway's upstream.
|
|
12
|
+
--key signs every result for its receipt, so executions verify as attested by this server.
|
|
8
13
|
By default it refuses calls that did not come through the gateway.
|
|
14
|
+
serve --ledger <ledger.jsonl> --http [--port 8790] [--host 127.0.0.1] [--token-env NAME] [--allow-direct]
|
|
15
|
+
the same server shared over HTTP: several gateways, one ledger
|
|
16
|
+
blast --ledger <ledger.jsonl> --receipts <dir> --fact <factId> [--json]
|
|
17
|
+
everything that relied on a fact: later calls, derived beliefs, and whether it was retracted
|
|
9
18
|
`;
|
|
10
19
|
async function main(argv) {
|
|
11
20
|
const [cmd, ...rest] = argv;
|
|
12
21
|
switch (cmd) {
|
|
13
22
|
case "serve": {
|
|
14
|
-
const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, "allow-direct": { type: "boolean", default: false } } });
|
|
23
|
+
const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, "allow-direct": { type: "boolean", default: false }, http: { type: "boolean", default: false }, port: { type: "string", default: "8790" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, key: { type: "string" } } });
|
|
15
24
|
if (!values.ledger)
|
|
16
25
|
throw new Error("serve needs --ledger");
|
|
17
26
|
const ledger = new Ledger(values.ledger);
|
|
27
|
+
const identity = values.key ? loadPrivateKey(values.key) : undefined;
|
|
28
|
+
if (values.http) {
|
|
29
|
+
const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
|
|
30
|
+
if (values["token-env"] && !token)
|
|
31
|
+
throw new Error(`serve: environment variable ${values["token-env"]} is not set`);
|
|
32
|
+
const running = await serveMemoryHttp(ledger, { port: Number(values.port), host: values.host, requireGateway: !values["allow-direct"], ...(token ? { tokens: [token] } : {}), ...(identity ? { identity } : {}) });
|
|
33
|
+
console.error(`agent-custody-memory: ${running.url} ledger=${values.ledger} events=${ledger.size} ${token ? "bearer token required" : "open"} ${values["allow-direct"] ? "direct calls allowed" : "gateway calls only"}`);
|
|
34
|
+
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
35
|
+
await running.close();
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
18
38
|
console.error(`agent-custody-memory: ledger=${values.ledger} events=${ledger.size} ${values["allow-direct"] ? "direct calls allowed" : "gateway calls only"}`);
|
|
19
|
-
await serveStdio(createMemoryServer(ledger, { requireGateway: !values["allow-direct"] }));
|
|
39
|
+
await serveStdio(createMemoryServer(ledger, { requireGateway: !values["allow-direct"], ...(identity ? { identity } : {}) }));
|
|
20
40
|
return 0;
|
|
21
41
|
}
|
|
42
|
+
case "blast": {
|
|
43
|
+
const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, receipts: { type: "string" }, fact: { type: "string" }, json: { type: "boolean", default: false } } });
|
|
44
|
+
if (!values.ledger || !values.receipts || !values.fact)
|
|
45
|
+
throw new Error("blast needs --ledger, --receipts, and --fact");
|
|
46
|
+
const b = blastRadius(new Ledger(values.ledger), loadReceipts(values.receipts), values.fact);
|
|
47
|
+
console.log(values.json ? JSON.stringify(b, null, 2) : formatBlastRadius(b, values.fact));
|
|
48
|
+
return b.fact ? 0 : 1;
|
|
49
|
+
}
|
|
22
50
|
default:
|
|
23
51
|
console.error(USAGE);
|
|
24
52
|
return cmd === undefined || cmd === "--help" || cmd === "-h" ? 0 : 2;
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import type { Ledger } from "./ledger.ts";
|
|
3
|
+
import { type MemoryServerOptions } from "./server.ts";
|
|
4
|
+
export interface MemoryHttpOptions extends MemoryServerOptions {
|
|
5
|
+
port: number;
|
|
6
|
+
host?: string;
|
|
7
|
+
/** bearer tokens accepted; when empty, anyone who can reach the port may call */
|
|
8
|
+
tokens?: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface RunningMemoryServer {
|
|
11
|
+
url: string;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare function memoryHttpHandler(ledger: Ledger, opts: MemoryHttpOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
15
|
+
/** Starts the memory server over HTTP. Port 0 picks a free port. Host defaults to loopback; bind wider only behind auth. */
|
|
16
|
+
export declare function serveMemoryHttp(ledger: Ledger, opts: MemoryHttpOptions): Promise<RunningMemoryServer>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The memory server over HTTP: one ledger shared by several gateways and, if allowed, direct writers. This is what
|
|
2
|
+
// makes quarantine a live deployment: attested writes arrive through gateways, self-reported ones directly, and the
|
|
3
|
+
// ledger tells them apart. Stateless Streamable HTTP, one MCP server instance per request over the shared ledger.
|
|
4
|
+
import { timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
|
+
import { createMemoryServer } from "./server.js";
|
|
8
|
+
export function memoryHttpHandler(ledger, opts) {
|
|
9
|
+
const tokens = opts.tokens ?? [];
|
|
10
|
+
const authorized = (req) => {
|
|
11
|
+
if (tokens.length === 0)
|
|
12
|
+
return true;
|
|
13
|
+
const h = req.headers.authorization ?? "";
|
|
14
|
+
const given = Buffer.from(h.startsWith("Bearer ") ? h.slice(7) : "");
|
|
15
|
+
return tokens.some((t) => Buffer.from(t).length === given.length && timingSafeEqual(Buffer.from(t), given));
|
|
16
|
+
};
|
|
17
|
+
return async (req, res) => {
|
|
18
|
+
if (!authorized(req)) {
|
|
19
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
20
|
+
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
24
|
+
const server = createMemoryServer(ledger, opts);
|
|
25
|
+
res.on("close", () => {
|
|
26
|
+
void transport.close();
|
|
27
|
+
void server.close();
|
|
28
|
+
});
|
|
29
|
+
await server.connect(transport);
|
|
30
|
+
await transport.handleRequest(req, res);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Starts the memory server over HTTP. Port 0 picks a free port. Host defaults to loopback; bind wider only behind auth. */
|
|
34
|
+
export function serveMemoryHttp(ledger, opts) {
|
|
35
|
+
const host = opts.host ?? "127.0.0.1";
|
|
36
|
+
const handler = memoryHttpHandler(ledger, opts);
|
|
37
|
+
const server = createServer((req, res) => {
|
|
38
|
+
void handler(req, res);
|
|
39
|
+
});
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
server.listen(opts.port, host, () => {
|
|
42
|
+
const { port } = server.address();
|
|
43
|
+
resolve({ url: `http://${host}:${port}/mcp`, close: () => new Promise((r) => server.close(() => r())) });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export { Ledger } from "./ledger.ts";
|
|
2
|
-
export type { AsOf, AssertEvent, AssertInput, ConfirmEvent, ConfirmInput, Fact, FactProvenance, LedgerEvent, RetractEvent, RetractInput, Source } from "./ledger.ts";
|
|
3
|
-
export { AGENT_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, serveStdio } from "./server.ts";
|
|
2
|
+
export type { AsOf, AssertEvent, AssertInput, ConfirmEvent, ConfirmInput, Fact, FactProvenance, ForgetEvent, ForgetInput, LedgerEvent, RetractEvent, RetractInput, Source } from "./ledger.ts";
|
|
3
|
+
export { AGENT_META_KEY, FACTS_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, serveStdio } from "./server.ts";
|
|
4
4
|
export type { MemoryServerOptions } from "./server.ts";
|
|
5
5
|
export { SCENARIOS, formatReport, runAll, runScenario } from "./evals.ts";
|
|
6
6
|
export type { MemoryUnderTest, Op, Report, Scenario, ScenarioScore } from "./evals.ts";
|
|
7
7
|
export { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.ts";
|
|
8
8
|
export { factMetadata, factText, mem0Store, zepStore } from "./stores.ts";
|
|
9
|
+
export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.ts";
|
|
10
|
+
export { memoryHttpHandler, serveMemoryHttp } from "./http.ts";
|
|
11
|
+
export type { MemoryHttpOptions, RunningMemoryServer } from "./http.ts";
|
|
12
|
+
export type { BlastRadius, ReceiptSummary } from "./blast.ts";
|
|
9
13
|
export type { Mem0Like, Mem0Options, Store, ZepLike, ZepOptions } from "./stores.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Public surface of @agent-custody/state.
|
|
2
2
|
export { Ledger } from "./ledger.js";
|
|
3
|
-
export { AGENT_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, serveStdio } from "./server.js";
|
|
3
|
+
export { AGENT_META_KEY, FACTS_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, serveStdio } from "./server.js";
|
|
4
4
|
export { SCENARIOS, formatReport, runAll, runScenario } from "./evals.js";
|
|
5
5
|
export { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.js";
|
|
6
6
|
export { factMetadata, factText, mem0Store, zepStore } from "./stores.js";
|
|
7
|
+
export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
|
|
8
|
+
export { memoryHttpHandler, serveMemoryHttp } from "./http.js";
|
package/dist/ledger.d.ts
CHANGED
|
@@ -19,6 +19,11 @@ export interface Fact {
|
|
|
19
19
|
actor: string;
|
|
20
20
|
source: Source;
|
|
21
21
|
provenance: FactProvenance;
|
|
22
|
+
/** present once the value has been erased: the value field is null and this is the digest of what it was */
|
|
23
|
+
forgotten?: {
|
|
24
|
+
valueDigest: string;
|
|
25
|
+
at: string;
|
|
26
|
+
};
|
|
22
27
|
/** ids of this fact in the retrieval stores it was written through to, by store name; absent when there are none */
|
|
23
28
|
external?: Record<string, string>;
|
|
24
29
|
/** ISO timestamps. validTo is null while the fact is believed to still hold. */
|
|
@@ -53,7 +58,22 @@ export interface ConfirmEvent {
|
|
|
53
58
|
actor: string;
|
|
54
59
|
source: Source;
|
|
55
60
|
}
|
|
56
|
-
|
|
61
|
+
/**
|
|
62
|
+
* A forget is erasure, not correction. The fact's value is removed from the ledger file itself and replaced by its
|
|
63
|
+
* digest, so the ledger can still prove which value it held without holding it. The fact stops being believed.
|
|
64
|
+
*/
|
|
65
|
+
export interface ForgetEvent {
|
|
66
|
+
eventId: string;
|
|
67
|
+
kind: "forget";
|
|
68
|
+
txTime: string;
|
|
69
|
+
factId: string;
|
|
70
|
+
actor: string;
|
|
71
|
+
reason: string;
|
|
72
|
+
source: Source;
|
|
73
|
+
/** sha256 of the canonical JSON of the erased value */
|
|
74
|
+
valueDigest: string;
|
|
75
|
+
}
|
|
76
|
+
export type LedgerEvent = AssertEvent | RetractEvent | ConfirmEvent | ForgetEvent;
|
|
57
77
|
export interface AssertInput {
|
|
58
78
|
subject: string;
|
|
59
79
|
predicate: string;
|
|
@@ -73,6 +93,12 @@ export interface ConfirmInput {
|
|
|
73
93
|
actor: string;
|
|
74
94
|
source?: Source;
|
|
75
95
|
}
|
|
96
|
+
export interface ForgetInput {
|
|
97
|
+
factId: string;
|
|
98
|
+
actor: string;
|
|
99
|
+
reason: string;
|
|
100
|
+
source?: Source;
|
|
101
|
+
}
|
|
76
102
|
export interface RetractInput {
|
|
77
103
|
factId: string;
|
|
78
104
|
actor: string;
|
|
@@ -103,8 +129,16 @@ export declare class Ledger {
|
|
|
103
129
|
assert(input: AssertInput): AssertEvent;
|
|
104
130
|
retract(input: RetractInput): RetractEvent;
|
|
105
131
|
confirm(input: ConfirmInput): ConfirmEvent;
|
|
132
|
+
/**
|
|
133
|
+
* Erases a fact's value from the ledger file, keeping its digest, and stops believing it. The file is rewritten in
|
|
134
|
+
* place, which is the one thing an append-only ledger must do for a deletion demand. Everything else about the fact
|
|
135
|
+
* stays: who wrote it, when, from which receipt, and now who erased it and why.
|
|
136
|
+
*/
|
|
137
|
+
forget(input: ForgetInput): ForgetEvent;
|
|
106
138
|
/** The facts believed at a moment. Valid time answers "was it true then"; transaction time answers "did the ledger know it then". */
|
|
107
139
|
asOf(q?: AsOf): Fact[];
|
|
140
|
+
/** Every fact ever asserted, with supersession applied and retracted ones included, for audits that must see everything. */
|
|
141
|
+
facts(): Fact[];
|
|
108
142
|
/** Every event that touched a fact, oldest first: its assert, the assert that superseded it, its confirmation, its retraction. */
|
|
109
143
|
history(factId: string): LedgerEvent[];
|
|
110
144
|
private confirmedAt;
|
package/dist/ledger.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Bitemporal. Valid time is when a fact was true in the world; transaction time is when the ledger learned of it.
|
|
3
3
|
// Nothing is ever edited in place. Correcting a belief is a new event, so "what did the agent believe at T" is always answerable.
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import {
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { dirname } from "node:path";
|
|
7
8
|
export class Ledger {
|
|
8
9
|
events = [];
|
|
@@ -95,12 +96,38 @@ export class Ledger {
|
|
|
95
96
|
this.append(event);
|
|
96
97
|
return event;
|
|
97
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Erases a fact's value from the ledger file, keeping its digest, and stops believing it. The file is rewritten in
|
|
101
|
+
* place, which is the one thing an append-only ledger must do for a deletion demand. Everything else about the fact
|
|
102
|
+
* stays: who wrote it, when, from which receipt, and now who erased it and why.
|
|
103
|
+
*/
|
|
104
|
+
forget(input) {
|
|
105
|
+
const prior = this.factById(input.factId);
|
|
106
|
+
if (!prior)
|
|
107
|
+
throw new Error(`cannot forget unknown fact ${input.factId}`);
|
|
108
|
+
if (prior.fact.forgotten)
|
|
109
|
+
throw new Error(`fact ${input.factId} is already forgotten`);
|
|
110
|
+
const txTime = this.now().toISOString();
|
|
111
|
+
const valueDigest = createHash("sha256").update(canonical(prior.fact.value)).digest("hex");
|
|
112
|
+
for (const e of this.events) {
|
|
113
|
+
if (e.kind === "assert" && e.fact.factId === input.factId) {
|
|
114
|
+
e.fact.value = null;
|
|
115
|
+
e.fact.forgotten = { valueDigest, at: txTime };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const event = { eventId: randomUUID(), kind: "forget", txTime, factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null }, valueDigest };
|
|
119
|
+
this.events.push(event);
|
|
120
|
+
const tmp = `${this.file}.tmp`;
|
|
121
|
+
writeFileSync(tmp, this.events.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
122
|
+
renameSync(tmp, this.file);
|
|
123
|
+
return event;
|
|
124
|
+
}
|
|
98
125
|
/** The facts believed at a moment. Valid time answers "was it true then"; transaction time answers "did the ledger know it then". */
|
|
99
126
|
asOf(q = {}) {
|
|
100
127
|
const validAt = q.validAt ?? this.now().toISOString();
|
|
101
128
|
const txAt = q.txAt ?? this.now().toISOString();
|
|
102
129
|
const known = this.events.filter((e) => e.txTime <= txAt);
|
|
103
|
-
const retracted = new Set(known.filter((e) => e.kind === "retract").map((e) => e.factId));
|
|
130
|
+
const retracted = new Set(known.filter((e) => e.kind === "retract" || e.kind === "forget").map((e) => e.factId));
|
|
104
131
|
const confirmed = new Set(known.filter((e) => e.kind === "confirm").map((e) => e.factId));
|
|
105
132
|
const facts = new Map();
|
|
106
133
|
for (const e of known) {
|
|
@@ -119,6 +146,18 @@ export class Ledger {
|
|
|
119
146
|
(q.predicate === undefined || f.predicate === q.predicate) &&
|
|
120
147
|
(q.include !== "attested" || f.provenance === "attested"));
|
|
121
148
|
}
|
|
149
|
+
/** Every fact ever asserted, with supersession applied and retracted ones included, for audits that must see everything. */
|
|
150
|
+
facts() {
|
|
151
|
+
const out = new Map();
|
|
152
|
+
for (const e of this.events) {
|
|
153
|
+
if (e.kind !== "assert")
|
|
154
|
+
continue;
|
|
155
|
+
out.set(e.fact.factId, { ...e.fact });
|
|
156
|
+
if (e.supersedes && out.has(e.supersedes) && !this.retractedAt(e.fact.factId))
|
|
157
|
+
out.get(e.supersedes).validTo = e.fact.validFrom;
|
|
158
|
+
}
|
|
159
|
+
return [...out.values()];
|
|
160
|
+
}
|
|
122
161
|
/** Every event that touched a fact, oldest first: its assert, the assert that superseded it, its confirmation, its retraction. */
|
|
123
162
|
history(factId) {
|
|
124
163
|
return this.events.filter((e) => (e.kind === "assert" ? e.fact.factId === factId || e.supersedes === factId : e.factId === factId));
|
|
@@ -137,10 +176,28 @@ export class Ledger {
|
|
|
137
176
|
return found;
|
|
138
177
|
}
|
|
139
178
|
retractedAt(factId) {
|
|
140
|
-
return this.events.some((e) => e.kind === "retract" && e.factId === factId);
|
|
179
|
+
return this.events.some((e) => (e.kind === "retract" || e.kind === "forget") && e.factId === factId);
|
|
141
180
|
}
|
|
142
181
|
append(event) {
|
|
143
182
|
appendFileSync(this.file, JSON.stringify(event) + "\n");
|
|
144
183
|
this.events.push(event);
|
|
145
184
|
}
|
|
146
185
|
}
|
|
186
|
+
/** Canonical JSON: sorted keys, no whitespace, undefined dropped. The same encoding the receipts package uses. */
|
|
187
|
+
function canonical(value) {
|
|
188
|
+
const sort = (v) => {
|
|
189
|
+
if (Array.isArray(v))
|
|
190
|
+
return v.map(sort);
|
|
191
|
+
if (v && typeof v === "object") {
|
|
192
|
+
const o = {};
|
|
193
|
+
for (const k of Object.keys(v).sort()) {
|
|
194
|
+
const x = v[k];
|
|
195
|
+
if (x !== undefined)
|
|
196
|
+
o[k] = sort(x);
|
|
197
|
+
}
|
|
198
|
+
return o;
|
|
199
|
+
}
|
|
200
|
+
return v;
|
|
201
|
+
};
|
|
202
|
+
return JSON.stringify(sort(value));
|
|
203
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
2
|
import { type Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { type KeyPair } from "@agent-custody/receipts";
|
|
3
4
|
import type { Ledger } from "./ledger.ts";
|
|
4
5
|
import type { Store } from "./stores.ts";
|
|
5
6
|
export declare const SERVER_VERSION = "0.1.0";
|
|
6
7
|
/** The same keys the receipts gateway sets on the upstream call. Duplicated here so this package needs no runtime import from receipts. */
|
|
7
8
|
export declare const RECEIPT_META_KEY = "agent-custody/receipt";
|
|
8
9
|
export declare const AGENT_META_KEY = "agent-custody/agent";
|
|
10
|
+
/** Set on read results: the ids of the facts served, so the gateway can record what the agent was shown. */
|
|
11
|
+
export declare const FACTS_META_KEY = "agent-custody/facts";
|
|
9
12
|
export declare const TOOLS: Tool[];
|
|
10
13
|
export interface MemoryServerOptions {
|
|
11
14
|
/** Refuse calls that did not come through the gateway, i.e. carry no receipt id. On by default when served from the CLI. */
|
|
12
15
|
requireGateway?: boolean;
|
|
13
16
|
/** Retrieval stores every write goes through to and every retraction reaches. A store that refuses a write fails the write; nothing is recorded. */
|
|
14
17
|
stores?: Store[];
|
|
18
|
+
/** With a key, every result to a gateway call is signed for that receipt, so a verifier holding the public key sees the execution as attested by this server. */
|
|
19
|
+
identity?: KeyPair;
|
|
15
20
|
}
|
|
16
21
|
export declare function createMemoryServer(ledger: Ledger, opts?: MemoryServerOptions): Server;
|
|
17
22
|
/** Serves over stdio, the way the gateway spawns it. Diagnostics must go to stderr. */
|
package/dist/server.js
CHANGED
|
@@ -7,10 +7,13 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
7
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
9
9
|
import { z } from "zod";
|
|
10
|
+
import { signResult } from "@agent-custody/receipts";
|
|
10
11
|
export const SERVER_VERSION = "0.1.0";
|
|
11
12
|
/** The same keys the receipts gateway sets on the upstream call. Duplicated here so this package needs no runtime import from receipts. */
|
|
12
13
|
export const RECEIPT_META_KEY = "agent-custody/receipt";
|
|
13
14
|
export const AGENT_META_KEY = "agent-custody/agent";
|
|
15
|
+
/** Set on read results: the ids of the facts served, so the gateway can record what the agent was shown. */
|
|
16
|
+
export const FACTS_META_KEY = "agent-custody/facts";
|
|
14
17
|
const iso = z.string().datetime({ offset: true });
|
|
15
18
|
const Write = z.object({
|
|
16
19
|
subject: z.string().min(1),
|
|
@@ -27,6 +30,8 @@ const Read = z.object({ subject: z.string().min(1).optional(), predicate: z.stri
|
|
|
27
30
|
const Confirm = z.object({ factId: z.string().min(1) });
|
|
28
31
|
const Retract = z.object({ factId: z.string().min(1), reason: z.string().min(1), actor: z.string().min(1).optional() });
|
|
29
32
|
const History = z.object({ factId: z.string().min(1) });
|
|
33
|
+
const Get = z.object({ factId: z.string().min(1) });
|
|
34
|
+
const Forget = z.object({ factId: z.string().min(1), reason: z.string().min(1) });
|
|
30
35
|
const str = { type: "string" };
|
|
31
36
|
export const TOOLS = [
|
|
32
37
|
{
|
|
@@ -49,6 +54,16 @@ export const TOOLS = [
|
|
|
49
54
|
description: "Undo a belief: the fact leaves the present, stays visible to questions about the past, and whatever it superseded is believed again.",
|
|
50
55
|
inputSchema: { type: "object", properties: { factId: str, reason: str, actor: str }, required: ["factId", "reason"] },
|
|
51
56
|
},
|
|
57
|
+
{
|
|
58
|
+
name: "memory.forget",
|
|
59
|
+
description: "Erase a fact's value: from the ledger file, keeping only its digest, and from every store behind the server. The fact stops being believed. The receipt for this call, with the result the gateway observed, is the certificate that the erasure happened.",
|
|
60
|
+
inputSchema: { type: "object", properties: { factId: str, reason: str }, required: ["factId", "reason"] },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "memory.get",
|
|
64
|
+
description: "One fact by id, whatever its state: its space, actor, provenance, source receipt, validity. Meant for the gateway's fact lookups, so policy can decide on the fact a write supersedes or a retraction targets.",
|
|
65
|
+
inputSchema: { type: "object", properties: { factId: str }, required: ["factId"] },
|
|
66
|
+
},
|
|
52
67
|
{
|
|
53
68
|
name: "memory.history",
|
|
54
69
|
description: "Every event that touched a fact, oldest first.",
|
|
@@ -63,15 +78,18 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
63
78
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
64
79
|
const meta = (req.params._meta ?? {});
|
|
65
80
|
const receiptId = typeof meta[RECEIPT_META_KEY] === "string" ? meta[RECEIPT_META_KEY] : null;
|
|
81
|
+
const result = await handle(req.params.name, req.params.arguments ?? {}, meta, receiptId);
|
|
82
|
+
return opts.identity && receiptId ? signResult(result, opts.identity, receiptId, req.params.name) : result;
|
|
83
|
+
});
|
|
84
|
+
async function handle(name, args, meta, receiptId) {
|
|
66
85
|
const gatewayAgent = typeof meta[AGENT_META_KEY] === "string" ? meta[AGENT_META_KEY] : null;
|
|
67
86
|
if (opts.requireGateway && !receiptId)
|
|
68
87
|
return fail("memory server accepts calls only through the receipts gateway; no receipt id on this call");
|
|
69
|
-
const args = req.params.arguments ?? {};
|
|
70
88
|
const actorFor = (claimed) => gatewayAgent ?? claimed ?? "anonymous";
|
|
71
89
|
// A write is attested when it came through the gateway: the actor is from a signed grant and the receipt exists.
|
|
72
90
|
const provenance = receiptId && gatewayAgent ? "attested" : "claimed";
|
|
73
91
|
try {
|
|
74
|
-
switch (
|
|
92
|
+
switch (name) {
|
|
75
93
|
case "memory.write": {
|
|
76
94
|
const a = Write.parse(args);
|
|
77
95
|
const input = { subject: a.subject, predicate: a.predicate, value: a.value ?? null, space: a.space, actor: actorFor(a.actor), source: { receiptId }, provenance, ...(a.validFrom ? { validFrom: a.validFrom } : {}), ...(a.confidence !== undefined ? { confidence: a.confidence } : {}), ...(a.supersedes ? { supersedes: a.supersedes } : {}) };
|
|
@@ -87,7 +105,8 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
87
105
|
}
|
|
88
106
|
case "memory.read": {
|
|
89
107
|
const { includeClaimed, ...q } = Read.parse(args);
|
|
90
|
-
|
|
108
|
+
const facts = ledger.asOf({ ...q, include: includeClaimed ? "all" : "attested" });
|
|
109
|
+
return { ...json({ facts }), _meta: { [FACTS_META_KEY]: facts.map((f) => f.factId) } };
|
|
91
110
|
}
|
|
92
111
|
case "memory.retract": {
|
|
93
112
|
const a = Retract.parse(args);
|
|
@@ -119,16 +138,49 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
119
138
|
const ev = ledger.confirm({ factId: a.factId, actor: actorFor(undefined), source: { receiptId } });
|
|
120
139
|
return json({ eventId: ev.eventId, factId: ev.factId, txTime: ev.txTime, actor: ev.actor, source: ev.source });
|
|
121
140
|
}
|
|
141
|
+
case "memory.forget": {
|
|
142
|
+
const a = Forget.parse(args);
|
|
143
|
+
const fact = ledger.facts().find((f) => f.factId === a.factId);
|
|
144
|
+
const ev = ledger.forget({ factId: a.factId, actor: actorFor(undefined), reason: a.reason, source: { receiptId } });
|
|
145
|
+
const removedFrom = [];
|
|
146
|
+
const stillHeld = [];
|
|
147
|
+
for (const store of opts.stores ?? []) {
|
|
148
|
+
const id = fact?.external?.[store.name];
|
|
149
|
+
if (!id)
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
await store.remove(id, fact);
|
|
153
|
+
removedFrom.push(store.name);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
stillHeld.push(`${store.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const out = { factId: ev.factId, valueDigest: ev.valueDigest, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, erasedFromLedger: true, removedFrom, stillHeld };
|
|
160
|
+
if (stillHeld.length > 0)
|
|
161
|
+
return { isError: true, content: [{ type: "text", text: `erased from the ledger, but still held by ${stillHeld.join("; ")}` }, { type: "text", text: JSON.stringify(out) }] };
|
|
162
|
+
return json(out);
|
|
163
|
+
}
|
|
164
|
+
case "memory.get": {
|
|
165
|
+
const a = Get.parse(args);
|
|
166
|
+
const f = ledger.facts().find((x) => x.factId === a.factId);
|
|
167
|
+
if (!f)
|
|
168
|
+
return fail(`unknown fact ${a.factId}`);
|
|
169
|
+
const retracted = ledger.history(a.factId).some((e) => e.kind === "retract");
|
|
170
|
+
// Cedar has no null: absent fields stay absent, so a policy tests them with `has`.
|
|
171
|
+
const clean = Object.fromEntries(Object.entries({ ...f, source: f.source.receiptId ?? undefined, retracted }).filter(([, v]) => v !== null && v !== undefined));
|
|
172
|
+
return json(clean);
|
|
173
|
+
}
|
|
122
174
|
case "memory.history":
|
|
123
175
|
return json({ events: ledger.history(History.parse(args).factId) });
|
|
124
176
|
default:
|
|
125
|
-
return fail(`unknown tool ${
|
|
177
|
+
return fail(`unknown tool ${name}`);
|
|
126
178
|
}
|
|
127
179
|
}
|
|
128
180
|
catch (e) {
|
|
129
181
|
return fail(e instanceof z.ZodError ? `invalid arguments: ${e.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}` : String(e instanceof Error ? e.message : e));
|
|
130
182
|
}
|
|
131
|
-
}
|
|
183
|
+
}
|
|
132
184
|
return server;
|
|
133
185
|
}
|
|
134
186
|
/** Serves over stdio, the way the gateway spawns it. Diagnostics must go to stderr. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-custody/state",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Governed memory for AI agents: a fact ledger with provenance, valid time, rollback, and lineage, built on @agent-custody/receipts",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"node": ">=22"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@agent-custody/receipts": "0.1.
|
|
36
|
+
"@agent-custody/receipts": "0.1.5",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
38
|
"zod": "^4.5.4"
|
|
39
39
|
},
|