@agent-custody/state 0.1.6 → 0.1.8
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 +30 -9
- package/dist/cli.js +117 -13
- package/dist/evals-file.d.ts +53 -0
- package/dist/evals-file.js +43 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- package/dist/ledger.d.ts +28 -6
- package/dist/ledger.js +33 -25
- package/dist/server.d.ts +6 -0
- package/dist/server.js +37 -13
- package/dist/storage.d.ts +32 -0
- package/dist/storage.js +67 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -74,19 +74,19 @@ The lookup for writes is optional, so a write that supersedes nothing needs no l
|
|
|
74
74
|
|
|
75
75
|
**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.
|
|
76
76
|
|
|
77
|
-
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.
|
|
77
|
+
[Writing policies](../receipts/docs/policies.md#policies-for-memory) in the receipts guide has six tested policies for the memory tools, from confining an agent to its team's space to a complete support-agent policy. 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.
|
|
78
78
|
|
|
79
79
|
`--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.
|
|
80
80
|
|
|
81
81
|
## Certified forget
|
|
82
82
|
|
|
83
|
-
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,
|
|
83
|
+
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, stops believing the fact, and removes it from every store behind the server. What it keeps in place of the value is a choice, recorded on the event as `digestKind`: a plain `sha256` of the value, which lets the ledger prove what it erased but is guessable for short values such as an email by anyone holding the file; an `hmac-sha256` under a forget key the server holds outside the file (`serve --forget-key-env NAME`), which proves the same to anyone shown the key and nothing to anyone else; or `none`, with `keepDigest: false`, for the case where counsel wants nothing derived from the value retained. Use the keyed form unless you have a reason not to. 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.
|
|
84
84
|
|
|
85
|
-
**Retention and legal hold.** `memory.sweep` forgets every fact the ledger learned of before an instant, in one space or all, and removes each from every store; it is retention as a receipted call, with the receipt as the record of what was erased. `memory.hold` puts a legal hold on a fact: while it stands, neither a deletion request nor a sweep can forget it, and `memory.release` lifts it. Holds and releases are events with actor, reason, and receipt, so the history of a fact shows the hold as plainly as the write. `agent-custody-memory sweep --ledger ... --before ... --reason ...` runs retention on the ledger file alone, for ledgers with no stores behind them.
|
|
85
|
+
**Retention and legal hold.** `memory.sweep` forgets every fact the ledger learned of before an instant, in one space or all, and removes each from every store; it is retention as a receipted call, with the receipt as the record of what was erased. Retention windows live in the server: `serve --retention 'org=P365D,team:*=P90D,user:*=P30D'`, ISO 8601 durations by space pattern, and a sweep with no `before` uses them per space. To run it on a schedule without a daemon, `agent-custody-memory sweep --via retention-gateway.json --reason "quarterly retention"` spawns that gateway and calls `memory.sweep` through it, so the sweep runs as the principal named in the gateway's grant, a retention job with the single scope `memory.sweep`, and its receipt records when retention ran, by whom, what it erased, and what a hold kept. Any cron, systemd timer, or CI schedule drives that one command. `memory.hold` puts a legal hold on a fact: while it stands, neither a deletion request nor a sweep can forget it, and `memory.release` lifts it. Holds and releases are events with actor, reason, and receipt, so the history of a fact shows the hold as plainly as the write. `agent-custody-memory sweep --ledger ... --before ... --reason ...` runs retention on the ledger file alone, for ledgers with no stores behind them.
|
|
86
86
|
|
|
87
87
|
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.
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
Receipts are the other place a value lives: the receipt that recorded the write carries it in its request arguments, and the receipts of reads carry it in their results, signed and in a Merkle log. The receipts package's `prune` command is retention for the log: it replaces older leaves with their hashes, so every root and every proof still verifies while the content is gone, and removes the pruned receipts' bundle files. Run it on the same schedule as the sweep.
|
|
90
90
|
|
|
91
91
|
## Blast radius
|
|
92
92
|
|
|
@@ -138,9 +138,23 @@ import { Ledger, ledgerUnderTest, runAll, SCENARIOS, formatReport } from "@agent
|
|
|
138
138
|
console.log(formatReport(await runAll(ledgerUnderTest(new Ledger("./ledger.jsonl")), SCENARIOS)));
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
+
From the shell, on a schedule:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
agent-custody-memory eval --baseline # built-in scenarios, ledger and a naive store side by side
|
|
145
|
+
agent-custody-memory eval --scenarios incidents.json --sign keys/eval.key --out report.json # your own incidents, signed report
|
|
146
|
+
agent-custody-memory eval --verify report.json --key keys/eval.pub # what a reviewer runs
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A scenario file is `{ "version": "0.1", "scenarios": [{ "name", "ops": [...] }] }` with the same write, read, and retract ops as the built-ins; every read carries `expect`, null meaning nothing. The file is validated and a bad one names the offending op. The signed report is an in-toto statement over the scores, bound to a digest of the scenarios it ran, in the same envelope format as receipts; `eval --verify` checks the signature and the digest. `eval` exits non-zero when the ledger scores below perfect on the scenarios it was given, so a cron job that runs it fails loudly when a change regresses memory behaviour.
|
|
150
|
+
|
|
141
151
|
## The ledger
|
|
142
152
|
|
|
143
|
-
`src/ledger.ts`
|
|
153
|
+
`src/ledger.ts` keeps an append-only log of events, in memory for its queries and in one of two stores for durability. **JSONL** is the default: one event per line, readable by anyone, the file you copy for an audit. **SQLite** is for durability and shared use, chosen by giving the ledger a path ending in `.sqlite` or `.db`: transactional writes with write-ahead logging, an in-place forget that overwrites the erased value (secure delete, then a truncating checkpoint so nothing lingers in the write-ahead log), indexes by fact and by time, and a file more than one process can open. Node ships the SQLite module, so there is no native dependency; on Node 22 it prints an experimental warning once. `agent-custody-memory export --ledger ledger.sqlite --out ledger.jsonl` writes the auditable JSONL from any store, so the copy-the-file audit path survives the choice. The whole ledger test suite runs against both stores.
|
|
154
|
+
|
|
155
|
+
Choose JSONL for one server process and for anything an auditor should be able to read with `cat`. Choose SQLite when the ledger is shared over HTTP by several gateways, when a crash between two writes must not cost you an event, or when a deletion demand must leave no trace of the value in the file. Query pushdown to SQLite's indexes is not done yet: both stores load every event into memory, which is fine to tens of thousands of facts. [Issue #8](https://github.com/ch4r10t33r/agent-custody/issues/8) tracks indexed queries for larger ledgers.
|
|
156
|
+
|
|
157
|
+
The log holds these kinds of event.
|
|
144
158
|
|
|
145
159
|
- **assert** creates a fact: subject, predicate, value, the space it lives in (a person, a team, an org), the actor that wrote it, the source receipt id if the write went through a receipts producer, and the valid-time interval. An assert may **supersede** an earlier fact, which ends that fact's validity where the new one begins.
|
|
146
160
|
- **retract** says a fact should never have been believed. This is the undo. The record stays, so queries about earlier moments still see the fact, and anything the retracted fact had superseded is believed again.
|
|
@@ -159,14 +173,16 @@ The ledger refuses to supersede a fact that is unknown, already superseded, or r
|
|
|
159
173
|
## Layout
|
|
160
174
|
|
|
161
175
|
```
|
|
162
|
-
src/ledger.ts the fact record, the
|
|
176
|
+
src/ledger.ts the fact record, the event kinds, as-of queries, supersession, retraction, forget, holds, sweeps
|
|
177
|
+
src/storage.ts the event stores: JSONL (default, auditable) and SQLite (durable, shared), chosen by file extension
|
|
163
178
|
src/server.ts the ledger as MCP tools; source and actor taken from the gateway's _meta
|
|
164
179
|
src/http.ts the memory server over Streamable HTTP with bearer auth, for a shared ledger
|
|
165
|
-
src/cli.ts agent-custody-memory serve (stdio or --http), sweep, blast
|
|
180
|
+
src/cli.ts agent-custody-memory serve (stdio or --http, with --retention and --forget-key-env), sweep (ledger-only or --via a gateway), eval, export, blast
|
|
166
181
|
src/blast.ts blast radius: from receipts' consumed facts and the ledger's source receipts, forward
|
|
167
182
|
src/stores.ts write-through adapters: Mem0 and Zep, and the Store interface for others
|
|
168
183
|
src/evals.ts the memory-mutation harness: scenarios, scoring, report
|
|
169
184
|
src/evals-ledger.ts the ledger and a naive overwrite store behind the harness interface
|
|
185
|
+
src/evals-file.ts scenario files, validated; signed eval reports and their verification
|
|
170
186
|
src/index.ts public surface
|
|
171
187
|
examples/ runnable walkthroughs, each ends with OK and is run by the test suite
|
|
172
188
|
test/ one test per question a platform owner asks after a memory incident
|
|
@@ -177,8 +193,10 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
|
|
|
177
193
|
|
|
178
194
|
**Done**
|
|
179
195
|
|
|
180
|
-
- Bitemporal fact ledger with supersession, retraction, as-of and history queries, persisted as JSONL.
|
|
196
|
+
- Bitemporal fact ledger with supersession, retraction, as-of and history queries, persisted as JSONL or SQLite behind one store interface, with export back to JSONL.
|
|
181
197
|
- 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.
|
|
198
|
+
- Forget digests are keyed under a server-held secret, or absent on request, so an erased value cannot be guessed back from the file.
|
|
199
|
+
- Retention windows per space in the server, sweeps that default to them, and a `sweep --via` trigger that runs retention through a gateway as a named principal, on any timer.
|
|
182
200
|
- Retention and legal hold: a receipted sweep forgets what was learned before an instant and reaches the stores; a hold refuses forget and sweep until released, as events on the fact's history.
|
|
183
201
|
- Value-level quarantine: a write may cite a fact the gateway fetched itself; the value must match it and the fact is then `verified`, the provenance level above attested, or the write is refused.
|
|
184
202
|
- 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.
|
|
@@ -187,10 +205,13 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
|
|
|
187
205
|
- 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.
|
|
188
206
|
- 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.
|
|
189
207
|
- 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.
|
|
208
|
+
- The eval CLI: built-in or custom scenario files, the naive baseline beside the ledger, a signed report a reviewer verifies, and a non-zero exit on regression, for cron.
|
|
190
209
|
- 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.
|
|
191
210
|
- 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.
|
|
192
211
|
|
|
193
212
|
**Next, in the order it pays off**
|
|
194
213
|
|
|
195
|
-
1.
|
|
214
|
+
1. Indexed queries on the SQLite store, so a shared ledger with millions of events answers reads without loading them all. [Issue #8](https://github.com/ch4r10t33r/agent-custody/issues/8).
|
|
215
|
+
2. Write-through adapters for Letta, LangMem, and Cognee, one per user who asks. [Issue #4](https://github.com/ch4r10t33r/agent-custody/issues/4).
|
|
216
|
+
3. The hosted plane, behind early access: tenanted log, then memory, then reports and a control plane. [Issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
|
|
196
217
|
|
package/dist/cli.js
CHANGED
|
@@ -4,18 +4,55 @@ import { Ledger } from "./ledger.js";
|
|
|
4
4
|
import { createMemoryServer, serveStdio } from "./server.js";
|
|
5
5
|
import { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
|
|
6
6
|
import { serveMemoryHttp } from "./http.js";
|
|
7
|
-
import { loadPrivateKey } from "@agent-custody/receipts";
|
|
7
|
+
import { loadPrivateKey, loadPublicKey } from "@agent-custody/receipts";
|
|
8
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { formatReport, runAll, SCENARIOS } from "./evals.js";
|
|
12
|
+
import { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.js";
|
|
13
|
+
import { loadScenarios, signReport, verifyReport } from "./evals-file.js";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
16
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
17
|
+
/** The forget key from an environment variable, when named; a named but unset variable is an error at startup. */
|
|
18
|
+
function forgetKeyFrom(envName) {
|
|
19
|
+
if (!envName)
|
|
20
|
+
return {};
|
|
21
|
+
const v = process.env[envName];
|
|
22
|
+
if (!v)
|
|
23
|
+
throw new Error(`forget key: environment variable ${envName} is not set`);
|
|
24
|
+
return { forgetKey: v };
|
|
25
|
+
}
|
|
26
|
+
/** "org=P365D,team:*=P90D" into retention windows. */
|
|
27
|
+
function parseRetention(spec) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const part of spec.split(",")) {
|
|
30
|
+
const [pattern, duration] = part.split("=").map((x) => x.trim());
|
|
31
|
+
if (!pattern || !duration)
|
|
32
|
+
throw new Error(`retention: expected space=duration, got "${part}"`);
|
|
33
|
+
out[pattern] = duration;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
8
37
|
const USAGE = `agent-custody-memory <command>
|
|
9
38
|
|
|
10
|
-
serve --ledger <ledger.jsonl> [--allow-direct] [--key <memory.key>]
|
|
39
|
+
serve --ledger <ledger.jsonl|ledger.sqlite> [--allow-direct] [--key <memory.key>] [--forget-key-env NAME] [--retention 'org=P365D,team:*=P90D']
|
|
40
|
+
--forget-key-env names a secret kept outside the ledger; forgotten values then leave an HMAC, not a guessable hash.
|
|
11
41
|
the memory server over stdio; run it as the receipts gateway's upstream.
|
|
12
42
|
--key signs every result for its receipt, so executions verify as attested by this server.
|
|
13
43
|
By default it refuses calls that did not come through the gateway.
|
|
14
44
|
serve --ledger <ledger.jsonl> --http [--port 8790] [--host 127.0.0.1] [--token-env NAME] [--allow-direct]
|
|
15
45
|
the same server shared over HTTP: several gateways, one ledger
|
|
16
|
-
sweep --
|
|
17
|
-
retention
|
|
18
|
-
|
|
46
|
+
sweep --via <gateway.json> --reason <text> [--before <ISO instant>] [--space <space>] [--no-digest]
|
|
47
|
+
retention as a receipted call: runs memory.sweep through that gateway, as the principal in its grant.
|
|
48
|
+
Without --before, the memory server's --retention windows decide. Put this on a timer.
|
|
49
|
+
sweep --ledger <ledger.jsonl> --before <ISO instant> [--space <space>] --reason <text> [--actor <id>] [--forget-key-env NAME] [--no-digest]
|
|
50
|
+
retention on the ledger file alone, for ledgers with no gateway or stores in front of them.
|
|
51
|
+
eval [--scenarios <file.json>] [--baseline] [--json] [--sign <key> --out <report.json>]
|
|
52
|
+
runs the memory-mutation scenarios on a fresh ledger; --baseline also scores a naive
|
|
53
|
+
overwrite store; --sign writes a signed report. Exits 1 if the ledger regresses.
|
|
54
|
+
eval --verify <report.json> --key <pub> checks a signed report and prints its scores
|
|
55
|
+
export --ledger <ledger.sqlite> --out <ledger.jsonl> the auditable JSONL of any ledger, one event per line
|
|
19
56
|
blast --ledger <ledger.jsonl> --receipts <dir> --fact <factId> [--json]
|
|
20
57
|
everything that relied on a fact: later calls, derived beliefs, and whether it was retracted
|
|
21
58
|
`;
|
|
@@ -23,33 +60,100 @@ async function main(argv) {
|
|
|
23
60
|
const [cmd, ...rest] = argv;
|
|
24
61
|
switch (cmd) {
|
|
25
62
|
case "serve": {
|
|
26
|
-
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" } } });
|
|
63
|
+
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" }, "forget-key-env": { type: "string" }, retention: { type: "string" } } });
|
|
27
64
|
if (!values.ledger)
|
|
28
65
|
throw new Error("serve needs --ledger");
|
|
29
|
-
const ledger = new Ledger(values.ledger);
|
|
66
|
+
const ledger = new Ledger(values.ledger, forgetKeyFrom(values["forget-key-env"]));
|
|
30
67
|
const identity = values.key ? loadPrivateKey(values.key) : undefined;
|
|
68
|
+
const retention = values.retention ? parseRetention(values.retention) : undefined;
|
|
69
|
+
const common = { requireGateway: !values["allow-direct"], ...(identity ? { identity } : {}), ...(retention ? { retention } : {}) };
|
|
31
70
|
if (values.http) {
|
|
32
71
|
const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
|
|
33
72
|
if (values["token-env"] && !token)
|
|
34
73
|
throw new Error(`serve: environment variable ${values["token-env"]} is not set`);
|
|
35
|
-
const running = await serveMemoryHttp(ledger, { port: Number(values.port), host: values.host,
|
|
74
|
+
const running = await serveMemoryHttp(ledger, { port: Number(values.port), host: values.host, ...common, ...(token ? { tokens: [token] } : {}) });
|
|
36
75
|
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"}`);
|
|
37
76
|
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
38
77
|
await running.close();
|
|
39
78
|
return 0;
|
|
40
79
|
}
|
|
41
80
|
console.error(`agent-custody-memory: ledger=${values.ledger} events=${ledger.size} ${values["allow-direct"] ? "direct calls allowed" : "gateway calls only"}`);
|
|
42
|
-
await serveStdio(createMemoryServer(ledger,
|
|
81
|
+
await serveStdio(createMemoryServer(ledger, common));
|
|
43
82
|
return 0;
|
|
44
83
|
}
|
|
45
84
|
case "sweep": {
|
|
46
|
-
const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, before: { type: "string" }, space: { type: "string" }, reason: { type: "string" }, actor: { type: "string", default: "cli" } } });
|
|
85
|
+
const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, via: { type: "string" }, before: { type: "string" }, space: { type: "string" }, reason: { type: "string" }, actor: { type: "string", default: "cli" }, "forget-key-env": { type: "string" }, "no-digest": { type: "boolean", default: false } } });
|
|
86
|
+
if (values.via) {
|
|
87
|
+
// Retention as a receipted call: spawn the gateway from its config and call memory.sweep through it, so the
|
|
88
|
+
// sweep runs as the principal named in that gateway's grant and its receipt is the record.
|
|
89
|
+
if (!values.reason)
|
|
90
|
+
throw new Error("sweep --via needs --reason");
|
|
91
|
+
const gatewayCli = fileURLToPath(import.meta.resolve("@agent-custody/receipts/cli"));
|
|
92
|
+
const client = new Client({ name: "agent-custody-memory-sweep", version: "0" });
|
|
93
|
+
await client.connect(new StdioClientTransport({ command: process.execPath, args: [gatewayCli, "gateway", "--config", values.via], stderr: "inherit" }));
|
|
94
|
+
try {
|
|
95
|
+
const r = await client.callTool({ name: "memory.sweep", arguments: { reason: values.reason, ...(values.before ? { before: new Date(values.before).toISOString() } : {}), ...(values.space ? { space: values.space } : {}), ...(values["no-digest"] ? { keepDigest: false } : {}) } });
|
|
96
|
+
const text = (r.content.find((c) => c.type === "text")?.text) ?? "";
|
|
97
|
+
console.log(text);
|
|
98
|
+
return r.isError ? 1 : 0;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await client.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
47
104
|
if (!values.ledger || !values.before || !values.reason)
|
|
48
|
-
throw new Error("sweep needs --ledger, --before, and --reason");
|
|
49
|
-
const r = new Ledger(values.ledger).sweep({ before: new Date(values.before).toISOString(), ...(values.space ? { space: values.space } : {}), actor: values.actor, reason: values.reason });
|
|
105
|
+
throw new Error("sweep needs --ledger, --before, and --reason, or --via <gateway.json> --reason");
|
|
106
|
+
const r = new Ledger(values.ledger, forgetKeyFrom(values["forget-key-env"])).sweep({ before: new Date(values.before).toISOString(), ...(values.space ? { space: values.space } : {}), actor: values.actor, reason: values.reason, ...(values["no-digest"] ? { keepDigest: false } : {}) });
|
|
50
107
|
console.log(`forgot ${r.forgotten.length} fact(s); ${r.held.length} on hold, kept`);
|
|
51
108
|
for (const f of r.forgotten)
|
|
52
|
-
console.log(` ${f.factId}
|
|
109
|
+
console.log(` ${f.factId} ${f.digestKind}${f.valueDigest ? ` ${f.valueDigest.slice(0, 12)}` : ""}`);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
case "eval": {
|
|
113
|
+
const { values } = parseArgs({ args: rest, options: { scenarios: { type: "string" }, baseline: { type: "boolean", default: false }, json: { type: "boolean", default: false }, sign: { type: "string" }, out: { type: "string" }, verify: { type: "string" }, key: { type: "string", multiple: true } } });
|
|
114
|
+
if (values.verify) {
|
|
115
|
+
if (!values.key?.length)
|
|
116
|
+
throw new Error("eval --verify needs --key <pub>");
|
|
117
|
+
const r = verifyReport(JSON.parse(readFileSync(values.verify, "utf8")), values.key.map(loadPublicKey));
|
|
118
|
+
if (!r.ok) {
|
|
119
|
+
console.log(`NOT VERIFIED: ${r.error}`);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
console.log(`VERIFIED signed by ${r.keyid.slice(0, 12)} system ${r.predicate.system} ran ${r.predicate.ranAt} scenarios ${r.predicate.scenarioNames.length} (digest ${r.predicate.scenariosDigest.slice(0, 12)})`);
|
|
123
|
+
console.log(formatReport(r.predicate.report));
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
const scenarios = values.scenarios ? loadScenarios(values.scenarios) : SCENARIOS;
|
|
127
|
+
const ledger = new Ledger(join(mkdtempSync(join(tmpdir(), "agent-custody-eval-")), "ledger.jsonl"));
|
|
128
|
+
const report = await runAll(ledgerUnderTest(ledger), scenarios);
|
|
129
|
+
const baseline = values.baseline ? await runAll(overwriteStoreUnderTest(), scenarios) : null;
|
|
130
|
+
const regressed = report.totals.correctReads < report.totals.reads || report.scenarios.some((s) => s.failures.length > 0);
|
|
131
|
+
if (values.json)
|
|
132
|
+
console.log(JSON.stringify({ ledger: report, baseline }, null, 2));
|
|
133
|
+
else {
|
|
134
|
+
console.log("The ledger:");
|
|
135
|
+
console.log(formatReport(report));
|
|
136
|
+
if (baseline) {
|
|
137
|
+
console.log("\nA naive overwrite store:");
|
|
138
|
+
console.log(formatReport(baseline));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (values.sign) {
|
|
142
|
+
if (!values.out)
|
|
143
|
+
throw new Error("eval --sign needs --out <report.json>");
|
|
144
|
+
writeFileSync(values.out, JSON.stringify(signReport("agent-custody-ledger", scenarios, report, loadPrivateKey(values.sign)), null, 2));
|
|
145
|
+
console.error(`signed report written to ${values.out}`);
|
|
146
|
+
}
|
|
147
|
+
return regressed ? 1 : 0;
|
|
148
|
+
}
|
|
149
|
+
case "export": {
|
|
150
|
+
const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, out: { type: "string" } } });
|
|
151
|
+
if (!values.ledger || !values.out)
|
|
152
|
+
throw new Error("export needs --ledger and --out");
|
|
153
|
+
const l = new Ledger(values.ledger);
|
|
154
|
+
writeFileSync(values.out, l.export().map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
155
|
+
console.log(`exported ${l.size} event(s) from ${l.location} to ${values.out}`);
|
|
156
|
+
l.close();
|
|
53
157
|
return 0;
|
|
54
158
|
}
|
|
55
159
|
case "blast": {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type Envelope, type KeyPair, type PublicKeyRef } from "@agent-custody/receipts";
|
|
3
|
+
import type { Report, Scenario } from "./evals.ts";
|
|
4
|
+
export declare const EVAL_REPORT_TYPE = "https://agent-custody.dev/eval-report/v0.1";
|
|
5
|
+
export declare const ScenarioFileSchema: z.ZodObject<{
|
|
6
|
+
version: z.ZodLiteral<"0.1">;
|
|
7
|
+
scenarios: z.ZodArray<z.ZodObject<{
|
|
8
|
+
name: z.ZodString;
|
|
9
|
+
ops: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
10
|
+
op: z.ZodLiteral<"write">;
|
|
11
|
+
key: z.ZodString;
|
|
12
|
+
subject: z.ZodString;
|
|
13
|
+
predicate: z.ZodString;
|
|
14
|
+
value: z.ZodUnknown;
|
|
15
|
+
space: z.ZodOptional<z.ZodString>;
|
|
16
|
+
actor: z.ZodOptional<z.ZodString>;
|
|
17
|
+
supersedes: z.ZodOptional<z.ZodString>;
|
|
18
|
+
bad: z.ZodOptional<z.ZodBoolean>;
|
|
19
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
20
|
+
op: z.ZodLiteral<"read">;
|
|
21
|
+
subject: z.ZodString;
|
|
22
|
+
predicate: z.ZodOptional<z.ZodString>;
|
|
23
|
+
space: z.ZodOptional<z.ZodString>;
|
|
24
|
+
expect: z.ZodUnknown;
|
|
25
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
26
|
+
op: z.ZodLiteral<"retract">;
|
|
27
|
+
key: z.ZodString;
|
|
28
|
+
actor: z.ZodOptional<z.ZodString>;
|
|
29
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
30
|
+
}, z.core.$strip>], "op">>;
|
|
31
|
+
}, z.core.$strip>>;
|
|
32
|
+
}, z.core.$strip>;
|
|
33
|
+
export type ScenarioFile = z.infer<typeof ScenarioFileSchema>;
|
|
34
|
+
/** Loads and validates a scenario file. Every read must carry `expect`, null meaning "nothing". */
|
|
35
|
+
export declare function loadScenarios(path: string): Scenario[];
|
|
36
|
+
export interface EvalReportPredicate {
|
|
37
|
+
system: string;
|
|
38
|
+
scenariosDigest: string;
|
|
39
|
+
scenarioNames: string[];
|
|
40
|
+
report: Report;
|
|
41
|
+
ranAt: string;
|
|
42
|
+
}
|
|
43
|
+
/** An in-toto statement over the report, signed with the given key: the artefact a reviewer verifies. */
|
|
44
|
+
export declare function signReport(system: string, scenarios: Scenario[], report: Report, key: KeyPair): Envelope;
|
|
45
|
+
export type ReportCheck = {
|
|
46
|
+
ok: true;
|
|
47
|
+
predicate: EvalReportPredicate;
|
|
48
|
+
keyid: string;
|
|
49
|
+
} | {
|
|
50
|
+
ok: false;
|
|
51
|
+
error: string;
|
|
52
|
+
};
|
|
53
|
+
export declare function verifyReport(envelope: Envelope, keys: PublicKeyRef[]): ReportCheck;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Scenario files and signed eval reports. A team encodes its own memory incidents as scenarios, runs them on a
|
|
2
|
+
// schedule, and hands a reviewer a report signed with a key, verifiable with the same envelope format as receipts.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { digestOf, dsseSign, dsseVerify } from "@agent-custody/receipts";
|
|
6
|
+
export const EVAL_REPORT_TYPE = "https://agent-custody.dev/eval-report/v0.1";
|
|
7
|
+
const OpSchema = z.discriminatedUnion("op", [
|
|
8
|
+
z.object({ op: z.literal("write"), key: z.string().min(1), subject: z.string().min(1), predicate: z.string().min(1), value: z.unknown(), space: z.string().min(1).optional(), actor: z.string().min(1).optional(), supersedes: z.string().min(1).optional(), bad: z.boolean().optional() }),
|
|
9
|
+
z.object({ op: z.literal("read"), subject: z.string().min(1), predicate: z.string().min(1).optional(), space: z.string().min(1).optional(), expect: z.unknown() }),
|
|
10
|
+
z.object({ op: z.literal("retract"), key: z.string().min(1), actor: z.string().min(1).optional(), reason: z.string().min(1).optional() }),
|
|
11
|
+
]);
|
|
12
|
+
export const ScenarioFileSchema = z.object({
|
|
13
|
+
version: z.literal("0.1"),
|
|
14
|
+
scenarios: z.array(z.object({ name: z.string().min(1), ops: z.array(OpSchema).min(1) })).min(1),
|
|
15
|
+
});
|
|
16
|
+
/** Loads and validates a scenario file. Every read must carry `expect`, null meaning "nothing". */
|
|
17
|
+
export function loadScenarios(path) {
|
|
18
|
+
const parsed = ScenarioFileSchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
19
|
+
if (!parsed.success)
|
|
20
|
+
throw new Error(`scenario file ${path}: ${parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
21
|
+
for (const [si, s] of parsed.data.scenarios.entries())
|
|
22
|
+
for (const [oi, o] of s.ops.entries())
|
|
23
|
+
if (o.op === "read" && !("expect" in o))
|
|
24
|
+
throw new Error(`scenario file ${path}: scenarios.${si}.ops.${oi}: a read needs expect (null for nothing)`);
|
|
25
|
+
return parsed.data.scenarios;
|
|
26
|
+
}
|
|
27
|
+
/** An in-toto statement over the report, signed with the given key: the artefact a reviewer verifies. */
|
|
28
|
+
export function signReport(system, scenarios, report, key) {
|
|
29
|
+
const predicate = { system, scenariosDigest: digestOf(scenarios), scenarioNames: scenarios.map((s) => s.name), report, ranAt: new Date().toISOString() };
|
|
30
|
+
const statement = { _type: "https://in-toto.io/Statement/v1", subject: [{ name: `eval:${system}`, digest: { sha256: digestOf(report) } }], predicateType: EVAL_REPORT_TYPE, predicate };
|
|
31
|
+
return dsseSign("application/vnd.in-toto+json", statement, key);
|
|
32
|
+
}
|
|
33
|
+
export function verifyReport(envelope, keys) {
|
|
34
|
+
const v = dsseVerify(envelope, keys);
|
|
35
|
+
if (!v.ok)
|
|
36
|
+
return { ok: false, error: v.error };
|
|
37
|
+
const st = v.payload;
|
|
38
|
+
if (st.predicateType !== EVAL_REPORT_TYPE || !st.predicate)
|
|
39
|
+
return { ok: false, error: `not an eval report: ${String(st.predicateType)}` };
|
|
40
|
+
if (st.subject?.[0]?.digest?.sha256 !== digestOf(st.predicate.report))
|
|
41
|
+
return { ok: false, error: "report digest does not match the subject" };
|
|
42
|
+
return { ok: true, predicate: st.predicate, keyid: v.keyid };
|
|
43
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
export { Ledger } from "./ledger.ts";
|
|
2
|
-
export
|
|
3
|
-
export {
|
|
2
|
+
export { JsonlStore, SqliteStore, openStore } from "./storage.ts";
|
|
3
|
+
export type { EventStore } from "./storage.ts";
|
|
4
|
+
export type { AsOf, AssertEvent, AssertInput, ConfirmEvent, ConfirmInput, Fact, FactProvenance, DigestKind, ForgetEvent, ForgetInput, HoldEvent, HoldInput, LedgerEvent, SweepInput, RetractEvent, RetractInput, Source } from "./ledger.ts";
|
|
5
|
+
export { AGENT_META_KEY, FACTS_META_KEY, OBSERVED_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, durationMs, retentionCutoff, serveStdio } from "./server.ts";
|
|
4
6
|
export type { MemoryServerOptions } from "./server.ts";
|
|
5
7
|
export { SCENARIOS, formatReport, runAll, runScenario } from "./evals.ts";
|
|
6
8
|
export type { MemoryUnderTest, Op, Report, Scenario, ScenarioScore } from "./evals.ts";
|
|
7
9
|
export { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.ts";
|
|
10
|
+
export { EVAL_REPORT_TYPE, ScenarioFileSchema, loadScenarios, signReport, verifyReport } from "./evals-file.ts";
|
|
11
|
+
export type { EvalReportPredicate, ReportCheck, ScenarioFile } from "./evals-file.ts";
|
|
8
12
|
export { factMetadata, factText, mem0Store, zepStore } from "./stores.ts";
|
|
9
13
|
export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.ts";
|
|
10
14
|
export { memoryHttpHandler, serveMemoryHttp } from "./http.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Public surface of @agent-custody/state.
|
|
2
2
|
export { Ledger } from "./ledger.js";
|
|
3
|
-
export {
|
|
3
|
+
export { JsonlStore, SqliteStore, openStore } from "./storage.js";
|
|
4
|
+
export { AGENT_META_KEY, FACTS_META_KEY, OBSERVED_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, durationMs, retentionCutoff, serveStdio } from "./server.js";
|
|
4
5
|
export { SCENARIOS, formatReport, runAll, runScenario } from "./evals.js";
|
|
5
6
|
export { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.js";
|
|
7
|
+
export { EVAL_REPORT_TYPE, ScenarioFileSchema, loadScenarios, signReport, verifyReport } from "./evals-file.js";
|
|
6
8
|
export { factMetadata, factText, mem0Store, zepStore } from "./stores.js";
|
|
7
9
|
export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
|
|
8
10
|
export { memoryHttpHandler, serveMemoryHttp } from "./http.js";
|
package/dist/ledger.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type EventStore } from "./storage.ts";
|
|
1
2
|
/** Where a write came from. A receipt id means the write went through a receipts producer and can be verified there. */
|
|
2
3
|
export interface Source {
|
|
3
4
|
receiptId: string | null;
|
|
@@ -22,9 +23,10 @@ export interface Fact {
|
|
|
22
23
|
actor: string;
|
|
23
24
|
source: Source;
|
|
24
25
|
provenance: FactProvenance;
|
|
25
|
-
/** present once the value has been erased: the value field is null and this is the digest of what it was */
|
|
26
|
+
/** present once the value has been erased: the value field is null and this is the digest of what it was, or null when none was kept */
|
|
26
27
|
forgotten?: {
|
|
27
|
-
valueDigest: string;
|
|
28
|
+
valueDigest: string | null;
|
|
29
|
+
digestKind: DigestKind;
|
|
28
30
|
at: string;
|
|
29
31
|
};
|
|
30
32
|
/** ids of this fact in the retrieval stores it was written through to, by store name; absent when there are none */
|
|
@@ -61,6 +63,11 @@ export interface ConfirmEvent {
|
|
|
61
63
|
actor: string;
|
|
62
64
|
source: Source;
|
|
63
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* How a forgotten value's digest was made. sha256 is guessable for short values by anyone holding the file;
|
|
68
|
+
* hmac-sha256 needs the ledger's forget key, kept outside the file; none keeps nothing derived from the value.
|
|
69
|
+
*/
|
|
70
|
+
export type DigestKind = "sha256" | "hmac-sha256" | "none";
|
|
64
71
|
/**
|
|
65
72
|
* A forget is erasure, not correction. The fact's value is removed from the ledger file itself and replaced by its
|
|
66
73
|
* digest, so the ledger can still prove which value it held without holding it. The fact stops being believed.
|
|
@@ -73,8 +80,9 @@ export interface ForgetEvent {
|
|
|
73
80
|
actor: string;
|
|
74
81
|
reason: string;
|
|
75
82
|
source: Source;
|
|
76
|
-
/**
|
|
77
|
-
valueDigest: string;
|
|
83
|
+
/** digest of the canonical JSON of the erased value, per digestKind; null when none was kept */
|
|
84
|
+
valueDigest: string | null;
|
|
85
|
+
digestKind: DigestKind;
|
|
78
86
|
}
|
|
79
87
|
/** A legal hold: while it stands, the fact cannot be forgotten, by request or by retention sweep. Release lifts it. */
|
|
80
88
|
export interface HoldEvent {
|
|
@@ -111,6 +119,8 @@ export interface ForgetInput {
|
|
|
111
119
|
actor: string;
|
|
112
120
|
reason: string;
|
|
113
121
|
source?: Source;
|
|
122
|
+
/** false keeps no digest at all; default true */
|
|
123
|
+
keepDigest?: boolean;
|
|
114
124
|
}
|
|
115
125
|
export interface HoldInput {
|
|
116
126
|
factId: string;
|
|
@@ -125,6 +135,7 @@ export interface SweepInput {
|
|
|
125
135
|
actor: string;
|
|
126
136
|
reason: string;
|
|
127
137
|
source?: Source;
|
|
138
|
+
keepDigest?: boolean;
|
|
128
139
|
}
|
|
129
140
|
export interface RetractInput {
|
|
130
141
|
factId: string;
|
|
@@ -145,11 +156,22 @@ export interface AsOf {
|
|
|
145
156
|
}
|
|
146
157
|
export declare class Ledger {
|
|
147
158
|
private readonly events;
|
|
148
|
-
private readonly
|
|
159
|
+
private readonly store;
|
|
149
160
|
private readonly now;
|
|
150
|
-
|
|
161
|
+
private readonly forgetKey;
|
|
162
|
+
/**
|
|
163
|
+
* `location` is a path: JSONL by default, SQLite when it ends in .sqlite or .db; or pass a store.
|
|
164
|
+
* forgetKey: a secret kept outside the store; with it, forgotten values leave an HMAC rather than a plain hash.
|
|
165
|
+
*/
|
|
166
|
+
constructor(location: string | EventStore, opts?: {
|
|
151
167
|
now?: () => Date;
|
|
168
|
+
forgetKey?: string | Buffer;
|
|
152
169
|
});
|
|
170
|
+
/** Where the events live, for reports. */
|
|
171
|
+
get location(): string;
|
|
172
|
+
/** Every event in order, for export. */
|
|
173
|
+
export(): LedgerEvent[];
|
|
174
|
+
close(): void;
|
|
153
175
|
get size(): number;
|
|
154
176
|
/** The checks assert makes, without appending. For callers that must do something irreversible before the append. */
|
|
155
177
|
validateAssert(input: AssertInput): void;
|
package/dist/ledger.js
CHANGED
|
@@ -2,26 +2,34 @@
|
|
|
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 { createHash } from "node:crypto";
|
|
6
|
-
import {
|
|
7
|
-
import { dirname } from "node:path";
|
|
5
|
+
import { createHash, createHmac } from "node:crypto";
|
|
6
|
+
import { openStore } from "./storage.js";
|
|
8
7
|
const RANK = { claimed: 0, attested: 1, verified: 2 };
|
|
9
8
|
export class Ledger {
|
|
10
|
-
events
|
|
11
|
-
|
|
9
|
+
events;
|
|
10
|
+
store;
|
|
12
11
|
now;
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
forgetKey;
|
|
13
|
+
/**
|
|
14
|
+
* `location` is a path: JSONL by default, SQLite when it ends in .sqlite or .db; or pass a store.
|
|
15
|
+
* forgetKey: a secret kept outside the store; with it, forgotten values leave an HMAC rather than a plain hash.
|
|
16
|
+
*/
|
|
17
|
+
constructor(location, opts = {}) {
|
|
18
|
+
this.store = typeof location === "string" ? openStore(location) : location;
|
|
15
19
|
this.now = opts.now ?? (() => new Date());
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
this.forgetKey = opts.forgetKey ? Buffer.from(opts.forgetKey) : null;
|
|
21
|
+
this.events = this.store.load();
|
|
22
|
+
}
|
|
23
|
+
/** Where the events live, for reports. */
|
|
24
|
+
get location() {
|
|
25
|
+
return this.store.location;
|
|
26
|
+
}
|
|
27
|
+
/** Every event in order, for export. */
|
|
28
|
+
export() {
|
|
29
|
+
return [...this.events];
|
|
30
|
+
}
|
|
31
|
+
close() {
|
|
32
|
+
this.store.close();
|
|
25
33
|
}
|
|
26
34
|
get size() {
|
|
27
35
|
return this.events.length;
|
|
@@ -139,7 +147,7 @@ export class Ledger {
|
|
|
139
147
|
held.push(e.fact.factId);
|
|
140
148
|
continue;
|
|
141
149
|
}
|
|
142
|
-
forgotten.push(this.forget({ factId: e.fact.factId, actor: input.actor, reason: input.reason, ...(input.source ? { source: input.source } : {}) }));
|
|
150
|
+
forgotten.push(this.forget({ factId: e.fact.factId, actor: input.actor, reason: input.reason, ...(input.source ? { source: input.source } : {}), ...(input.keepDigest === undefined ? {} : { keepDigest: input.keepDigest }) }));
|
|
143
151
|
}
|
|
144
152
|
return { forgotten, held };
|
|
145
153
|
}
|
|
@@ -152,18 +160,18 @@ export class Ledger {
|
|
|
152
160
|
if (this.held(input.factId))
|
|
153
161
|
throw new Error(`fact ${input.factId} is on legal hold; release it first`);
|
|
154
162
|
const txTime = this.now().toISOString();
|
|
155
|
-
const
|
|
163
|
+
const text = canonical(prior.fact.value);
|
|
164
|
+
const digestKind = input.keepDigest === false ? "none" : this.forgetKey ? "hmac-sha256" : "sha256";
|
|
165
|
+
const valueDigest = digestKind === "none" ? null : digestKind === "hmac-sha256" ? createHmac("sha256", this.forgetKey).update(text).digest("hex") : createHash("sha256").update(text).digest("hex");
|
|
156
166
|
for (const e of this.events) {
|
|
157
167
|
if (e.kind === "assert" && e.fact.factId === input.factId) {
|
|
158
168
|
e.fact.value = null;
|
|
159
|
-
e.fact.forgotten = { valueDigest, at: txTime };
|
|
169
|
+
e.fact.forgotten = { valueDigest, digestKind, at: txTime };
|
|
170
|
+
this.store.replaceAssert(e);
|
|
160
171
|
}
|
|
161
172
|
}
|
|
162
|
-
const event = { eventId: randomUUID(), kind: "forget", txTime, factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null }, valueDigest };
|
|
163
|
-
this.
|
|
164
|
-
const tmp = `${this.file}.tmp`;
|
|
165
|
-
writeFileSync(tmp, this.events.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
166
|
-
renameSync(tmp, this.file);
|
|
173
|
+
const event = { eventId: randomUUID(), kind: "forget", txTime, factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null }, valueDigest, digestKind };
|
|
174
|
+
this.append(event);
|
|
167
175
|
return event;
|
|
168
176
|
}
|
|
169
177
|
/** The facts believed at a moment. Valid time answers "was it true then"; transaction time answers "did the ledger know it then". */
|
|
@@ -223,7 +231,7 @@ export class Ledger {
|
|
|
223
231
|
return this.events.some((e) => (e.kind === "retract" || e.kind === "forget") && e.factId === factId);
|
|
224
232
|
}
|
|
225
233
|
append(event) {
|
|
226
|
-
|
|
234
|
+
this.store.append(event);
|
|
227
235
|
this.events.push(event);
|
|
228
236
|
}
|
|
229
237
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -19,7 +19,13 @@ export interface MemoryServerOptions {
|
|
|
19
19
|
stores?: Store[];
|
|
20
20
|
/** 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. */
|
|
21
21
|
identity?: KeyPair;
|
|
22
|
+
/** Retention windows by space pattern, ISO 8601 durations: { "org": "P365D", "team:*": "P90D" }. memory.sweep without `before` uses them. */
|
|
23
|
+
retention?: Record<string, string>;
|
|
22
24
|
}
|
|
25
|
+
/** Parses the ISO 8601 duration subset retention needs: P<n>W, P<n>D, PT<n>H, and combinations of D and H. */
|
|
26
|
+
export declare function durationMs(iso: string): number;
|
|
27
|
+
/** The retention cutoff for a space, from the first pattern that matches it; null when none does. */
|
|
28
|
+
export declare function retentionCutoff(retention: Record<string, string>, space: string, now: Date): string | null;
|
|
23
29
|
export declare function createMemoryServer(ledger: Ledger, opts?: MemoryServerOptions): Server;
|
|
24
30
|
/** Serves over stdio, the way the gateway spawns it. Diagnostics must go to stderr. */
|
|
25
31
|
export declare function serveStdio(server: Server): Promise<void>;
|
package/dist/server.js
CHANGED
|
@@ -35,9 +35,9 @@ const Confirm = z.object({ factId: z.string().min(1) });
|
|
|
35
35
|
const Retract = z.object({ factId: z.string().min(1), reason: z.string().min(1), actor: z.string().min(1).optional() });
|
|
36
36
|
const History = z.object({ factId: z.string().min(1) });
|
|
37
37
|
const Get = z.object({ factId: z.string().min(1) });
|
|
38
|
-
const Forget = z.object({ factId: z.string().min(1), reason: z.string().min(1) });
|
|
38
|
+
const Forget = z.object({ factId: z.string().min(1), reason: z.string().min(1), keepDigest: z.boolean().optional() });
|
|
39
39
|
const Hold = z.object({ factId: z.string().min(1), reason: z.string().min(1) });
|
|
40
|
-
const Sweep = z.object({ before: iso, space: z.string().min(1).optional(), reason: z.string().min(1) });
|
|
40
|
+
const Sweep = z.object({ before: iso.optional(), space: z.string().min(1).optional(), reason: z.string().min(1), keepDigest: z.boolean().optional() });
|
|
41
41
|
const str = { type: "string" };
|
|
42
42
|
export const TOOLS = [
|
|
43
43
|
{
|
|
@@ -62,8 +62,8 @@ export const TOOLS = [
|
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
name: "memory.forget",
|
|
65
|
-
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.",
|
|
66
|
-
inputSchema: { type: "object", properties: { factId: str, reason: str }, required: ["factId", "reason"] },
|
|
65
|
+
description: "Erase a fact's value: from the ledger file, keeping only its digest (keyed when the server has a forget key; none when keepDigest is false), 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.",
|
|
66
|
+
inputSchema: { type: "object", properties: { factId: str, reason: str, keepDigest: { type: "boolean" } }, required: ["factId", "reason"] },
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
name: "memory.hold",
|
|
@@ -77,8 +77,8 @@ export const TOOLS = [
|
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
name: "memory.sweep",
|
|
80
|
-
description: "Retention: forget every fact the ledger learned of before an instant, in one space or all, skipping held facts, and remove each from every store. The receipt is the record of the sweep.",
|
|
81
|
-
inputSchema: { type: "object", properties: { before: str, space: str, reason: str }, required: ["
|
|
80
|
+
description: "Retention: forget every fact the ledger learned of before an instant, in one space or all, skipping held facts, and remove each from every store. Without `before`, the server's configured retention windows decide per space. The receipt is the record of the sweep.",
|
|
81
|
+
inputSchema: { type: "object", properties: { before: str, space: str, reason: str, keepDigest: { type: "boolean" } }, required: ["reason"] },
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
name: "memory.get",
|
|
@@ -91,6 +91,23 @@ export const TOOLS = [
|
|
|
91
91
|
inputSchema: { type: "object", properties: { factId: str }, required: ["factId"] },
|
|
92
92
|
},
|
|
93
93
|
];
|
|
94
|
+
/** Parses the ISO 8601 duration subset retention needs: P<n>W, P<n>D, PT<n>H, and combinations of D and H. */
|
|
95
|
+
export function durationMs(iso) {
|
|
96
|
+
const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?)?$/.exec(iso);
|
|
97
|
+
if (!m || iso === "P" || iso === "PT")
|
|
98
|
+
throw new Error(`retention: cannot parse duration ${iso}; use forms like P90D, P2W, PT12H`);
|
|
99
|
+
const [, w = "0", d = "0", h = "0", min = "0"] = m;
|
|
100
|
+
return ((Number(w) * 7 + Number(d)) * 24 + Number(h)) * 3_600_000 + Number(min) * 60_000;
|
|
101
|
+
}
|
|
102
|
+
/** The retention cutoff for a space, from the first pattern that matches it; null when none does. */
|
|
103
|
+
export function retentionCutoff(retention, space, now) {
|
|
104
|
+
for (const [pattern, duration] of Object.entries(retention)) {
|
|
105
|
+
const matches = pattern.endsWith("*") ? space.startsWith(pattern.slice(0, -1)) : space === pattern;
|
|
106
|
+
if (matches)
|
|
107
|
+
return new Date(now.getTime() - durationMs(duration)).toISOString();
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
94
111
|
const json = (v) => ({ content: [{ type: "text", text: JSON.stringify(v) }] });
|
|
95
112
|
const fail = (msg) => ({ isError: true, content: [{ type: "text", text: msg }] });
|
|
96
113
|
export function createMemoryServer(ledger, opts = {}) {
|
|
@@ -102,9 +119,9 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
102
119
|
const result = await handle(req.params.name, req.params.arguments ?? {}, meta, receiptId);
|
|
103
120
|
return opts.identity && receiptId ? signResult(result, opts.identity, receiptId, req.params.name) : result;
|
|
104
121
|
});
|
|
105
|
-
async function forgetOne(factId, reason) {
|
|
122
|
+
async function forgetOne(factId, reason, keepDigest) {
|
|
106
123
|
const fact = ledger.facts().find((f) => f.factId === factId);
|
|
107
|
-
const ev = ledger.forget({ factId, actor: currentActor, reason, source: { receiptId: currentReceipt } });
|
|
124
|
+
const ev = ledger.forget({ factId, actor: currentActor, reason, source: { receiptId: currentReceipt }, ...(keepDigest === undefined ? {} : { keepDigest }) });
|
|
108
125
|
const removedFrom = [];
|
|
109
126
|
const stillHeld = [];
|
|
110
127
|
for (const store of opts.stores ?? []) {
|
|
@@ -119,7 +136,7 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
119
136
|
stillHeld.push(`${store.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
120
137
|
}
|
|
121
138
|
}
|
|
122
|
-
return { factId: ev.factId, valueDigest: ev.valueDigest, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, erasedFromLedger: true, removedFrom, stillHeld };
|
|
139
|
+
return { factId: ev.factId, valueDigest: ev.valueDigest, digestKind: ev.digestKind, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, erasedFromLedger: true, removedFrom, stillHeld };
|
|
123
140
|
}
|
|
124
141
|
let currentActor = "anonymous";
|
|
125
142
|
let currentReceipt = null;
|
|
@@ -197,7 +214,7 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
197
214
|
}
|
|
198
215
|
case "memory.forget": {
|
|
199
216
|
const a = Forget.parse(args);
|
|
200
|
-
const out = await forgetOne(a.factId, a.reason);
|
|
217
|
+
const out = await forgetOne(a.factId, a.reason, a.keepDigest);
|
|
201
218
|
if (out.stillHeld.length > 0)
|
|
202
219
|
return { isError: true, content: [{ type: "text", text: `erased from the ledger, but still held by ${out.stillHeld.join("; ")}` }, { type: "text", text: JSON.stringify(out) }] };
|
|
203
220
|
return json(out);
|
|
@@ -212,8 +229,15 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
212
229
|
}
|
|
213
230
|
case "memory.sweep": {
|
|
214
231
|
const a = Sweep.parse(args);
|
|
232
|
+
if (!a.before && !opts.retention)
|
|
233
|
+
return fail("sweep needs `before`, or a server started with retention windows");
|
|
234
|
+
const now = new Date();
|
|
235
|
+
const cutoffFor = (space) => a.before ?? retentionCutoff(opts.retention ?? {}, space, now);
|
|
215
236
|
const targets = ledger.facts().filter((f) => !f.forgotten && (a.space === undefined || f.space === a.space));
|
|
216
|
-
const learnedBefore = new Set(ledger.facts().filter((f) =>
|
|
237
|
+
const learnedBefore = new Set(ledger.facts().filter((f) => {
|
|
238
|
+
const cutoff = cutoffFor(f.space);
|
|
239
|
+
return cutoff !== null && ledger.history(f.factId).find((e) => e.kind === "assert" && e.fact.factId === f.factId).txTime < cutoff;
|
|
240
|
+
}).map((f) => f.factId));
|
|
217
241
|
const forgotten = [];
|
|
218
242
|
const held = [];
|
|
219
243
|
for (const f of targets) {
|
|
@@ -223,10 +247,10 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
223
247
|
held.push(f.factId);
|
|
224
248
|
continue;
|
|
225
249
|
}
|
|
226
|
-
forgotten.push(await forgetOne(f.factId, a.reason));
|
|
250
|
+
forgotten.push(await forgetOne(f.factId, a.reason, a.keepDigest));
|
|
227
251
|
}
|
|
228
252
|
const stillHeld = forgotten.flatMap((o) => o.stillHeld);
|
|
229
|
-
const out = { before: a.before, space: a.space ?? null, forgotten: forgotten.map((o) => ({ factId: o.factId, valueDigest: o.valueDigest, removedFrom: o.removedFrom })), held, stillHeld };
|
|
253
|
+
const out = { before: a.before ?? null, retention: a.before ? null : (opts.retention ?? null), space: a.space ?? null, forgotten: forgotten.map((o) => ({ factId: o.factId, valueDigest: o.valueDigest, digestKind: o.digestKind, removedFrom: o.removedFrom })), held, stillHeld };
|
|
230
254
|
if (stillHeld.length > 0)
|
|
231
255
|
return { isError: true, content: [{ type: "text", text: `swept the ledger, but some values are still held by stores: ${stillHeld.join("; ")}` }, { type: "text", text: JSON.stringify(out) }] };
|
|
232
256
|
return json(out);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AssertEvent, LedgerEvent } from "./ledger.ts";
|
|
2
|
+
export interface EventStore {
|
|
3
|
+
readonly kind: "jsonl" | "sqlite";
|
|
4
|
+
readonly location: string;
|
|
5
|
+
/** every event, in order */
|
|
6
|
+
load(): LedgerEvent[];
|
|
7
|
+
append(event: LedgerEvent): void;
|
|
8
|
+
/** replaces one assert event in place, for forget: the value is gone from the store, not merely superseded */
|
|
9
|
+
replaceAssert(event: AssertEvent): void;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
|
12
|
+
export declare class JsonlStore implements EventStore {
|
|
13
|
+
readonly kind: "jsonl";
|
|
14
|
+
readonly location: string;
|
|
15
|
+
constructor(file: string);
|
|
16
|
+
load(): LedgerEvent[];
|
|
17
|
+
append(event: LedgerEvent): void;
|
|
18
|
+
replaceAssert(event: AssertEvent): void;
|
|
19
|
+
close(): void;
|
|
20
|
+
}
|
|
21
|
+
export declare class SqliteStore implements EventStore {
|
|
22
|
+
readonly kind: "sqlite";
|
|
23
|
+
readonly location: string;
|
|
24
|
+
private readonly db;
|
|
25
|
+
constructor(file: string);
|
|
26
|
+
load(): LedgerEvent[];
|
|
27
|
+
append(event: LedgerEvent): void;
|
|
28
|
+
replaceAssert(event: AssertEvent): void;
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
31
|
+
/** JSONL unless the path ends in .sqlite or .db. */
|
|
32
|
+
export declare function openStore(location: string): EventStore;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Where the ledger's events live. The ledger keeps every event in memory for its queries; a store makes them durable.
|
|
2
|
+
// JSONL is the default and the auditable artefact: one event per line, readable by anyone, copied for an audit.
|
|
3
|
+
// SQLite is for durability and shared use: transactional writes, write-ahead logging, an in-place forget that leaves
|
|
4
|
+
// no copy of the value behind, and a file more than one process can open. Node ships the SQLite module; no native
|
|
5
|
+
// dependency.
|
|
6
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
export class JsonlStore {
|
|
10
|
+
kind = "jsonl";
|
|
11
|
+
location;
|
|
12
|
+
constructor(file) {
|
|
13
|
+
this.location = file;
|
|
14
|
+
if (!existsSync(file))
|
|
15
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
load() {
|
|
18
|
+
if (!existsSync(this.location))
|
|
19
|
+
return [];
|
|
20
|
+
return readFileSync(this.location, "utf8").split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
21
|
+
}
|
|
22
|
+
append(event) {
|
|
23
|
+
appendFileSync(this.location, JSON.stringify(event) + "\n");
|
|
24
|
+
}
|
|
25
|
+
replaceAssert(event) {
|
|
26
|
+
const all = this.load().map((e) => (e.kind === "assert" && e.eventId === event.eventId ? event : e));
|
|
27
|
+
const tmp = `${this.location}.tmp`;
|
|
28
|
+
writeFileSync(tmp, all.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
29
|
+
renameSync(tmp, this.location);
|
|
30
|
+
}
|
|
31
|
+
close() { }
|
|
32
|
+
}
|
|
33
|
+
export class SqliteStore {
|
|
34
|
+
kind = "sqlite";
|
|
35
|
+
location;
|
|
36
|
+
db;
|
|
37
|
+
constructor(file) {
|
|
38
|
+
this.location = file;
|
|
39
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
40
|
+
// Loaded on demand so a JSONL ledger never touches the SQLite module, which warns on Node 22 that it is experimental.
|
|
41
|
+
const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
|
|
42
|
+
this.db = new DatabaseSync(file);
|
|
43
|
+
// secure_delete overwrites removed content with zeros, so a forgotten value does not linger in freed page space.
|
|
44
|
+
this.db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA secure_delete = ON;");
|
|
45
|
+
this.db.exec("CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, tx_time TEXT NOT NULL, fact_id TEXT NOT NULL, json TEXT NOT NULL)");
|
|
46
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS events_fact ON events(fact_id); CREATE INDEX IF NOT EXISTS events_tx ON events(tx_time)");
|
|
47
|
+
}
|
|
48
|
+
load() {
|
|
49
|
+
return this.db.prepare("SELECT json FROM events ORDER BY seq").all().map((r) => JSON.parse(r.json));
|
|
50
|
+
}
|
|
51
|
+
append(event) {
|
|
52
|
+
const factId = event.kind === "assert" ? event.fact.factId : event.factId;
|
|
53
|
+
this.db.prepare("INSERT INTO events (event_id, kind, tx_time, fact_id, json) VALUES (?, ?, ?, ?, ?)").run(event.eventId, event.kind, event.txTime, factId, JSON.stringify(event));
|
|
54
|
+
}
|
|
55
|
+
replaceAssert(event) {
|
|
56
|
+
this.db.prepare("UPDATE events SET json = ? WHERE event_id = ?").run(JSON.stringify(event), event.eventId);
|
|
57
|
+
// The old row image would otherwise linger in the write-ahead log; a truncating checkpoint removes it.
|
|
58
|
+
this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
59
|
+
}
|
|
60
|
+
close() {
|
|
61
|
+
this.db.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** JSONL unless the path ends in .sqlite or .db. */
|
|
65
|
+
export function openStore(location) {
|
|
66
|
+
return /\.(sqlite|db)$/i.test(location) ? new SqliteStore(location) : new JsonlStore(location);
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-custody/state",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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.8",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
38
|
"zod": "^4.5.4"
|
|
39
39
|
},
|