@agent-custody/state 0.5.5 → 0.5.6

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 CHANGED
@@ -142,9 +142,11 @@ WHAT NEEDS REVERSAL 2 belief(s) still believed, and 3 later call(s) to
142
142
 
143
143
  The first eight lines come from the receipt alone and work without a ledger; the last two are answered from the ledger, and without one they say `unknown without a ledger` rather than pretending nothing depended on the call. `--out action.json --sign keys/pack.key` writes the same answers as one signed action pack with the receipt and every downstream receipt inside it, and `explain --verify action.json --key keys/pack.pub --issuer-key ... --principal-key ...` checks the pack's signature and digest, the receipt, every downstream receipt, and that the written beliefs cite this receipt. Touch one receipt inside and the pack fails. The custody pack below is the same artefact seen from a fact instead of an action.
144
144
 
145
+ **The review page.** `agent-custody-memory review --receipts receipts --ledger ledger.sqlite --issuer-key keys/gateway.pub --principal-key keys/principal.pub` serves the same answers as pages on loopback: an index of every receipt with when, tool, outcome, agent, principal, producer, and its verdict, and one page per receipt with the ten answers, the verification report, and the receipt itself to download. `--out <dir>` writes the same as static files for a case file or a shared drive. It runs where the receipts are, because nobody else holds them; it has no login of its own, so expose it only behind one you already have.
146
+
145
147
  ## Write-through to the stores you already use
146
148
 
147
- 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.
149
+ 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. For teams whose recall is a pgvector table, `pgvectorStore` writes the fact's text, its embedding from the function you already use, and its custody metadata as one row keyed by the fact id, and verifies a removal with the same nearest-neighbour query a retrieval call runs.
148
150
 
149
151
  ```ts
150
152
  import { MemoryClient } from "mem0ai";
@@ -154,6 +156,7 @@ import { Ledger, createMemoryServer, mem0Store, zepStore } from "@agent-custody/
154
156
  const stores = [
155
157
  mem0Store(new MemoryClient({ apiKey: process.env.MEM0_API_KEY! }), { userId: "user_42" }), // infer is off: the memory is the fact, verbatim
156
158
  zepStore(new ZepClient({ apiKey: process.env.ZEP_API_KEY! }), { userId: "user_42" }), // or { graphId } for a shared graph
159
+ pgvectorStore(pool, { embed, dimensions: 1536, table: "agent_memories" }), // your Postgres, your embedding function
157
160
  ];
158
161
  createMemoryServer(new Ledger("./ledger.jsonl"), { stores });
159
162
  ```
