@cef-ai/testing 3.5.3 → 4.0.0

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.
@@ -1,191 +1,645 @@
1
1
  /**
2
- * A memory bank you can run a workflow against.
2
+ * THE FAKE MEMORY BANK — vault-api's memory door, in memory (contract C2 §5).
3
3
  *
4
- * WHY THIS HAD TO EXIST. `ctx.memory` was a stub that threw — nine tools, one
5
- * message saying to assert on it through a fetch mock. That was defensible
6
- * while memory was something an agent occasionally reached for. It stopped
7
- * being defensible when `remember`, `relate` and `recall` became NODES: the
8
- * runner now executes them itself, inline, and a workflow's durable output goes
9
- * through this path. A runner feature with no simulator is a runner feature
10
- * whose tests can only assert that it was ATTEMPTED.
4
+ * A JS model of the bank's own columns (the parity test diffs them against a
5
+ * SHA-pinned copy of ddc-node's DDL, `fixtures/vault-memory-ddl.sql`), served
6
+ * behind a fetch-level copy of the REST door: `POST <base>/<tool>` with the
7
+ * real request bodies, the real `{columns, rows}` projections, 204 writes, and
8
+ * vault-api's `{"error":{"code","message"}}` refusals. The agent-sdk's own
9
+ * `ctx.memory` shim runs against it unmodified — the fake is no longer a
10
+ * parallel object with method signatures of its own.
11
11
  *
12
- * That gap has a bill attached. WF1 reported "done · 6 of 8 steps ran" with
13
- * nothing persisted, twice, for two different reasons — a runner that walked
14
- * past unknown kinds in silence, and inline steps that left no trace — and
15
- * neither was caught by a unit test, because no unit test could reach the
16
- * write.
12
+ * FAITHFUL ON PURPOSE, where the old fake was not:
13
+ * - Reads are gated: scope ∈ the connection's scopes, privacy ≤ its ceiling.
14
+ * A hidden record and an absent one look the same.
15
+ * - Statuses: a new row with no status lands `accepted`; reads return
16
+ * accepted rows unless `statuses` says otherwise; `statuses` with `status`
17
+ * or `include_unaccepted` is a 400.
18
+ * - The write policy. The default is `curators` (the D1 target, and devnet):
19
+ * an ordinary connection's write is a 403; a harvester's upsert is forced
20
+ * to `proposed` and never rewrites a decided row; an analyzer writes only
21
+ * `metric:`/`obs:` ids and metric keys the rule admits.
22
+ * - A write is acked alike whether or not it landed: a hidden target, an edge
23
+ * to an invisible end, a metric on a decided record all answer 204 and
24
+ * change nothing.
25
+ * - Per platform, never a module singleton.
17
26
  *
18
- * FAITHFUL WHERE IT MATTERS, AND SMALL EVERYWHERE ELSE. Three behaviours are
19
- * real because workflows depend on them:
27
+ * APPROXIMATED, and said so: search is case-insensitive term matching over
28
+ * id, type, title and body (all terms, a trailing `*` makes a prefix), not
29
+ * FTS5 with stemming and bm25. `rank` is the negated hit count.
20
30
  *
21
- * UPSERT ON ID. `record_upsert` is idempotent on the record id, which is the
22
- * whole safety property of the store: events redeliver, and a bank where one
23
- * candidate appears four times makes every report over it quietly wrong. A
24
- * fake that appended would let a double-write pass.
25
- *
26
- * PRIVACY IS REFUSED AT THE DOOR, not defaulted. The real bank rejects a
27
- * write that does not classify itself, and these records are about real
28
- * people. A fake that accepted one would let a workflow ship that the store
29
- * then rejects in production.
30
- *
31
- * SEARCH NEEDS A TERM. The index is FTS5, so an empty match matches NOTHING
32
- * rather than everything. A fake that returned everything for "" would make
33
- * a node that silently reads nothing look like it works.
34
- *
35
- * Matching is substring rather than FTS5, with a trailing `*` tolerated, which
36
- * is close enough to test a graph and not close enough to test a query.
37
- *
38
- * PROPS, CREATED_AT AND DOMAIN follow the bank's rules too, because a view
39
- * that draws records reads them: props must be one object, a date may not be
40
- * in the future, and a write that leaves either out keeps what the record
41
- * already has (a new record is stamped now). Domain is the exception: the bank
42
- * overwrites it from every write, so leaving it out clears it. A read returns props as JSON
43
- * text, as the bank's own row does, so a workflow that parses them in
44
- * production parses them here; `bank.records` holds the object, for
45
- * assertions.
31
+ * `c3` (default true) models vault-api with contract C3: `id_prefix`,
32
+ * `domains` on `record_list`, the review stamp, the analyzer key rule, the
33
+ * `involves` harvester edge, and a 403 for a write above the writer's ceiling.
34
+ * `c3: false` models the vault-api deployed before it: those facets are
35
+ * IGNORED (as Go's JSON decoder ignores an unknown field), analyzer keys are
36
+ * the nine of the old allow-list, and an insert above the ceiling lands where
37
+ * its writer cannot see it.
46
38
  */