@@ -213,10 +216,11 @@ src/storage.ts the store interface and its three stores: JSONL (default, audita
213
216
  src/server.ts the ledger as MCP tools; source and actor taken from the gateway's _meta
214
217
  src/http.ts the memory server over Streamable HTTP with bearer auth, for a shared ledger
215
218
  src/explain.ts one action explained: the ten answers from a receipt and the ledger, and the signed action pack
219
+ src/review.ts the review page: the explain output as pages, served on loopback or written as files
216
220
  src/pack.ts the custody pack: build, sign, verify, format
217
- src/cli.ts agent-custody-memory serve (stdio or --http, with --retention and --forget-key-env), sweep (ledger-only or --via a gateway), eval, explain, pack, export, blast
221
+ src/cli.ts agent-custody-memory serve (stdio or --http, with --retention and --forget-key-env), sweep (ledger-only or --via a gateway), eval, explain, review, pack, export, blast
218
222
  src/blast.ts blast radius: from receipts' consumed facts and the ledger's source receipts, forward
219
- src/stores.ts write-through adapters: Mem0 and Zep, and the Store interface for others
223
+ src/stores.ts write-through adapters: Mem0, Zep, and pgvector, and the Store interface for others
220
224
  src/evals.ts the memory-mutation harness: scenarios, scoring, report
221
225
  src/evals-ledger.ts the ledger and a naive overwrite store behind the harness interface
222
226
  src/evals-file.ts scenario files, validated; signed eval reports and their verification
@@ -234,6 +238,7 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
234
238
  - 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.
235
239
  - Forget digests are keyed under a server-held secret, or absent on request, so an erased value cannot be guessed back from the file.
236
240
  - 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.
241
+ - The review page: the explain output as pages for the reviewer who will not open a terminal, an index of every receipt with its verdict and one page per receipt, served on loopback or written as static files.
237
242
  - Explain one action: from a receipt id, who, who authorized it, what was allowed, what the agent saw, what it did, why, the evidence, whether it verifies, what depended on it, and what needs reversal; the same as a signed action pack that carries every downstream receipt.
238
243
  - The custody pack: a fact's history with receipts, holds, blast radius, and forget certificate as one signed artefact, verified as a whole.
239
244
  - Removal verification: after a retraction, forget, or sweep the server asks each store's search whether the value still surfaces and records verified, stillIndexed, unverified, or failed per store, in the receipt.
@@ -244,6 +249,7 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
244
249
  - 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.
245
250
  - 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.
246
251
  - 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.
252
+ - Write-through to pgvector: the fact's text, embedding, and custody metadata as one row in the Postgres a team already runs, keyed by the fact id; removal verified by the same nearest-neighbour query recall runs; tested against Postgres with the extension in Docker.
247
253
  - 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.
248
254
  - 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.
249
255
  - 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.
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.js";
13
13
  import { loadScenarios, signReport, verifyReport } from "./evals-file.js";
14
14
  import { buildPack, formatPack, signPack, verifyPack } from "./pack.js";
15
15
  import { buildActionPack, formatExplain, signActionPack, verifyActionPack } from "./explain.js";
16
+ import { serveReview, writeReview } from "./review.js";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
19
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
@@ -68,6 +69,11 @@ const USAGE = `agent-custody-memory <command>
68
69
  explain ... --out <action.json> --sign <key> the same as one signed action pack, every downstream receipt inside
69
70
  explain --verify <action.json> --key <pub> [--issuer-key <pub>] [--principal-key <pub>] [--log-key <pub>]
70
71
  checks the pack, the receipt inside it, and every downstream receipt
72
+ review --receipts <dir> [--ledger <ledger>] [--issuer-key <pub>] [--principal-key <pub>] [--log-key <pub>] [--log-id <id>] [--port 8791] [--host 127.0.0.1] [--title <text>]
73
+ the explain output as pages, for the reviewer who will not open a terminal: an index of every
74
+ receipt with its verdict, one page per receipt with the ten answers and the verification report.
75
+ Serves on loopback; put it behind your own login if you expose it.
76
+ review ... --out <dir> the same as static files, for a case file or a shared drive
71
77
  export --ledger <ledger.sqlite|postgres://…> --out <ledger.jsonl> the auditable JSONL of any ledger, one event per line; also the feed for a warehouse
72
78
  blast --ledger <ledger.jsonl> --receipts <dir> --fact <factId> [--json]
73
79
  everything that relied on a fact: later calls, derived beliefs, and whether it was retracted
@@ -233,6 +239,26 @@ async function main(argv) {
233
239
  console.log(formatExplain(pack, verification, !!ledger));
234
240
  return verification && !verification.ok ? 1 : pack.missingReceipts.length ? 1 : 0;
235
241
  }
242
+ case "review": {
243
+ const { values } = parseArgs({ args: rest, options: { receipts: { type: "string" }, ledger: { type: "string" }, "issuer-key": { type: "string", multiple: true }, "principal-key": { type: "string", multiple: true }, "log-key": { type: "string", multiple: true }, "log-id": { type: "string" }, port: { type: "string", default: "8791" }, host: { type: "string", default: "127.0.0.1" }, title: { type: "string" }, out: { type: "string" } } });
244
+ if (!values.receipts)
245
+ throw new Error("review needs --receipts");
246
+ const keys = values["issuer-key"]?.length ? { issuerKeys: values["issuer-key"].map(loadPublicKey), principalKeys: (values["principal-key"] ?? []).map(loadPublicKey), ...(values["log-key"]?.length ? { logKeys: values["log-key"].map(loadPublicKey) } : {}), ...(values["log-id"] ? { logId: values["log-id"] } : {}) } : undefined;
247
+ const ledger = values.ledger ? new Ledger(values.ledger) : undefined;
248
+ const o = { receiptsDir: values.receipts, ...(ledger ? { ledger } : {}), ...(keys ? { keys } : {}), ...(values.title ? { title: values.title } : {}) };
249
+ if (values.out) {
250
+ const r = await writeReview(o, values.out);
251
+ console.log(`wrote ${r.receipts} receipt page(s) and the index to ${values.out}`);
252
+ await ledger?.close();
253
+ return 0;
254
+ }
255
+ const running = await serveReview(o, { port: Number(values.port), host: values.host });
256
+ console.error(`agent-custody-memory review: ${running.url} receipts=${values.receipts}${ledger ? ` ledger=${values.ledger}` : ""}${keys ? " verifying" : " not verifying (no keys given)"}`);
257
+ await new Promise((resolve) => process.once("SIGINT", resolve));
258
+ await running.close();
259
+ await ledger?.close();
260
+ return 0;
261
+ }
236
262
  case "export": {
237
263
  const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, out: { type: "string" } } });
238
264
  if (!values.ledger || !values.out)
package/dist/index.d.ts CHANGED
@@ -9,13 +9,15 @@ export type { MemoryUnderTest, Op, Report, Scenario, ScenarioScore } from "./eva
9
9
  export { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.ts";
10
10
  export { EVAL_REPORT_TYPE, ScenarioFileSchema, loadScenarios, signReport, verifyReport } from "./evals-file.ts";
11
11
  export type { EvalReportPredicate, ReportCheck, ScenarioFile } from "./evals-file.ts";
12
- export { factMetadata, factText, mem0Store, zepStore } from "./stores.ts";
12
+ export { factMetadata, factText, mem0Store, pgvectorStore, zepStore } from "./stores.ts";
13
13
  export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.ts";
14
14
  export { memoryHttpHandler, serveMemoryHttp } from "./http.ts";
15
15
  export { PACK_TYPE, buildPack, formatPack, signPack, verifyPack } from "./pack.ts";
16
16
  export { ACTION_PACK_TYPE, buildActionPack, decodeStatement, formatExplain, signActionPack, verifyActionPack } from "./explain.ts";
17
+ export { listReceipts, renderIndex, renderReceipt, reviewHandler, serveReview, writeReview } from "./review.ts";
18
+ export type { ReceiptRow, ReviewOptions, RunningReview } from "./review.ts";
17
19
  export type { ActionPack, ActionPackCheck, ActionPackVerification, ReceiptKeys } from "./explain.ts";
18
20
  export type { CustodyPack, PackCheck, PackVerification } from "./pack.ts";
19
21
  export type { MemoryHttpOptions, RunningMemoryServer } from "./http.ts";
20
22
  export type { BlastRadius, ReceiptSummary } from "./blast.ts";
21
- export type { Mem0Like, Mem0Options, RemovalOutcome, Store, ZepLike, ZepOptions } from "./stores.ts";
23
+ export type { Mem0Like, Mem0Options, PgLike, PgvectorOptions, RemovalOutcome, Store, ZepLike, ZepOptions } from "./stores.ts";
package/dist/index.js CHANGED
@@ -5,8 +5,9 @@ export { AGENT_META_KEY, FACTS_META_KEY, OBSERVED_META_KEY, RECEIPT_META_KEY, SE
5
5
  export { SCENARIOS, formatReport, runAll, runScenario } from "./evals.js";
6
6
  export { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.js";
7
7
  export { EVAL_REPORT_TYPE, ScenarioFileSchema, loadScenarios, signReport, verifyReport } from "./evals-file.js";
8
- export { factMetadata, factText, mem0Store, zepStore } from "./stores.js";
8
+ export { factMetadata, factText, mem0Store, pgvectorStore, zepStore } from "./stores.js";
9
9
  export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
10
10
  export { memoryHttpHandler, serveMemoryHttp } from "./http.js";
11
11
  export { PACK_TYPE, buildPack, formatPack, signPack, verifyPack } from "./pack.js";
12
12
  export { ACTION_PACK_TYPE, buildActionPack, decodeStatement, formatExplain, signActionPack, verifyActionPack } from "./explain.js";
13
+ export { listReceipts, renderIndex, renderReceipt, reviewHandler, serveReview, writeReview } from "./review.js";
package/dist/ledger.d.ts CHANGED
@@ -96,6 +96,8 @@ export interface HoldEvent {
96
96
  }
97
97
  export type LedgerEvent = AssertEvent | RetractEvent | ConfirmEvent | ForgetEvent | HoldEvent;
98
98
  export interface AssertInput {
99
+ /** the fact's id, when the caller had to know it before the append, as the memory server does for its stores; default a fresh UUID */
100
+ factId?: string;
99
101
  subject: string;
100
102
  predicate: string;
101
103
  value: unknown;
package/dist/ledger.js CHANGED
@@ -62,7 +62,7 @@ export class Ledger {
62
62
  kind: "assert",
63
63
  txTime,
64
64
  fact: {
65
- factId: randomUUID(),
65
+ factId: input.factId ?? randomUUID(),
66
66
  subject: input.subject,
67
67
  predicate: input.predicate,
68
68
  value: input.value,
@@ -0,0 +1,42 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { type VerifyOptions, type VerifyResult } from "@agent-custody/receipts";
3
+ import { type ActionPack } from "./explain.ts";
4
+ import type { Ledger } from "./ledger.ts";
5
+ export interface ReviewOptions {
6
+ receiptsDir: string;
7
+ ledger?: Ledger;
8
+ /** with these, every receipt page carries its verification report */
9
+ keys?: Pick<VerifyOptions, "issuerKeys" | "principalKeys" | "logKeys" | "logId">;
10
+ /** shown in the page header, e.g. the team or the deployment */
11
+ title?: string;
12
+ }
13
+ export interface ReceiptRow {
14
+ receiptId: string;
15
+ timestamp: string;
16
+ tool: string;
17
+ status: string;
18
+ agent: string;
19
+ principal: string | null;
20
+ issuer: string;
21
+ verified: boolean | null;
22
+ }
23
+ /** Every receipt in the directory, newest first, with its verdict when keys were given. Damaged files are skipped. */
24
+ export declare function listReceipts(o: ReviewOptions): ReceiptRow[];
25
+ /** The index: one row per receipt, newest first. */
26
+ export declare function renderIndex(rows: ReceiptRow[], o: ReviewOptions, link: (id: string) => string): string;
27
+ /** One receipt's page: the ten answers, the verification report, and the receipt itself. */
28
+ export declare function renderReceipt(pack: ActionPack, verification: VerifyResult | null, o: ReviewOptions, backLink: string, rawLink: string): string;
29
+ /** Serves the review on loopback: / is the index, /r/<id> a receipt, /r/<id>.json the bundle. */
30
+ export declare function reviewHandler(o: ReviewOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
31
+ export interface RunningReview {
32
+ url: string;
33
+ close(): Promise<void>;
34
+ }
35
+ export declare function serveReview(o: ReviewOptions, opts: {
36
+ port: number;
37
+ host?: string;
38
+ }): Promise<RunningReview>;
39
+ /** Writes the same pages as static files: index.html, and r/<id>.html with r/<id>.json beside it. For a case file or a shared drive. */
40
+ export declare function writeReview(o: ReviewOptions, outDir: string): Promise<{
41
+ receipts: number;
42
+ }>;
package/dist/review.js ADDED
@@ -0,0 +1,150 @@
1
+ // The review page: the explain command for people who will not open a terminal. It serves, or writes as static
2
+ // files, one page per receipt with the ten answers and the verification report, and an index of every receipt in
3
+ // the directory. It runs where the receipts are, on the customer's machine, because nobody else holds them; the
4
+ // hosted log has only their hashes. No framework, no outside requests, and no authentication of its own: bind it
5
+ // to loopback, or put it behind whatever already guards the machine.
6
+ import { readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { createServer } from "node:http";
8
+ import { join } from "node:path";
9
+ import { verifyBundle } from "@agent-custody/receipts";
10
+ import { buildActionPack, decodeStatement, formatExplain } from "./explain.js";
11
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
12
+ /** Every receipt in the directory, newest first, with its verdict when keys were given. Damaged files are skipped. */
13
+ export function listReceipts(o) {
14
+ const rows = [];
15
+ for (const f of readdirSync(o.receiptsDir)) {
16
+ if (!f.endsWith(".json") || f.endsWith(".authorization.json"))
17
+ continue;
18
+ try {
19
+ const bundle = JSON.parse(readFileSync(join(o.receiptsDir, f), "utf8"));
20
+ const p = decodeStatement(bundle).predicate;
21
+ const verified = o.keys ? verifyBundle(bundle, { issuerKeys: o.keys.issuerKeys, principalKeys: o.keys.principalKeys ?? [], ...(o.keys.logKeys ? { logKeys: o.keys.logKeys } : {}), ...(o.keys.logId ? { logId: o.keys.logId } : {}) }).ok : null;
22
+ rows.push({ receiptId: p.receiptId, timestamp: p.timestamp, tool: p.tool.name, status: p.execution.status, agent: p.agent.id, principal: p.principal.id, issuer: p.issuer.kind, verified });
23
+ }
24
+ catch {
25
+ // not a bundle; the index is of receipts
26
+ }
27
+ }
28
+ return rows.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
29
+ }
30
+ const STYLE = `
31
+ :root { color-scheme: light dark; --ink: #1b2430; --ink2: #5b6b7a; --line: #d7dfe5; --bg: #fafbfc; --panel: #ffffff; --accent: #0f6e63; --bad: #b3261e; --mono: ui-monospace, Menlo, monospace; }
32
+ @media (prefers-color-scheme: dark) { :root { --ink: #e6ecf0; --ink2: #9fb0bd; --line: #27333c; --bg: #0e1418; --panel: #151d23; --accent: #4fc3b0; --bad: #ff8a80; } }
33
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
34
+ main { max-width: 74rem; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
35
+ h1 { font-size: 1.4rem; margin: 0 0 .25rem; } h2 { font-size: 1.05rem; margin: 1.75rem 0 .6rem; }
36
+ .sub { color: var(--ink2); margin: 0 0 1.25rem; }
37
+ a { color: var(--accent); }
38
+ table { border-collapse: collapse; width: 100%; font-size: .93rem; }
39
+ th, td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; white-space: nowrap; }
40
+ th { font-size: .78rem; letter-spacing: .04em; text-transform: uppercase; color: var(--ink2); }
41
+ code, pre, .mono { font-family: var(--mono); font-size: .88em; }
42
+ pre { background: var(--panel); border: 1px solid var(--line); border-radius: 4px; padding: .9rem 1rem; overflow-x: auto; white-space: pre-wrap; }
43
+ dl { display: grid; grid-template-columns: max-content 1fr; gap: .45rem 1.25rem; margin: 0; }
44
+ dt { font-size: .78rem; letter-spacing: .04em; text-transform: uppercase; color: var(--ink2); padding-top: .15rem; }
45
+ dd { margin: 0; white-space: pre-wrap; }
46
+ .ok { color: var(--accent); font-weight: 600; } .bad { color: var(--bad); font-weight: 600; } .muted { color: var(--ink2); }
47
+ .wrap { overflow-x: auto; }
48
+ @media (max-width: 40rem) { dl { grid-template-columns: 1fr; } }
49
+ `;
50
+ const page = (title, body) => `<!doctype html>
51
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
52
+ <meta http-equiv="content-security-policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">
53
+ <title>${esc(title)}</title>
54
+ <style>${STYLE}</style>
55
+ <main>${body}</main>
56
+ `;
57
+ /** The index: one row per receipt, newest first. */
58
+ export function renderIndex(rows, o, link) {
59
+ const withKeys = !!o.keys;
60
+ const verdict = (r) => (r.verified === null ? '<span class="muted">not checked</span>' : r.verified ? '<span class="ok">verified</span>' : '<span class="bad">NOT VERIFIED</span>');
61
+ const body = `
62
+ <h1>${esc(o.title ?? "Receipts")}</h1>
63
+ <p class="sub">${rows.length} receipt(s) in ${esc(o.receiptsDir)}${withKeys ? ", each checked against the keys given" : ", not checked: start the review with the gateway's and principal's public keys to verify"}${o.ledger ? "; beliefs from the ledger" : "; no ledger, so what depended on a call is not known here"}.</p>
64
+ <div class="wrap"><table><thead><tr><th>when</th><th>tool</th><th>outcome</th><th>agent</th><th>principal</th><th>producer</th><th>verification</th><th>receipt</th></tr></thead><tbody>
65
+ ${rows.map((r) => `<tr><td>${esc(r.timestamp.replace("T", " ").slice(0, 19))}</td><td><code>${esc(r.tool)}</code></td><td>${esc(r.status)}</td><td>${esc(r.agent)}</td><td>${esc(r.principal ?? "")}</td><td>${esc(r.issuer)}</td><td>${verdict(r)}</td><td><a class="mono" href="${esc(link(r.receiptId))}">${esc(r.receiptId.slice(0, 8))}</a></td></tr>`).join("\n")}
66
+ </tbody></table></div>
67
+ <p class="muted">A gateway receipt was enforced outside the agent's process; an SDK receipt is the agent's own report. The verification column is the offline check any auditor can repeat with the same public keys.</p>`;
68
+ return page(o.title ?? "Receipts", body);
69
+ }
70
+ /** The explain text as a definition list: question, answer, continuation lines kept with their answer. */
71
+ function explainHtml(text) {
72
+ const items = [];
73
+ for (const line of text.split("\n")) {
74
+ const q = line.slice(0, 28).trim();
75
+ const a = line.slice(28).trimEnd();
76
+ if (q && /^[A-Z][A-Z ?]+$/.test(q))
77
+ items.push({ q, a: [a] });
78
+ else if (items.length)
79
+ items[items.length - 1].a.push(a);
80
+ }
81
+ return `<dl>${items.map((i) => `<dt>${esc(i.q)}</dt><dd>${esc(i.a.join("\n"))}</dd>`).join("")}</dl>`;
82
+ }
83
+ /** One receipt's page: the ten answers, the verification report, and the receipt itself. */
84
+ export function renderReceipt(pack, verification, o, backLink, rawLink) {
85
+ const p = decodeStatement(pack.receipt).predicate;
86
+ const report = verification ? verification.checks.map((c) => `${c.ok ? "PASS" : "FAIL"} ${c.name}${c.detail ? ` (${c.detail})` : ""}`).join("\n") + `\n\nRESULT: ${verification.ok ? "VERIFIED" : "NOT VERIFIED"}` : "Not checked: start the review with --issuer-key and --principal-key to verify every receipt here.";
87
+ const body = `
88
+ <p><a href="${esc(backLink)}">All receipts</a></p>
89
+ <h1>${esc(p.tool.name)} <span class="muted">${esc(p.execution.status)}</span></h1>
90
+ <p class="sub">${esc(p.timestamp.replace("T", " ").slice(0, 19))} · receipt <span class="mono">${esc(p.receiptId)}</span> · ${verification ? (verification.ok ? '<span class="ok">verified</span>' : '<span class="bad">NOT VERIFIED</span>') : '<span class="muted">not checked</span>'}</p>
91
+ <h2>What this action is</h2>
92
+ ${explainHtml(formatExplain(pack, verification, !!o.ledger))}
93
+ <h2>Verification</h2>
94
+ <pre>${esc(report)}</pre>
95
+ <h2>The receipt</h2>
96
+ <p><a href="${esc(rawLink)}">The bundle as issued</a>, which any verifier checks with <code>agent-custody verify</code> and the public keys.</p>`;
97
+ return page(`${p.tool.name} · ${p.receiptId.slice(0, 8)}`, body);
98
+ }
99
+ function verifyOf(bundle, o) {
100
+ return o.keys ? verifyBundle(bundle, { issuerKeys: o.keys.issuerKeys, principalKeys: o.keys.principalKeys ?? [], ...(o.keys.logKeys ? { logKeys: o.keys.logKeys } : {}), ...(o.keys.logId ? { logId: o.keys.logId } : {}) }) : null;
101
+ }
102
+ /** Serves the review on loopback: / is the index, /r/<id> a receipt, /r/<id>.json the bundle. */
103
+ export function reviewHandler(o) {
104
+ return async (req, res) => {
105
+ const url = new URL(req.url ?? "/", "http://localhost");
106
+ const send = (status, type, body) => {
107
+ res.writeHead(status, { "content-type": type, "cache-control": "no-store", "x-frame-options": "DENY", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:" });
108
+ res.end(body);
109
+ };
110
+ try {
111
+ if (url.pathname === "/")
112
+ return send(200, "text/html; charset=utf-8", renderIndex(listReceipts(o), o, (id) => `/r/${id}`));
113
+ const m = /^\/r\/([0-9a-f-]{36})(\.json)?$/.exec(url.pathname);
114
+ if (!m)
115
+ return send(404, "text/plain", "not found");
116
+ if (m[2])
117
+ return send(200, "application/json", readFileSync(join(o.receiptsDir, `${m[1]}.json`), "utf8"));
118
+ const pack = await buildActionPack(o.receiptsDir, m[1], o.ledger);
119
+ return send(200, "text/html; charset=utf-8", renderReceipt(pack, verifyOf(pack.receipt, o), o, "/", `/r/${m[1]}.json`));
120
+ }
121
+ catch (e) {
122
+ return send(e.code === "ENOENT" ? 404 : 500, "text/plain", e instanceof Error ? e.message : String(e));
123
+ }
124
+ };
125
+ }
126
+ export function serveReview(o, opts) {
127
+ const host = opts.host ?? "127.0.0.1";
128
+ const handler = reviewHandler(o);
129
+ const server = createServer((req, res) => {
130
+ void handler(req, res);
131
+ });
132
+ return new Promise((resolve) => {
133
+ server.listen(opts.port, host, () => {
134
+ const { port } = server.address();
135
+ resolve({ url: `http://${host}:${port}/`, close: () => new Promise((r) => server.close(() => r())) });
136
+ });
137
+ });
138
+ }
139
+ /** Writes the same pages as static files: index.html, and r/<id>.html with r/<id>.json beside it. For a case file or a shared drive. */
140
+ export async function writeReview(o, outDir) {
141
+ mkdirSync(join(outDir, "r"), { recursive: true });
142
+ const rows = listReceipts(o);
143
+ writeFileSync(join(outDir, "index.html"), renderIndex(rows, o, (id) => `r/${id}.html`));
144
+ for (const r of rows) {
145
+ const pack = await buildActionPack(o.receiptsDir, r.receiptId, o.ledger);
146
+ writeFileSync(join(outDir, "r", `${r.receiptId}.html`), renderReceipt(pack, verifyOf(pack.receipt, o), o, "../index.html", `${r.receiptId}.json`));
147
+ writeFileSync(join(outDir, "r", `${r.receiptId}.json`), JSON.stringify(pack.receipt, null, 2));
148
+ }
149
+ return { receipts: rows.length };
150
+ }
package/dist/server.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // call's _meta, which receipt it is and who the attested grant says is calling. Those become the fact's source and
4
4
  // actor; a caller cannot supply them. Run it directly and the source is null and the actor is whatever the caller
5
5
  // claims, which is recorded as such.
6
+ import { randomUUID } from "node:crypto";
6
7
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
9
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -200,13 +201,15 @@ export function createMemoryServer(ledger, opts = {}) {
200
201
  }
201
202
  const input = { subject: a.subject, predicate: a.predicate, value: a.value ?? null, space: a.space, actor: actorFor(a.actor), source: { receiptId }, provenance: writeProvenance, ...(a.validFrom ? { validFrom: a.validFrom } : {}), ...(a.confidence !== undefined ? { confidence: a.confidence } : {}), ...(a.supersedes ? { supersedes: a.supersedes } : {}) };
202
203
  // The stores are written first, so their ids can be recorded on the fact; the ledger's checks run beforehand
203
- // so a write the ledger would refuse never reaches a store.
204
+ // so a write the ledger would refuse never reaches a store. The fact id is chosen here, before the stores
205
+ // see the fact, so the metadata they keep names the real id and a store may key its row by it.
204
206
  await ledger.validateAssert(input);
205
207
  const external = {};
206
- const preview = { ...input, factId: "pending", validFrom: input.validFrom ?? new Date().toISOString(), validTo: null, confidence: input.confidence ?? null };
208
+ const factId = randomUUID();
209
+ const preview = { ...input, factId, validFrom: input.validFrom ?? new Date().toISOString(), validTo: null, confidence: input.confidence ?? null };
207
210
  for (const store of opts.stores ?? [])