47
- const str = (v) => (typeof v === "string" ? v : "");
48
- /** How far ahead of now a sent date may be, as the bank allows for clock skew. */
49
- const CREATED_AT_SKEW_MS = 5 * 60 * 1000;
50
- /** A sent props value, refused as the bank refuses it: it must be one object. */
51
- const propsOf = (v) => {
52
- if (v === undefined || v === null)
53
- return undefined;
54
- if (typeof v !== "object" || Array.isArray(v))
55
- throw new Error("[bank] props must be a JSON object");
56
- return v;
57
- };
58
- /** A sent date, refused as the bank refuses it: RFC 3339 and not in the future. */
59
- const createdAtOf = (v) => {
60
- const at = str(v);
61
- if (!at)
62
- return undefined;
63
- const ms = Date.parse(at);
64
- if (Number.isNaN(ms))
65
- throw new Error(`[bank] created_at must be an RFC 3339 timestamp (got "${at}")`);
66
- if (ms > Date.now() + CREATED_AT_SKEW_MS)
67
- throw new Error(`[bank] created_at must not be in the future (got "${at}")`);
68
- return at;
39
+ import { createMemoryHandle } from "@cef-ai/agent-sdk/runtime";
40
+ /* -------------------------------------------------------------------------- */
41
+ /* the model — named and ordered as the bank's own columns */
42
+ /* -------------------------------------------------------------------------- */
43
+ /** `record`'s columns (ddc-node memory migrations 0001, 0002, 0007; C3 §1.4). */
44
+ export const RECORD_COLUMNS = [
45
+ "id", "type", "title", "body", "scope", "privacy", "created_at", "updated_at",
46
+ "domain", "status", "version", "props", "reviewed_by", "reviewed_at",
47
+ ];
48
+ /** `relation`'s columns (0001, 0002). */
49
+ export const RELATION_COLUMNS = [
50
+ "id", "in_id", "out_id", "type", "scope", "privacy", "created_at", "updated_at", "domain",
51
+ ];
52
+ /** `record_metric`'s columns (0002). */
53
+ export const RECORD_METRIC_COLUMNS = ["record_id", "key", "value"];
54
+ /**
55
+ * Columns the model carries ahead of the pinned DDL because an approved
56
+ * contract adds them and the migration is not merged yet. The parity test
57
+ * fails when one of these appears in the DDL, so the list is emptied the day
58
+ * the fixture is refreshed past that migration.
59
+ */
60
+ export const PENDING_COLUMNS = {
61
+ record: ["reviewed_by", "reviewed_at"], // C3 §1.4 review stamp, memory schema v9
69
62
  };
63
+ /* -------------------------------------------------------------------------- */
64
+ /* rules mirrored from vault-api (ddc-node handlers/memory.go, memory/*.go) */
65
+ /* -------------------------------------------------------------------------- */
66
+ const PRIVACIES = ["public", "internal", "private", "restricted"];
67
+ const rank = (p) => PRIVACIES.indexOf(p);
68
+ const STATUSES = ["proposed", "accepted", "rejected", "superseded"];
69
+ const PLATFORM_EDGES = ["used", "produced", "ran_by", "caused_by"];
70
+ const HARVESTER_EDGES_PRE_C3 = ["derived_from", "supersedes", "about"];
71
+ const HARVESTER_EDGES_C3 = [...HARVESTER_EDGES_PRE_C3, "involves"];
72
+ const ANALYZER_EDGES = ["derived_from", "about"];
73
+ const ANALYZER_KEYS_PRE_C3 = ["n", "decided", "hits", "hit_rate", "base_rate", "lift", "window_days", "config_version", "dropped"];
74
+ const ANALYZER_KEY_RULE = /^[a-z][a-z0-9_]{0,62}$/;
75
+ const LEDGER_KEYS = ["a2a_cost", "a2a_tokens", "duration_ms"];
76
+ const MAX_LIMIT = 500;
77
+ const DEFAULT_LIMIT = 50;
78
+ const DEFAULT_BODY_CHARS = 4000;
79
+ const MAX_BODY_CHARS = 20000;
80
+ const MAX_PROPS_BYTES = 16 * 1024;
81
+ const CREATED_AT_SKEW_MS = 5 * 60 * 1000;
82
+ const WORKFLOW_AGENT_REFUSAL = "memory: workflow agents write to cubbies; only harvester connections write to the "
83
+ + "memory bank — write this run's output to a cubby, or ask the vault owner to mark this connection as a harvester";
84
+ const NO_INITIATOR = "memory: this run holds no privacy ceiling, so it can write nothing — an agent's ceiling is "
85
+ + "its job initiator's, and this job has no initiator (no person published the event that started it, directly "
86
+ + "or through the agents that led to it)";
70
87
  /**
71
- * The nine tools `ctx.memory` wraps, over a Map.
72
- *
73
- * Returns the bank alongside so a test can assert on what is IN it, which is
74
- * the question worth asking: "the step did not throw" is not the same claim as
75
- * "the record is there, with that classification".
88
+ * record_list's cursor, as this fake mints it. A cursor that does not decode is
89
+ * the door's 400 (ddc-node memory/cursor.go decodeCursorTuple), never a thrown
90
+ * SyntaxError out of the fetch.
76
91
  */
77
- export function fakeMemoryBank() {
92
+ function decodeListCursor(cursor) {
93
+ let parts;
94
+ try {
95
+ parts = JSON.parse(Buffer.from(cursor, "base64url").toString());
96
+ }
97
+ catch {
98
+ throw new Refusal(400, "BAD_REQUEST", "memory: record_list: cursor does not decode");
99
+ }
100
+ if (!Array.isArray(parts) || parts.length !== 2 || parts.some((x) => typeof x !== "string")) {
101
+ throw new Refusal(400, "BAD_REQUEST", "memory: record_list: cursor is the wrong tuple for this tool");
102
+ }
103
+ return parts;
104
+ }
105
+ /** "a, b and c" — how vault-api lists edge types in a refusal. */
106
+ function andList(xs) {
107
+ return xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
108
+ }
109
+ class Refusal extends Error {
110
+ status;
111
+ code;
112
+ constructor(status, code, message) {
113
+ super(message);
114
+ this.status = status;
115
+ this.code = code;
116
+ }
117
+ }
118
+ const bad = (msg) => new Refusal(400, "BAD_REQUEST", msg);
119
+ const denied = (msg) => new Refusal(403, "SCOPE_DENIED", msg);
120
+ const str = (v) => (typeof v === "string" ? v : "");
121
+ const strList = (v) => Array.isArray(v) ? v.map((x) => String(x)) : undefined;
122
+ /* -------------------------------------------------------------------------- */
123
+ export function fakeMemoryBank(opts = {}) {
78
124
  const records = new Map();
79
- const edges = [];
80
- const classify = (privacy, what) => {
81
- const p = str(privacy);
82
- // The real bank refuses at the door. Defaulting here would hide the one
83
- // mistake this check exists to catch.
84
- if (!p)
85
- throw new Error(`[bank] ${what} without a privacy classification`);
86
- return p;
125
+ const relations = [];
126
+ const metrics = new Map();
127
+ const requests = [];
128
+ const base = "http://fake-vault/api/v1/vaults/v-test/memory";
129
+ // A clock that never repeats, so `updated_at DESC` is a total order.
130
+ let last = 0;
131
+ const now = () => {
132
+ last = Math.max(Date.now(), last + 1);
133
+ return new Date(last).toISOString();
87
134
  };
88
- // Built before `memory` so the tools can consult it — refuseWrite is a knob a
89
- // test turns, and upsert has to read it at call time, not at construction.
90
- const bank = { records, edges, all: () => [...records.values()] };
91
- const memory = {
92
- upsert: async (r) => {
93
- const id = str(r.id);
94
- if (!id)
95
- throw new Error("[bank] a record needs an id");
96
- const privacy = classify(r.privacy, "record");
97
- const props = propsOf(r.props);
98
- const createdAt = createdAtOf(r.createdAt);
99
- const existing = records.get(id);
135
+ const bank = {
136
+ records,
137
+ relations,
138
+ metrics,
139
+ requests,
140
+ base,
141
+ options: { policy: opts.policy ?? "curators", c3: opts.c3 ?? true, connection: opts.connection, connections: opts.connections },
142
+ all: () => [...records.values()],
143
+ props: (id) => {
144
+ const r = records.get(id);
145
+ return r ? JSON.parse(r.props) : undefined;
146
+ },
147
+ seed(r) {
148
+ const at = now();
100
149
  const row = {
101
- id,
102
- type: str(r.type) || "record",
103
- title: str(r.title) || id,
104
- body: str(r.body),
105
- scope: str(r.scope),
106
- privacy,
107
- // Left out, each keeps what the record has — see the note above.
108
- ...(props ?? existing?.props ? { props: props ?? existing?.props } : {}),
109
- created_at: createdAt ?? existing?.created_at ?? new Date().toISOString(),
110
- // Domain is NOT kept: the bank overwrites it from every write, so a
111
- // write that leaves it out clears it.
112
- ...(str(r.domain) ? { domain: str(r.domain) } : {}),
150
+ title: "", body: "", domain: "", status: "accepted", version: 1,
151
+ created_at: at, updated_at: at, reviewed_by: null, reviewed_at: null,
152
+ ...r,
153
+ props: r.props === undefined ? "{}" : typeof r.props === "string" ? r.props : JSON.stringify(r.props),
113
154
  };
114
- // The ack is the same either way — see FakeBank.refuseWrite.
115
- if (bank.refuseWrite?.(row))
116
- return { id };
117
- // UPSERT, NOT APPEND — see the note above.
118
- records.set(id, row);
119
- return { id };
120
- },
121
- update: async (r) => {
122
- const id = str(r.id);
123
- const existing = records.get(id);
124
- if (!existing)
125
- throw new Error(`[bank] no record ${id}`);
126
- records.set(id, { ...existing, ...r, id });
127
- return { id };
128
- },
129
- setPrivacy: async (r) => {
130
- const id = str(r.id);
131
- const existing = records.get(id);
132
- if (!existing)
133
- throw new Error(`[bank] no record ${id}`);
134
- records.set(id, { ...existing, privacy: classify(r.privacy, "record") });
135
- return { id };
155
+ records.set(row.id, row);
156
+ return row;
136
157
  },
137
- delete: async (r) => {
138
- records.delete(str(r.id));
139
- return {};
158
+ accept: (id, o) => decide(id, "accepted", o?.by),
159
+ reject: (id, o) => decide(id, "rejected", o?.by),
160
+ supersede(id) {
161
+ const r = must(id);
162
+ r.status = "superseded";
163
+ r.updated_at = now();
140
164
  },
141
- relation: async (e) => {
142
- const from = str(e.in);
143
- const to = str(e.out);
144
- if (!from || !to)
145
- throw new Error("[bank] a relation needs both ends");
146
- edges.push({
147
- in: from,
148
- out: to,
149
- type: str(e.type) || "related",
150
- scope: str(e.scope),
151
- // An edge carries its own: gating only the endpoints would leak that
152
- // the relationship exists.
153
- privacy: classify(e.privacy, "relation"),
154
- ...(str(e.domain) ? { domain: str(e.domain) } : {}),
165
+ fetch: (async (input, init) => {
166
+ const url = String(input instanceof Request ? input.url : input);
167
+ const tool = url.slice(url.lastIndexOf("/") + 1);
168
+ const headers = new Headers(init?.headers);
169
+ const agentId = headers.get("x-agent-id") ?? "";
170
+ let body;
171
+ try {
172
+ body = JSON.parse(String(init?.body ?? "{}"));
173
+ }
174
+ catch {
175
+ return refusal(new Refusal(400, "BAD_REQUEST", "memory: bad body"));
176
+ }
177
+ requests.push({ tool, body, agentId });
178
+ try {
179
+ const out = serve(tool, body, connectionOf(agentId));
180
+ if (out === null)
181
+ return new Response(null, { status: 204 });
182
+ return new Response(JSON.stringify(out), { status: 200, headers: { "content-type": "application/json" } });
183
+ }
184
+ catch (e) {
185
+ if (e instanceof Refusal)
186
+ return refusal(e);
187
+ throw e;
188
+ }
189
+ }),
190
+ handle(self = {}, build) {
191
+ return createMemoryHandle({
192
+ base,
193
+ headers: self.agentId ? { "x-agent-id": self.agentId } : {},
194
+ fetch: bank.fetch,
195
+ self,
196
+ build: build ?? { agent: (self.agentId ?? "test-agent").split(":").pop(), version: "0.0.0-test" },
155
197
  });
156
- return {};
157
198
  },
158
- search: async (match, opts) => {
159
- // FTS5: an empty term matches NOTHING, not everything.
160
- const term = str(match).replace(/\*+$/, "").trim().toLowerCase();
161
- if (!term)
162
- return [];
163
- const hit = (r) => `${r.id} ${r.type} ${r.title} ${r.body}`.toLowerCase().includes(term);
164
- return [...records.values()].filter(hit).slice(0, opts?.limit ?? 50);
165
- },
166
- get: async (id) => {
167
- const row = records.get(str(id));
168
- if (!row)
199
+ };
200
+ function must(id) {
201
+ const r = records.get(id);
202
+ if (!r)
203
+ throw new Error(`[fake bank] no record ${id}`);
204
+ return r;
205
+ }
206
+ function decide(id, status, by = "0xperson") {
207
+ const r = must(id);
208
+ r.status = status;
209
+ r.updated_at = now();
210
+ if (bank.options.c3) {
211
+ r.reviewed_by = by;
212
+ r.reviewed_at = r.updated_at;
213
+ }
214
+ }
215
+ function refusal(e) {
216
+ return new Response(JSON.stringify({ error: { code: e.code, message: e.message, retryable: false } }), {
217
+ status: e.status,
218
+ headers: { "content-type": "application/json" },
219
+ });
220
+ }
221
+ function connectionOf(agentId) {
222
+ const c = { ...(bank.options.connection ?? {}), ...(bank.options.connections?.[agentId] ?? {}) };
223
+ return { scopes: c.scopes ?? "*", ceiling: c.ceiling ?? "restricted", role: c.role ?? null, initiator: c.initiator ?? true };
224
+ }
225
+ /* --------------------------------- gate --------------------------------- */
226
+ const inScope = (c, scope) => c.scopes === "*" || c.scopes.includes(scope);
227
+ const visible = (c, r) => !!r && c.initiator !== false && inScope(c, r.scope) && rank(r.privacy) <= rank(c.ceiling);
228
+ /* -------------------------------- reads --------------------------------- */
229
+ function statusFilter(b) {
230
+ const statuses = strList(b.statuses);
231
+ if (statuses && statuses.length > 0) {
232
+ if (b.status || b.include_unaccepted)
233
+ throw bad("memory: send statuses, or status / include_unaccepted, not both");
234
+ for (const s of statuses)
235
+ if (!STATUSES.includes(s))
236
+ throw bad(`memory: unknown status "${s}"`);
237
+ return (r) => statuses.includes(r.status);
238
+ }
239
+ if (b.status) {
240
+ if (!STATUSES.includes(b.status))
241
+ throw bad(`memory: unknown status "${String(b.status)}"`);
242
+ return (r) => r.status === b.status;
243
+ }
244
+ if (b.include_unaccepted)
245
+ return () => true;
246
+ return (r) => r.status === "accepted";
247
+ }
248
+ function facets(b, list) {
249
+ const preds = [statusFilter(b)];
250
+ const types = strList(b.types);
251
+ if (b.type && types)
252
+ throw bad("memory: send type or types, not both");
253
+ if (types && types.length > 10)
254
+ throw bad(`memory: types may name at most 10 values (got ${types.length})`);
255
+ if (b.type)
256
+ preds.push((r) => r.type === b.type);
257
+ if (types && types.length > 0)
258
+ preds.push((r) => types.includes(r.type));
259
+ if (b.scope)
260
+ preds.push((r) => r.scope === b.scope);
261
+ if (b.privacy)
262
+ preds.push((r) => r.privacy === b.privacy);
263
+ const domains = strList(b.domains);
264
+ // record_search has always taken domains; record_list only with C3 (A2).
265
+ const domainsKnown = !list || bank.options.c3;
266
+ if (domains && domainsKnown) {
267
+ if (b.domain)
268
+ throw bad("memory: send domain or domains, not both");
269
+ if (domains.length > 20)
270
+ throw bad(`memory: domains may name at most 20 values (got ${domains.length})`);
271
+ preds.push((r) => domains.includes(r.domain));
272
+ }
273
+ if (b.domain)
274
+ preds.push((r) => r.domain === b.domain);
275
+ if (b.id_prefix !== undefined && bank.options.c3) {
276
+ const p = str(b.id_prefix);
277
+ if (p.length < 1 || p.length > 128)
278
+ throw bad("memory: id_prefix must be 1 to 128 characters");
279
+ preds.push((r) => r.id.startsWith(p));
280
+ }
281
+ return (r) => preds.every((f) => f(r));
282
+ }
283
+ function includeCols(b) {
284
+ const inc = strList(b.include) ?? [];
285
+ const known = ["body", "props", "created_at", "updated_at", "domain", "status", "version", ...(bank.options.c3 ? ["review"] : [])];
286
+ for (const i of inc)
287
+ if (!known.includes(i))
288
+ throw bad(`memory: include may name ${known.join(", ")} (got "${i}")`);
289
+ let chars = DEFAULT_BODY_CHARS;
290
+ if (b.body_chars !== undefined) {
291
+ if (!inc.includes("body"))
292
+ throw bad('memory: body_chars needs include ["body"]');
293
+ chars = Number(b.body_chars);
294
+ if (!Number.isInteger(chars) || chars < 1 || chars > MAX_BODY_CHARS) {
295
+ throw bad(`memory: body_chars must be between 1 and ${MAX_BODY_CHARS} (got ${String(b.body_chars)})`);
296
+ }
297
+ }
298
+ const names = [];
299
+ const getters = [];
300
+ if (inc.includes("body")) {
301
+ names.push("body");
302
+ getters.push((r) => r.body.slice(0, chars));
303
+ }
304
+ if (inc.includes("props")) {
305
+ names.push("props");
306
+ getters.push((r) => r.props);
307
+ }
308
+ if (inc.includes("review")) {
309
+ names.push("reviewed_by", "reviewed_at");
310
+ getters.push((r) => r.reviewed_by, (r) => r.reviewed_at);
311
+ }
312
+ return { names, value: (r) => getters.map((g) => g(r)) };
313
+ }
314
+ const limitOf = (b) => {
315
+ const n = b.limit === undefined ? DEFAULT_LIMIT : Number(b.limit);
316
+ return Math.min(Math.max(Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_LIMIT, 1), MAX_LIMIT);
317
+ };
318
+ function search(b, c) {
319
+ const match = str(b.match).trim();
320
+ if (!match && !b.query)
321
+ throw bad("memory: record_search needs match or query");
322
+ const terms = match.split(/\s+/).filter((t) => t && t.toUpperCase() !== "OR").map((t) => t.replace(/^"|"$/g, "").toLowerCase());
323
+ const any = /\sOR\s/.test(match);
324
+ const hits = (r) => {
325
+ const text = `${r.id} ${r.type} ${r.title} ${r.body}`.toLowerCase();
326
+ const each = terms.map((t) => (t.endsWith("*") ? text.includes(t.slice(0, -1)) : text.includes(t)));
327
+ return each.filter(Boolean).length;
328
+ };
329
+ const f = facets(b, false);
330
+ const inc = includeCols(b);
331
+ const rows = [...records.values()]
332
+ .filter((r) => visible(c, r) && f(r))
333
+ .map((r) => ({ r, n: hits(r) }))
334
+ .filter(({ n }) => (any ? n > 0 : n === terms.length))
335
+ .sort((a, z) => z.n - a.n || a.r.id.localeCompare(z.r.id))
336
+ .slice(0, limitOf(b));
337
+ return {
338
+ columns: ["id", "type", "title", "scope", "privacy", "rank", "snippet", "domain", "status", "version", ...inc.names],
339
+ rows: rows.map(({ r, n }) => [r.id, r.type, r.title, r.scope, r.privacy, -n, r.title, r.domain, r.status, r.version, ...inc.value(r)]),
340
+ };
341
+ }
342
+ function list(b, c) {
343
+ const f = facets(b, true);
344
+ const inc = includeCols(b);
345
+ let rows = [...records.values()]
346
+ .filter((r) => visible(c, r) && f(r))
347
+ .sort((a, z) => z.updated_at.localeCompare(a.updated_at) || a.id.localeCompare(z.id));
348
+ if (b.cursor) {
349
+ const [at, id] = decodeListCursor(str(b.cursor));
350
+ rows = rows.filter((r) => r.updated_at < at || (r.updated_at === at && r.id > id));
351
+ }
352
+ const limit = limitOf(b);
353
+ const page = rows.slice(0, limit);
354
+ const lastRow = page[page.length - 1];
355
+ const cursor = rows.length > limit && lastRow
356
+ ? Buffer.from(JSON.stringify([lastRow.updated_at, lastRow.id])).toString("base64url")
357
+ : undefined;
358
+ return {
359
+ columns: ["id", "type", "title", "scope", "privacy", "updated_at", "domain", "status", "version", "created_at", ...inc.names],
360
+ rows: page.map((r) => [r.id, r.type, r.title, r.scope, r.privacy, r.updated_at, r.domain, r.status, r.version, r.created_at, ...inc.value(r)]),
361
+ ...(cursor ? { cursor } : {}),
362
+ };
363
+ }
364
+ function get(b, c) {
365
+ const r = records.get(str(b.id));
366
+ const columns = ["id", "type", "title", "body", "scope", "privacy", "created_at", "updated_at", "domain", "status", "version", "props",
367
+ ...(bank.options.c3 ? ["reviewed_by", "reviewed_at"] : [])];
368
+ if (!visible(c, r))
369
+ return { columns, rows: [] };
370
+ return {
371
+ columns,
372
+ rows: [[r.id, r.type, r.title, r.body, r.scope, r.privacy, r.created_at, r.updated_at, r.domain, r.status, r.version, r.props,
373
+ ...(bank.options.c3 ? [r.reviewed_by, r.reviewed_at] : [])]],
374
+ };
375
+ }
376
+ function neighbours(b, c) {
377
+ const id = str(b.id);
378
+ const dir = str(b.direction) || "both";
379
+ if (!["in", "out", "both"].includes(dir))
380
+ throw bad(`record_neighbours: direction must be one of in, out, both (got "${dir}")`);
381
+ const statuses = bank.options.c3 ? strList(b.statuses) : undefined;
382
+ const columns = ["id", "type", "title", "scope", "privacy", "type", "domain", "status", "version"];
383
+ if (!visible(c, records.get(id)))
384
+ return { columns, rows: [] };
385
+ const rows = [];
386
+ for (const e of relations) {
387
+ const touches = dir === "out" ? e.in_id === id : dir === "in" ? e.out_id === id : e.in_id === id || e.out_id === id;
388
+ if (!touches || (b.edge_type && e.type !== b.edge_type) || !visible(c, e))
389
+ continue;
390
+ const far = records.get(e.in_id === id ? e.out_id : e.in_id);
391
+ if (!far || !visible(c, far) || (statuses && !statuses.includes(far.status)))
392
+ continue;
393
+ rows.push([far.id, far.type, far.title, far.scope, far.privacy, e.type, far.domain, far.status, far.version]);
394
+ }
395
+ rows.sort((a, z) => String(a[0]).localeCompare(String(z[0])));
396
+ return { columns, rows: rows.slice(0, limitOf(b)) };
397
+ }
398
+ function recordMetrics(b, c) {
399
+ const ids = strList(b.ids);
400
+ if (ids && bank.options.c3) {
401
+ if (b.id)
402
+ throw bad("memory: send id or ids, not both");
403
+ if (ids.length > 50)
404
+ throw bad("memory: ids may name at most 50 records");
405
+ const rows = [];
406
+ for (const id of [...ids].sort()) {
407
+ if (!visible(c, records.get(id)))
408
+ continue;
409
+ for (const [k, v] of [...(metrics.get(id) ?? new Map())].sort())
410
+ rows.push([id, k, v]);
411
+ }
412
+ return { columns: ["record_id", "key", "value"], rows };
413
+ }
414
+ const id = str(b.id);
415
+ if (!visible(c, records.get(id)))
416
+ return { columns: ["key", "value"], rows: [] };
417
+ return { columns: ["key", "value"], rows: [...(metrics.get(id) ?? new Map())].sort() };
418
+ }
419
+ function countByType(b, c) {
420
+ const n = new Map();
421
+ for (const r of records.values()) {
422
+ if (!visible(c, r) || r.status !== "accepted" || (b.scope && r.scope !== b.scope) || (b.domain && r.domain !== b.domain))
423
+ continue;
424
+ n.set(r.type, (n.get(r.type) ?? 0) + 1);
425
+ }
426
+ return { columns: ["type", "n"], rows: [...n].sort() };
427
+ }
428
+ /* -------------------------------- writes -------------------------------- */
429
+ function writeGate(tool, b, c, scope, privacy) {
430
+ if (c.initiator === false)
431
+ throw denied(NO_INITIATOR);
432
+ if (!scope)
433
+ throw bad("memory: scope is required");
434
+ if (!PRIVACIES.includes(privacy)) {
435
+ throw bad("memory: privacy must be one of public, internal, private, restricted");
436
+ }
437
+ if (!inScope(c, scope)) {
438
+ const writable = c.scopes === "*" ? "every scope" : c.scopes.join(", ") || "none";
439
+ throw denied(`memory: not a scope you may write to: "${scope}"`
440
+ + (writable === "none" ? " — you hold no writable scope in this vault" : ` — you may write to: ${writable}`));
441
+ }
442
+ if (bank.options.c3 && rank(privacy) > rank(c.ceiling)) {
443
+ throw new Refusal(403, "PRIVACY_ABOVE_CEILING", `memory: you may write at most ${c.ceiling} here (got ${privacy}); `
444
+ + "a record above your own ceiling would be invisible to you — for an agent, the ceiling is its job initiator's");
445
+ }
446
+ if (bank.options.policy !== "curators")
447
+ return;
448
+ if (c.role === "embedder")
449
+ throw denied("memory: an embedder writes only record vectors (record_vec_upsert) — records, edges and metrics are other roles' writes");
450
+ if (c.role === "harvester") {
451
+ if (!["record_upsert", "relation_upsert"].includes(tool))
452
+ throw denied(`memory: a harvester may not call ${tool} — it files proposals and draws derived_from, supersedes, about and "
453
+ + "involves edges; promoting, rewriting, reclassifying and deleting are a person's acts`);
454
+ if (tool === "record_upsert" && b.status && b.status !== "proposed") {
455
+ throw denied(`memory: a harvester writes proposals — status must be proposed or omitted (got "${String(b.status)}"); `
456
+ + "a person accepts a proposal into the bank");
457
+ }
458
+ return;
459
+ }
460
+ if (c.role === "analyzer") {
461
+ if (!["record_upsert", "relation_upsert", "record_metric_upsert"].includes(tool)) {
462
+ throw denied(`memory: an analyzer may not call ${tool} — it files proposed metric: and obs: records, draws derived_from `
463
+ + "and about edges, and attaches counts to its own proposed metric: records");
464
+ }
465
+ const id = str(b.id);
466
+ if (tool !== "relation_upsert" && !id.startsWith("metric:") && !(tool === "record_upsert" && id.startsWith("obs:"))) {
467
+ throw denied(tool === "record_metric_upsert"
468
+ ? `memory: an analyzer attaches counts only to metric: records (got id "${id}")`
469
+ : `memory: an analyzer writes only metric: and obs: records (got id "${id}")`);
470
+ }
471
+ return;
472
+ }
473
+ throw denied(WORKFLOW_AGENT_REFUSAL);
474
+ }
475
+ function upsert(b, c) {
476
+ const id = str(b.id);
477
+ if (!id)
478
+ throw bad("memory: a record needs an id");
479
+ if (!str(b.type))
480
+ throw bad("memory: type is required");
481
+ if (id.startsWith("run:"))
482
+ throw new Refusal(403, "PLATFORM_RESERVED", "memory: run: records are the platform's");
483
+ if (b.status !== undefined && !STATUSES.includes(b.status))
484
+ throw bad(`memory: unknown status "${String(b.status)}"`);
485
+ let props;
486
+ if (b.props !== undefined && b.props !== null) {
487
+ if (typeof b.props !== "object" || Array.isArray(b.props))
488
+ throw bad("memory: props must be a JSON object");
489
+ props = JSON.stringify(b.props);
490
+ if (new TextEncoder().encode(props).length > MAX_PROPS_BYTES)
491
+ throw bad("memory: props over 16 KiB");
492
+ }
493
+ const createdAt = str(b.created_at);
494
+ if (createdAt) {
495
+ const ms = Date.parse(createdAt);
496
+ if (Number.isNaN(ms))
497
+ throw bad(`memory: created_at must be RFC 3339 (got "${createdAt}")`);
498
+ if (ms > Date.now() + CREATED_AT_SKEW_MS)
499
+ throw bad(`memory: created_at must not be in the future (got "${createdAt}")`);
500
+ }
501
+ const scope = str(b.scope);
502
+ const privacy = str(b.privacy);
503
+ writeGate("record_upsert", b, c, scope, privacy);
504
+ if (bank.refuseWrite?.({ id, type: str(b.type), scope, privacy }))
505
+ return null;
506
+ const prev = records.get(id);
507
+ const curator = bank.options.policy === "curators" && (c.role === "harvester" || c.role === "analyzer");
508
+ if (prev) {
509
+ // The conflict arm is gated: a row the writer cannot see is not touched,
510
+ // and the ack says nothing (the existence oracle is closed).
511
+ if (!visible(c, prev))
169
512
  return null;
170
- // Props travel as JSON text, as the bank's own row carries them.
171
- return row.props ? { ...row, props: JSON.stringify(row.props) } : row;
172
- },
173
- neighbours: async (id) => {
174
- const me = str(id);
175
- return edges
176
- .filter((e) => e.in === me || e.out === me)
177
- .map((e) => {
178
- const far = e.in === me ? e.out : e.in;
179
- return { ...(records.get(far) ?? { id: far }), edgeType: e.type };
513
+ // The proposal builder rewrites only a row that is still proposed.
514
+ if (curator && prev.status !== "proposed")
515
+ return null;
516
+ }
517
+ const at = now();
518
+ const status = curator ? "proposed" : b.status ?? prev?.status ?? "accepted";
519
+ const changed = prev && prev.status !== status;
520
+ records.set(id, {
521
+ id,
522
+ type: str(b.type),
523
+ title: str(b.title),
524
+ body: str(b.body),
525
+ scope,
526
+ privacy: privacy,
527
+ created_at: createdAt || prev?.created_at || at,
528
+ updated_at: at,
529
+ // Overwritten on every write: omitting it clears it.
530
+ domain: str(b.domain),
531
+ status,
532
+ version: Math.max(prev?.version ?? 1, Number(b.version) || 1),
533
+ props: props ?? prev?.props ?? "{}",
534
+ // C3 §1.4: a person naming accepted/rejected stamps; any other status change clears.
535
+ reviewed_by: changed ? null : prev?.reviewed_by ?? null,
536
+ reviewed_at: changed ? null : prev?.reviewed_at ?? null,
537
+ });
538
+ // An insert above the writer's ceiling (pre-C3) lands, invisible to it.
539
+ return null;
540
+ }
541
+ function relationUpsert(b, c) {
542
+ const from = str(b.in);
543
+ const to = str(b.out);
544
+ const type = str(b.type);
545
+ if (!from || !to || !type)
546
+ throw bad("memory: a relation needs in, out and type");
547
+ if (PLATFORM_EDGES.includes(type))
548
+ throw new Refusal(403, "PLATFORM_RESERVED", `memory: "${type}" edges are the platform's`);
549
+ const scope = str(b.scope);
550
+ const privacy = str(b.privacy);
551
+ writeGate("relation_upsert", b, c, scope, privacy);
552
+ if (bank.options.policy === "curators") {
553
+ const allowed = c.role === "analyzer" ? ANALYZER_EDGES : bank.options.c3 ? HARVESTER_EDGES_C3 : HARVESTER_EDGES_PRE_C3;
554
+ if (!allowed.includes(type))
555
+ throw denied(`memory: ${c.role === "analyzer" ? "an" : "a"} ${c.role} may draw only ${andList(allowed)} edges (got "${type}")`);
556
+ }
557
+ // Both ends must be visible, or nothing is drawn — and the ack is the same.
558
+ if (!visible(c, records.get(from)) || !visible(c, records.get(to)))
559
+ return null;
560
+ const existing = relations.find((e) => e.in_id === from && e.out_id === to && e.type === type);
561
+ const at = now();
562
+ if (existing) {
563
+ Object.assign(existing, { scope, privacy, domain: str(b.domain), updated_at: at });
564
+ }
565
+ else {
566
+ relations.push({
567
+ id: `rel:${relations.length + 1}`, in_id: from, out_id: to, type, scope,
568
+ privacy: privacy, created_at: at, updated_at: at, domain: str(b.domain),
180
569
  });
181
- },
182
- countByType: async () => {
183
- const n = new Map();
184
- for (const r of records.values())
185
- n.set(r.type, (n.get(r.type) ?? 0) + 1);
186
- return [...n].map(([type, count]) => ({ type, n: count }));
187
- },
188
- };
189
- return { memory, bank };
570
+ }
571
+ return null;
572
+ }
573
+ function metricUpsert(b, c) {
574
+ const id = str(b.id);
575
+ const key = str(b.metric_key);
576
+ const value = Number(b.metric_value);
577
+ if (!id || !key)
578
+ throw bad("memory: record_metric_upsert needs id and metric_key");
579
+ if (!Number.isFinite(value) || Math.abs(value) > 1e15)
580
+ throw bad("memory: metric_value must be finite, |v| ≤ 1e15");
581
+ const r = records.get(id);
582
+ writeGate("record_metric_upsert", b, c, r?.scope ?? "", r?.privacy ?? "internal");
583
+ if (bank.options.policy === "curators" && c.role === "analyzer") {
584
+ const ok = bank.options.c3
585
+ ? ANALYZER_KEY_RULE.test(key) && !LEDGER_KEYS.includes(key)
586
+ : ANALYZER_KEYS_PRE_C3.includes(key);
587
+ if (!ok) {
588
+ throw denied(bank.options.c3
589
+ ? "memory: an analyzer metric key must be lower_snake_case, at most 63 characters, and not one of the platform's run keys "
590
+ + `a2a_cost, a2a_tokens or duration_ms (got "${key}")`
591
+ : `memory: an analyzer may write only the keys ${ANALYZER_KEYS_PRE_C3.join(", ")} (got "${key}")`);
592
+ }
593
+ if (!visible(c, r) || r.status !== "proposed")
594
+ return null;
595
+ }
596
+ else if (!visible(c, r)) {
597
+ return null;
598
+ }
599
+ const m = metrics.get(id) ?? new Map();
600
+ m.set(key, value);
601
+ metrics.set(id, m);
602
+ return null;
603
+ }
604
+ function personOnly(tool, b, c, apply) {
605
+ const r = records.get(str(b.id));
606
+ writeGate(tool, b, c, r?.scope ?? (c.scopes === "*" ? "x" : c.scopes[0] ?? ""), r?.privacy ?? "public");
607
+ if (r && visible(c, r))
608
+ apply(r);
609
+ return null;
610
+ }
611
+ function serve(tool, b, c) {
612
+ switch (tool) {
613
+ case "record_search": return search(b, c);
614
+ case "record_list": return list(b, c);
615
+ case "record_get": return get(b, c);
616
+ case "record_neighbours": return neighbours(b, c);
617
+ case "record_metrics": return recordMetrics(b, c);
618
+ case "record_count_by_type": return countByType(b, c);
619
+ case "record_upsert": return upsert(b, c);
620
+ case "relation_upsert": return relationUpsert(b, c);
621
+ case "record_metric_upsert": return metricUpsert(b, c);
622
+ case "record_update":
623
+ return personOnly(tool, b, c, (r) => {
624
+ if (b.title !== undefined)
625
+ r.title = str(b.title);
626
+ if (b.body !== undefined)
627
+ r.body = str(b.body);
628
+ r.updated_at = now();
629
+ });
630
+ case "record_set_privacy":
631
+ if (!PRIVACIES.includes(b.privacy))
632
+ throw bad("memory: privacy must be one of public, internal, private, restricted");
633
+ return personOnly(tool, b, c, (r) => { r.privacy = b.privacy; r.updated_at = now(); });
634
+ case "record_set_status":
635
+ if (!STATUSES.includes(b.status))
636
+ throw bad(`memory: unknown status "${String(b.status)}"`);
637
+ return personOnly(tool, b, c, (r) => { r.status = b.status; r.updated_at = now(); r.reviewed_by = null; r.reviewed_at = null; });
638
+ case "record_delete":
639
+ return personOnly(tool, b, c, (r) => { records.delete(r.id); });
640
+ }
641
+ throw new Refusal(404, "BAD_REQUEST", "unknown memory tool");
642
+ }
643
+ return bank;
190
644
  }
191
645
  //# sourceMappingURL=memory-bank.js.map