208
211
  external[store.name] = await store.put(preview);
209
- const ev = await ledger.assert({ ...input, external });
212
+ const ev = await ledger.assert({ ...input, factId, external });
210
213
  return json({ fact: ev.fact, eventId: ev.eventId, txTime: ev.txTime, supersedes: ev.supersedes });
211
214
  }
212
215
  case "memory.read": {
package/dist/stores.d.ts CHANGED
@@ -82,3 +82,25 @@ export type ZepOptions = {
82
82
  userId?: undefined;
83
83
  };
84
84
  export declare function zepStore(client: ZepLike, opts: ZepOptions): Store;
85
+ /** What the pgvector adapter needs from a client: the query method of a pg Pool or of PGlite. */
86
+ export interface PgLike {
87
+ query(text: string, values?: unknown[]): Promise<{
88
+ rows: unknown[];
89
+ }>;
90
+ }
91
+ export interface PgvectorOptions {
92
+ /** turns the fact's text into the vector the table indexes; the same function the retrieval side uses */
93
+ embed: (text: string) => Promise<number[]>;
94
+ /** the vector's length, fixed per table */
95
+ dimensions: number;
96
+ /** table name, optionally schema-qualified; default "agent_memories"; created if missing */
97
+ table?: string;
98
+ /** how many nearest rows verifyRemoved inspects for the removed id; default 10 */
99
+ topK?: number;
100
+ }
101
+ /**
102
+ * Write-through to a pgvector table: the fact's text, its embedding, and its custody metadata as one row, keyed by
103
+ * the fact id, in the Postgres a team already runs. Removal deletes the row, and verification embeds the text again
104
+ * and checks the removed id is not among the nearest rows, which is what a retrieval query would return.
105
+ */
106
+ export declare function pgvectorStore(client: PgLike, opts: PgvectorOptions): Store;
package/dist/stores.js CHANGED
@@ -56,3 +56,43 @@ export function zepStore(client, opts) {
56
56
  : {}),
57
57
  };
58
58
  }
59
+ /**
60
+ * Write-through to a pgvector table: the fact's text, its embedding, and its custody metadata as one row, keyed by
61
+ * the fact id, in the Postgres a team already runs. Removal deletes the row, and verification embeds the text again
62
+ * and checks the removed id is not among the nearest rows, which is what a retrieval query would return.
63
+ */
64
+ export function pgvectorStore(client, opts) {
65
+ const table = opts.table ?? "agent_memories";
66
+ if (!/^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$/.test(table))
67
+ throw new Error(`pgvector: table must be a plain identifier, optionally schema-qualified; got "${table}"`);
68
+ if (!(Number.isInteger(opts.dimensions) && opts.dimensions > 0))
69
+ throw new Error("pgvector: dimensions must be a positive integer");
70
+ let ready = null;
71
+ const init = () => (ready ??= (async () => {
72
+ await client.query("CREATE EXTENSION IF NOT EXISTS vector");
73
+ await client.query(`CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, text TEXT NOT NULL, embedding vector(${opts.dimensions}) NOT NULL, metadata JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`);
74
+ })());
75
+ const literal = (v) => {
76
+ if (v.length !== opts.dimensions)
77
+ throw new Error(`pgvector: embedding has ${v.length} dimensions, the table has ${opts.dimensions}`);
78
+ return `[${v.join(",")}]`;
79
+ };
80
+ return {
81
+ name: "pgvector",
82
+ async put(fact) {
83
+ await init();
84
+ const text = factText(fact);
85
+ await client.query(`INSERT INTO ${table} (id, text, embedding, metadata) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET text = EXCLUDED.text, embedding = EXCLUDED.embedding, metadata = EXCLUDED.metadata`, [fact.factId, text, literal(await opts.embed(text)), JSON.stringify(factMetadata(fact))]);
86
+ return fact.factId;
87
+ },
88
+ async remove(externalId) {
89
+ await init();
90
+ await client.query(`DELETE FROM ${table} WHERE id = $1`, [externalId]);
91
+ },
92
+ async verifyRemoved(externalId, fact) {
93
+ await init();
94
+ const rows = (await client.query(`SELECT id FROM ${table} ORDER BY embedding <=> $1 LIMIT $2`, [literal(await opts.embed(factText(fact))), opts.topK ?? 10])).rows;
95
+ return !rows.some((r) => r.id === externalId);
96
+ },
97
+ };
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/state",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Governed memory for AI agents: a fact ledger with provenance, valid time, rollback, lineage, certified forget, and a store that is a JSONL file, SQLite, or Postgres, 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.5.5",
36
+ "@agent-custody/receipts": "0.5.6",
37
37
  "@modelcontextprotocol/sdk": "^1.30.0",
38
38
  "zod": "^4.5.4"
39
39
  },