@tangle-network/agent-runtime 0.339.0 → 0.342.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.
- package/dist/{activation-CVSFxis9.js → activation-CWPmt0Do.js} +4 -4
- package/dist/{activation-CVSFxis9.js.map → activation-CWPmt0Do.js.map} +1 -1
- package/dist/{activation-CJIIl_Ey.d.ts → activation-CXqtijue.d.ts} +2 -2
- package/dist/agent.d.ts +4 -18
- package/dist/agent.js +3 -21
- package/dist/agent.js.map +1 -1
- package/dist/analyst-loop.d.ts +2 -2
- package/dist/{bundle-DVOhBTic.js → bundle-Cwpit6Tg.js} +2 -2
- package/dist/{bundle-DVOhBTic.js.map → bundle-Cwpit6Tg.js.map} +1 -1
- package/dist/candidate-execution/index.d.ts +2 -40
- package/dist/candidate-execution/index.js +9 -255
- package/dist/candidate-execution/index.js.map +1 -1
- package/dist/{driver-control-acknowledgers-C53ggdJ6.js → coordination-manager-B5JsolDR.js} +4852 -4539
- package/dist/coordination-manager-B5JsolDR.js.map +1 -0
- package/dist/{delegate-CCWE_AkU.js → delegate-Rl-5paLJ.js} +2 -2
- package/dist/{delegate-CCWE_AkU.js.map → delegate-Rl-5paLJ.js.map} +1 -1
- package/dist/durable.d.ts +1 -1
- package/dist/durable.js +451 -358
- package/dist/durable.js.map +1 -1
- package/dist/{graph-CI9JXKqO.js → graph-Ce-59ZXn.js} +5 -5
- package/dist/{graph-CI9JXKqO.js.map → graph-Ce-59ZXn.js.map} +1 -1
- package/dist/{improve-BOXTLWJ3.js → improve-BZXt2qM0.js} +837 -704
- package/dist/improve-BZXt2qM0.js.map +1 -0
- package/dist/{improvement-adapter-HUt_-cWA.d.ts → improvement-adapter-sIthWkcN.d.ts} +2 -2
- package/dist/index.d.ts +6 -14
- package/dist/index.js +10 -10
- package/dist/intelligence.d.ts +4 -4
- package/dist/intelligence.js +205 -191
- package/dist/intelligence.js.map +1 -1
- package/dist/kernel.d.ts +3 -3
- package/dist/kernel.js +10 -10
- package/dist/{loop-runner-pmPIHFVY.js → loop-runner-B0MV1-CD.js} +2 -2
- package/dist/{loop-runner-pmPIHFVY.js.map → loop-runner-B0MV1-CD.js.map} +1 -1
- package/dist/{loop-runner-CWzG1bH4.d.ts → loop-runner-CuhbgVGo.d.ts} +3 -3
- package/dist/loop-runner-bin.d.ts +1 -1
- package/dist/loop-runner-bin.js +1 -1
- package/dist/mcp/bin.js +3 -3
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +5 -5
- package/dist/placement.d.ts +2 -26
- package/dist/placement.js +1 -35
- package/dist/placement.js.map +1 -1
- package/dist/{prepare-bXkvPMJA.js → prepare-C-cWCyt3.js} +988 -735
- package/dist/prepare-C-cWCyt3.js.map +1 -0
- package/dist/{profile-Dpei_Sq0.js → profile-CbMw5hAn.js} +2 -2
- package/dist/{profile-Dpei_Sq0.js.map → profile-CbMw5hAn.js.map} +1 -1
- package/dist/profile-materialization-bPm0gGLO.d.ts +49 -0
- package/dist/profiles.d.ts +1 -1
- package/dist/{protected-model-port-BGgpy2r9.d.ts → protected-model-port-DVVh-hEk.d.ts} +2 -6
- package/dist/{provision-supervisor-CJ6Zz6dz.js → provision-supervisor-DL6gEMQp.js} +444 -419
- package/dist/provision-supervisor-DL6gEMQp.js.map +1 -0
- package/dist/{runtime-B2fjL9Oi.js → runtime-eghmj6W4.js} +1245 -1028
- package/dist/runtime-eghmj6W4.js.map +1 -0
- package/dist/{sandbox-profile-compat-BzVBFqrz.js → sandbox-profile-compat-FN2kab2-.js} +72 -72
- package/dist/{sandbox-profile-compat-BzVBFqrz.js.map → sandbox-profile-compat-FN2kab2-.js.map} +1 -1
- package/dist/{search-executor-zMumiak_.js → search-executor-rbjdZ3OD.js} +3 -3
- package/dist/{search-executor-zMumiak_.js.map → search-executor-rbjdZ3OD.js.map} +1 -1
- package/dist/{server-_4voDNrl.js → server-BiK1Kcno.js} +4 -4
- package/dist/{server-_4voDNrl.js.map → server-BiK1Kcno.js.map} +1 -1
- package/dist/{structural-rollout-DpLCXzaL.js → structural-rollout-BD9IN7K_.js} +3 -3
- package/dist/{structural-rollout-DpLCXzaL.js.map → structural-rollout-BD9IN7K_.js.map} +1 -1
- package/dist/{supervise-qAwXmPtD.js → supervise-DmOi7gll.js} +6525 -5562
- package/dist/supervise-DmOi7gll.js.map +1 -0
- package/dist/{supervisor-DHrUJhEZ.js → supervisor-DUWcecAd.js} +13717 -12778
- package/dist/supervisor-DUWcecAd.js.map +1 -0
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +13 -13
- package/dist/tui/index.d.ts +1 -1
- package/dist/tui/index.js +1 -1
- package/dist/{types-CaOXqRTq.d.ts → types-Cgs89PQd.d.ts} +315 -409
- package/dist/{types-zWfqDjeL.d.ts → types-Cwrea-bU.d.ts} +2 -17
- package/dist/workspace-store/index.d.ts +1 -82
- package/dist/workspace-store/index.js +274 -680
- package/dist/workspace-store/index.js.map +1 -1
- package/package.json +8 -8
- package/dist/driver-control-acknowledgers-C53ggdJ6.js.map +0 -1
- package/dist/improve-BOXTLWJ3.js.map +0 -1
- package/dist/prepare-bXkvPMJA.js.map +0 -1
- package/dist/profile-materialization-B1wm2SPT.d.ts +0 -94
- package/dist/provision-supervisor-CJ6Zz6dz.js.map +0 -1
- package/dist/runtime-B2fjL9Oi.js.map +0 -1
- package/dist/supervise-qAwXmPtD.js.map +0 -1
- package/dist/supervisor-DHrUJhEZ.js.map +0 -1
|
@@ -1,215 +1,4 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
//#region src/workspace-store/codec.ts
|
|
3
|
-
/** A value did not open: unknown key, wrong tenant or path, tampering, or plaintext where a seal was required. */
|
|
4
|
-
var WorkspaceCodecError = class extends Error {
|
|
5
|
-
code = "codec";
|
|
6
|
-
constructor(message) {
|
|
7
|
-
super(message);
|
|
8
|
-
this.name = "WorkspaceCodecError";
|
|
9
|
-
}
|
|
10
|
-
};
|
|
11
|
-
const VERSION = 1;
|
|
12
|
-
const IV_BYTES = 12;
|
|
13
|
-
const KEY_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
14
|
-
const encoder = new TextEncoder();
|
|
15
|
-
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
16
|
-
function bytesOf(value) {
|
|
17
|
-
if (typeof value !== "string") return value;
|
|
18
|
-
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
|
|
19
|
-
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
20
|
-
}
|
|
21
|
-
function base64(bytes) {
|
|
22
|
-
let binary = "";
|
|
23
|
-
for (let offset = 0; offset < bytes.length; offset += 32768) binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
|
|
24
|
-
return btoa(binary);
|
|
25
|
-
}
|
|
26
|
-
/** A copy in a plain ArrayBuffer, which every Web Crypto typing accepts (this package has no DOM lib). */
|
|
27
|
-
function arrayBuffer(bytes) {
|
|
28
|
-
return bytes.slice().buffer;
|
|
29
|
-
}
|
|
30
|
-
function concat(...parts) {
|
|
31
|
-
const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
32
|
-
let offset = 0;
|
|
33
|
-
for (const part of parts) {
|
|
34
|
-
out.set(part, offset);
|
|
35
|
-
offset += part.length;
|
|
36
|
-
}
|
|
37
|
-
return out;
|
|
38
|
-
}
|
|
39
|
-
/** Strings joined with a 4-byte length before each, so no two lists encode alike. */
|
|
40
|
-
function framed(...values) {
|
|
41
|
-
return concat(...values.flatMap((value) => {
|
|
42
|
-
const bytes = encoder.encode(value);
|
|
43
|
-
const length = /* @__PURE__ */ new Uint8Array(4);
|
|
44
|
-
new DataView(length.buffer).setUint32(0, bytes.length);
|
|
45
|
-
return [length, bytes];
|
|
46
|
-
}));
|
|
47
|
-
}
|
|
48
|
-
/** The authenticated data: the tenant and path a value belongs to. */
|
|
49
|
-
const associated = ({ tenant, path }) => framed(tenant, path);
|
|
50
|
-
/**
|
|
51
|
-
* AES-256-GCM with a key id per value. `keys` maps key id to 32 raw bytes
|
|
52
|
-
* (base64 or base64url when a string). The encryption key and the key that
|
|
53
|
-
* derives replay-stable IVs are both derived from the raw key with HKDF, so the
|
|
54
|
-
* raw key is never used for two algorithms.
|
|
55
|
-
*
|
|
56
|
-
* Without an `operationId` every seal uses a random 96-bit IV. With one, the IV
|
|
57
|
-
* is an HMAC of the tenant, path, operation and plaintext, as in SIV: only an
|
|
58
|
-
* identical write repeats it, and that repeat reveals only that the write was
|
|
59
|
-
* repeated.
|
|
60
|
-
*/
|
|
61
|
-
function createAesGcmContentCodec(options) {
|
|
62
|
-
const raw = /* @__PURE__ */ new Map();
|
|
63
|
-
for (const [id, value] of Object.entries(options.keys)) {
|
|
64
|
-
if (!KEY_ID.test(id)) throw new TypeError(`workspace store key id is not [A-Za-z0-9_-]{1,64}: ${id}`);
|
|
65
|
-
const bytes = bytesOf(value);
|
|
66
|
-
if (bytes.length !== 32) throw new TypeError(`workspace store key ${id} must be 32 bytes, got ${bytes.length}`);
|
|
67
|
-
raw.set(id, bytes);
|
|
68
|
-
}
|
|
69
|
-
if (!raw.has(options.activeKeyId)) throw new TypeError(`the active workspace store key ${options.activeKeyId} is not among the keys`);
|
|
70
|
-
const derived = /* @__PURE__ */ new Map();
|
|
71
|
-
const keyFor = (id) => {
|
|
72
|
-
const bytes = raw.get(id);
|
|
73
|
-
if (!bytes) return Promise.reject(new WorkspaceCodecError(`unknown workspace store key id: ${id}`));
|
|
74
|
-
let key = derived.get(id);
|
|
75
|
-
if (!key) {
|
|
76
|
-
key = (async () => {
|
|
77
|
-
const base = await crypto.subtle.importKey("raw", arrayBuffer(bytes), "HKDF", false, ["deriveKey"]);
|
|
78
|
-
const hkdf = (info) => ({
|
|
79
|
-
name: "HKDF",
|
|
80
|
-
hash: "SHA-256",
|
|
81
|
-
salt: /* @__PURE__ */ new Uint8Array(32),
|
|
82
|
-
info: encoder.encode(`workspace-store/${info}`)
|
|
83
|
-
});
|
|
84
|
-
return {
|
|
85
|
-
seal: await crypto.subtle.deriveKey(hkdf("aes-256-gcm"), base, {
|
|
86
|
-
name: "AES-GCM",
|
|
87
|
-
length: 256
|
|
88
|
-
}, false, ["encrypt", "decrypt"]),
|
|
89
|
-
iv: await crypto.subtle.deriveKey(hkdf("siv"), base, {
|
|
90
|
-
name: "HMAC",
|
|
91
|
-
hash: "SHA-256",
|
|
92
|
-
length: 256
|
|
93
|
-
}, false, ["sign"])
|
|
94
|
-
};
|
|
95
|
-
})();
|
|
96
|
-
derived.set(id, key);
|
|
97
|
-
}
|
|
98
|
-
return key;
|
|
99
|
-
};
|
|
100
|
-
return {
|
|
101
|
-
async seal(plain, context) {
|
|
102
|
-
const id = options.activeKeyId;
|
|
103
|
-
const key = await keyFor(id);
|
|
104
|
-
const aad = associated(context);
|
|
105
|
-
const iv = context.operationId === void 0 ? crypto.getRandomValues(new Uint8Array(IV_BYTES)) : new Uint8Array(await crypto.subtle.sign("HMAC", key.iv, arrayBuffer(concat(framed(context.tenant, context.path, context.operationId), plain)))).slice(0, IV_BYTES);
|
|
106
|
-
const sealed = new Uint8Array(await crypto.subtle.encrypt({
|
|
107
|
-
name: "AES-GCM",
|
|
108
|
-
iv: arrayBuffer(iv),
|
|
109
|
-
additionalData: arrayBuffer(aad)
|
|
110
|
-
}, key.seal, arrayBuffer(plain)));
|
|
111
|
-
const keyId = encoder.encode(id);
|
|
112
|
-
return concat(Uint8Array.of(VERSION, keyId.length), keyId, iv, sealed);
|
|
113
|
-
},
|
|
114
|
-
async open(sealed, context) {
|
|
115
|
-
if (sealed.length < 2 || sealed[0] !== VERSION) throw new WorkspaceCodecError("not a sealed workspace value");
|
|
116
|
-
const start = 2 + sealed[1];
|
|
117
|
-
if (sealed.length < start + IV_BYTES + 16) throw new WorkspaceCodecError("a sealed workspace value is truncated");
|
|
118
|
-
const key = await keyFor(new TextDecoder().decode(sealed.subarray(2, start)));
|
|
119
|
-
try {
|
|
120
|
-
return new Uint8Array(await crypto.subtle.decrypt({
|
|
121
|
-
name: "AES-GCM",
|
|
122
|
-
iv: arrayBuffer(sealed.subarray(start, start + IV_BYTES)),
|
|
123
|
-
additionalData: arrayBuffer(associated(context))
|
|
124
|
-
}, key.seal, arrayBuffer(sealed.subarray(start + IV_BYTES))));
|
|
125
|
-
} catch {
|
|
126
|
-
throw new WorkspaceCodecError(`${context.path}: the sealed value does not open for this tenant and path`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* The AES-256-GCM codec from an app's env, or `null` when neither variable is
|
|
133
|
-
* set. `WORKSPACE_STORE_KEYS` is `id:base64,id:base64`; `WORKSPACE_STORE_KEY_ID`
|
|
134
|
-
* names the key new writes use. Setting one without the other is refused.
|
|
135
|
-
*/
|
|
136
|
-
function aesGcmContentCodecFromEnv(env) {
|
|
137
|
-
const list = env.WORKSPACE_STORE_KEYS?.trim();
|
|
138
|
-
const active = env.WORKSPACE_STORE_KEY_ID?.trim();
|
|
139
|
-
if (!list && !active) return null;
|
|
140
|
-
if (!list || !active) throw new TypeError("set both WORKSPACE_STORE_KEYS and WORKSPACE_STORE_KEY_ID");
|
|
141
|
-
const keys = {};
|
|
142
|
-
for (const pair of list.split(",")) {
|
|
143
|
-
const at = pair.indexOf(":");
|
|
144
|
-
if (at <= 0) throw new TypeError("WORKSPACE_STORE_KEYS is id:base64,id:base64");
|
|
145
|
-
keys[pair.slice(0, at).trim()] = pair.slice(at + 1).trim();
|
|
146
|
-
}
|
|
147
|
-
return createAesGcmContentCodec({
|
|
148
|
-
keys,
|
|
149
|
-
activeKeyId: active
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
/** Stored text of a sealed `content`: a prefix, then the sealed bytes in base64. */
|
|
153
|
-
const SEALED_CONTENT_PREFIX = "wsc1:";
|
|
154
|
-
/** Seal bytes a host is about to upload (an R2 object) under the entry's tenant and path. */
|
|
155
|
-
function sealWorkspaceBytes(codec, context, bytes) {
|
|
156
|
-
return codec.seal(bytes, context);
|
|
157
|
-
}
|
|
158
|
-
/** Open bytes a host downloaded for an entry's `bytes` reference. */
|
|
159
|
-
function openWorkspaceBytes(codec, context, sealed) {
|
|
160
|
-
return codec.open(sealed, context);
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* `store` with every entry's `content` sealed at rest. Index fields, `bytes`
|
|
164
|
-
* references and tombstones pass through untouched; the host seals the bytes
|
|
165
|
-
* themselves. `idempotentOperations` is the inner store's: a replayed put with
|
|
166
|
-
* an `operationId` seals to the same bytes, so the inner replay check holds.
|
|
167
|
-
*/
|
|
168
|
-
function withContentCodec(store, codec, options = {}) {
|
|
169
|
-
const open = async (entry) => {
|
|
170
|
-
if (entry.content === void 0) return entry;
|
|
171
|
-
if (!entry.content.startsWith("wsc1:")) {
|
|
172
|
-
if (options.readPlaintext) return entry;
|
|
173
|
-
throw new WorkspaceCodecError(`${entry.path}: stored content is not sealed`);
|
|
174
|
-
}
|
|
175
|
-
const plain = await codec.open(bytesOf(entry.content.slice(5)), {
|
|
176
|
-
tenant: entry.workspaceId,
|
|
177
|
-
path: entry.path
|
|
178
|
-
});
|
|
179
|
-
return {
|
|
180
|
-
...entry,
|
|
181
|
-
content: decoder.decode(plain)
|
|
182
|
-
};
|
|
183
|
-
};
|
|
184
|
-
return {
|
|
185
|
-
...store.idempotentOperations !== void 0 ? { idempotentOperations: store.idempotentOperations } : {},
|
|
186
|
-
async get(workspaceId, path) {
|
|
187
|
-
const entry = await store.get(workspaceId, path);
|
|
188
|
-
return entry ? open(entry) : null;
|
|
189
|
-
},
|
|
190
|
-
async list(workspaceId, prefix) {
|
|
191
|
-
return Promise.all((await store.list(workspaceId, prefix)).map(open));
|
|
192
|
-
},
|
|
193
|
-
async put(input) {
|
|
194
|
-
if (input.content === void 0) return store.put(input);
|
|
195
|
-
const sealed = await codec.seal(encoder.encode(input.content), {
|
|
196
|
-
tenant: input.workspaceId,
|
|
197
|
-
path: input.path,
|
|
198
|
-
...input.operationId !== void 0 ? { operationId: input.operationId } : {}
|
|
199
|
-
});
|
|
200
|
-
return {
|
|
201
|
-
...await store.put({
|
|
202
|
-
...input,
|
|
203
|
-
content: `${SEALED_CONTENT_PREFIX}${base64(sealed)}`
|
|
204
|
-
}),
|
|
205
|
-
content: input.content
|
|
206
|
-
};
|
|
207
|
-
},
|
|
208
|
-
delete: (input) => store.delete(input),
|
|
209
|
-
tombstones: (workspaceId, prefix) => store.tombstones(workspaceId, prefix)
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
//#endregion
|
|
213
2
|
//#region src/workspace-store/store.ts
|
|
214
3
|
/** A write lost a compare-and-set race or targeted the wrong revision. Re-read and retry. */
|
|
215
4
|
var WorkspaceConflictError = class extends Error {
|
|
@@ -872,274 +661,282 @@ const idempotentOperationCase = ["operationId: a repeated put or delete returns
|
|
|
872
661
|
operationId: ""
|
|
873
662
|
}), TypeError, "an empty operationId");
|
|
874
663
|
}];
|
|
664
|
+
const compareAndSetCase = ["compare-and-set: create, stale update, and re-create conflict with a typed error", async (store) => {
|
|
665
|
+
const base = {
|
|
666
|
+
workspaceId: W,
|
|
667
|
+
path: "/files/a.md",
|
|
668
|
+
kind: "file",
|
|
669
|
+
actor: "alice"
|
|
670
|
+
};
|
|
671
|
+
const first = await store.put({
|
|
672
|
+
...base,
|
|
673
|
+
content: "one",
|
|
674
|
+
ifRevision: null
|
|
675
|
+
});
|
|
676
|
+
check(first.revision >= 1 && first.content === "one", "create returns revision and content");
|
|
677
|
+
check(first.createdBy === "alice" && first.createdAt && first.updatedAt, "create stamps author and times");
|
|
678
|
+
check((await rejects(() => store.put({
|
|
679
|
+
...base,
|
|
680
|
+
content: "dup",
|
|
681
|
+
ifRevision: null
|
|
682
|
+
}), WorkspaceConflictError, "create over a live path")).actual === first.revision, "conflict reports the live revision");
|
|
683
|
+
const second = await store.put({
|
|
684
|
+
...base,
|
|
685
|
+
content: "two",
|
|
686
|
+
ifRevision: first.revision,
|
|
687
|
+
actor: "bob"
|
|
688
|
+
});
|
|
689
|
+
check(second.revision > first.revision && second.id === first.id, "update bumps revision, keeps id");
|
|
690
|
+
check(second.createdBy === "alice" && second.updatedBy === "bob", "update keeps creator, records updater");
|
|
691
|
+
await rejects(() => store.put({
|
|
692
|
+
...base,
|
|
693
|
+
content: "stale",
|
|
694
|
+
ifRevision: first.revision
|
|
695
|
+
}), WorkspaceConflictError, "update at a stale revision");
|
|
696
|
+
check((await store.get(W, base.path))?.content === "two", "a refused write changes nothing");
|
|
697
|
+
check((await Promise.allSettled(["x", "y"].map((content) => store.put({
|
|
698
|
+
...base,
|
|
699
|
+
content,
|
|
700
|
+
ifRevision: second.revision
|
|
701
|
+
})))).filter((r) => r.status === "fulfilled").length === 1, "exactly one racing write wins");
|
|
702
|
+
}];
|
|
703
|
+
const entriesCase = ["entries: exactly one of content or bytes, normalized paths, tenant isolation, sorted list", async (store) => {
|
|
704
|
+
const bytes = {
|
|
705
|
+
key: "r2/obj-1",
|
|
706
|
+
size: 42,
|
|
707
|
+
contentType: "application/pdf"
|
|
708
|
+
};
|
|
709
|
+
const file = await store.put({
|
|
710
|
+
workspaceId: W,
|
|
711
|
+
path: "/files/b.pdf",
|
|
712
|
+
kind: "file",
|
|
713
|
+
actor: "alice",
|
|
714
|
+
bytes,
|
|
715
|
+
ifRevision: null
|
|
716
|
+
});
|
|
717
|
+
check(file.bytes?.key === bytes.key && file.content === void 0, "bytes reference round-trips");
|
|
718
|
+
await rejects(() => store.put({
|
|
719
|
+
workspaceId: W,
|
|
720
|
+
path: "/files/c",
|
|
721
|
+
kind: "file",
|
|
722
|
+
actor: "a",
|
|
723
|
+
ifRevision: null
|
|
724
|
+
}), TypeError, "neither content nor bytes");
|
|
725
|
+
for (const path of [
|
|
726
|
+
"files/x",
|
|
727
|
+
"/files/../x",
|
|
728
|
+
"/files//x",
|
|
729
|
+
"/files/x/"
|
|
730
|
+
]) await rejects(() => store.put({
|
|
731
|
+
workspaceId: W,
|
|
732
|
+
path,
|
|
733
|
+
kind: "file",
|
|
734
|
+
actor: "a",
|
|
735
|
+
content: "x",
|
|
736
|
+
ifRevision: null
|
|
737
|
+
}), TypeError, `path ${path}`);
|
|
738
|
+
await store.put({
|
|
739
|
+
workspaceId: W,
|
|
740
|
+
path: "/files/a.md",
|
|
741
|
+
kind: "file",
|
|
742
|
+
actor: "a",
|
|
743
|
+
content: "a",
|
|
744
|
+
ifRevision: null
|
|
745
|
+
});
|
|
746
|
+
await store.put({
|
|
747
|
+
workspaceId: "other",
|
|
748
|
+
path: "/files/z.md",
|
|
749
|
+
kind: "file",
|
|
750
|
+
actor: "a",
|
|
751
|
+
content: "z",
|
|
752
|
+
ifRevision: null
|
|
753
|
+
});
|
|
754
|
+
check((await store.list(W, "/files/")).map((e) => e.path).join() === "/files/a.md,/files/b.pdf", "list is sorted and tenant-scoped");
|
|
755
|
+
check(await store.get("other", "/files/a.md") === null, "another tenant cannot read the path");
|
|
756
|
+
check((await store.put({
|
|
757
|
+
workspaceId: W,
|
|
758
|
+
path: "/memories/members/alice/m1",
|
|
759
|
+
kind: "fact",
|
|
760
|
+
actor: "alice",
|
|
761
|
+
content: "x",
|
|
762
|
+
ifRevision: null
|
|
763
|
+
})).scope === "member" && file.scope === "workspace", "scope follows the path");
|
|
764
|
+
}];
|
|
765
|
+
const deleteTombstoneCase = ["delete erases content and keeps a tombstone; revisions stay monotonic", async (store) => {
|
|
766
|
+
const base = {
|
|
767
|
+
workspaceId: W,
|
|
768
|
+
path: "/files/secret.md",
|
|
769
|
+
kind: "file",
|
|
770
|
+
actor: "alice"
|
|
771
|
+
};
|
|
772
|
+
const entry = await store.put({
|
|
773
|
+
...base,
|
|
774
|
+
content: "card 4111",
|
|
775
|
+
ifRevision: null
|
|
776
|
+
});
|
|
777
|
+
await rejects(() => store.delete({
|
|
778
|
+
workspaceId: W,
|
|
779
|
+
path: base.path,
|
|
780
|
+
ifRevision: entry.revision + 5,
|
|
781
|
+
actor: "alice"
|
|
782
|
+
}), WorkspaceConflictError, "delete at a stale revision");
|
|
783
|
+
const tombstone = await store.delete({
|
|
784
|
+
workspaceId: W,
|
|
785
|
+
path: base.path,
|
|
786
|
+
ifRevision: entry.revision,
|
|
787
|
+
actor: "bob"
|
|
788
|
+
});
|
|
789
|
+
check(tombstone !== null, "delete returns a tombstone");
|
|
790
|
+
check(tombstone.id === entry.id && tombstone.revision > entry.revision, "tombstone keeps id, bumps revision");
|
|
791
|
+
check(tombstone.deletedBy === "bob" && Boolean(tombstone.deletedAt), "tombstone records who and when");
|
|
792
|
+
check(!JSON.stringify(tombstone).includes("4111"), "tombstone holds no content");
|
|
793
|
+
check(await store.get(W, base.path) === null, "deleted entry is gone");
|
|
794
|
+
check((await store.list(W, "/files/")).length === 0, "deleted entry is not listed");
|
|
795
|
+
const audit = await store.tombstones(W, "/files/");
|
|
796
|
+
check(audit.length === 1 && !JSON.stringify(audit).includes("4111"), "audit rows hold no content");
|
|
797
|
+
check(await store.delete({
|
|
798
|
+
workspaceId: W,
|
|
799
|
+
path: "/files/none",
|
|
800
|
+
actor: "a"
|
|
801
|
+
}) === null, "unconditional delete of a missing path is a no-op");
|
|
802
|
+
check((await store.put({
|
|
803
|
+
...base,
|
|
804
|
+
content: "new",
|
|
805
|
+
ifRevision: null
|
|
806
|
+
})).revision > tombstone.revision, "re-created path continues the revision sequence");
|
|
807
|
+
}];
|
|
808
|
+
const rememberRecallCase = ["tools: remember then recall round-trips, and forget erases", async (store) => {
|
|
809
|
+
const call = toolsFor(store, alice);
|
|
810
|
+
const saved = await call("memory.remember", {
|
|
811
|
+
text: "The walk-in freezer code is on the clipboard",
|
|
812
|
+
kind: "fact",
|
|
813
|
+
scope: "workspace"
|
|
814
|
+
});
|
|
815
|
+
check(saved.saved && saved.path === `/memories/workspace/${saved.id}`, "remember saves under workspace");
|
|
816
|
+
const found = await toolsFor(store, bob)("memory.recall", { query: "freezer code" });
|
|
817
|
+
check(found.memories[0]?.id === saved.id, "another member recalls a workspace fact");
|
|
818
|
+
check(found.memories[0]?.text === "The walk-in freezer code is on the clipboard", "whitespace is collapsed");
|
|
819
|
+
const updated = await call("memory.remember", {
|
|
820
|
+
text: "The freezer code is in the office",
|
|
821
|
+
kind: "fact",
|
|
822
|
+
scope: "workspace",
|
|
823
|
+
replaces: saved.id
|
|
824
|
+
});
|
|
825
|
+
check(updated.id === saved.id && updated.revision > saved.revision, "replaces updates in place");
|
|
826
|
+
await call("memory.forget", { id: saved.id });
|
|
827
|
+
check((await call("memory.recall", { query: "freezer" })).memories.length === 0, "forgotten memory is not recalled");
|
|
828
|
+
check((await store.tombstones(W)).some((row) => row.id === saved.id), "forget leaves a tombstone");
|
|
829
|
+
}];
|
|
830
|
+
const memberScopeCase = ["tools: a member cannot see or touch another member’s scope", async (store) => {
|
|
831
|
+
const mine = await toolsFor(store, alice)("memory.remember", {
|
|
832
|
+
text: "Alice prefers replies in Spanish",
|
|
833
|
+
kind: "preference",
|
|
834
|
+
scope: "me"
|
|
835
|
+
});
|
|
836
|
+
check(mine.path === `/memories/members/alice/${mine.id}`, "scope me writes under the member");
|
|
837
|
+
const asBob = toolsFor(store, bob);
|
|
838
|
+
check((await asBob("memory.recall", { query: "Spanish replies" })).memories.length === 0, "recall is member-isolated");
|
|
839
|
+
await rejects(() => asBob("memory.forget", { id: mine.id }), Error, "forget of another member’s memory");
|
|
840
|
+
check(await store.get(W, mine.path) !== null, "the other member’s memory survives");
|
|
841
|
+
await rejects(() => asBob("files.read", { path: mine.path }), Error, "files.read outside /files and /knowledge");
|
|
842
|
+
check(!renderPinnedMemory(await readTurnMemories(store, bob)).includes("Spanish"), "pinned memory is member-isolated");
|
|
843
|
+
check(renderPinnedMemory(await readTurnMemories(store, alice)).includes("Spanish"), "the owner sees it pinned");
|
|
844
|
+
}];
|
|
845
|
+
const policyCase = ["tools: default policy and admit hook refuse by name", async (store) => {
|
|
846
|
+
await rejects(() => toolsFor(store, alice)("memory.remember", {
|
|
847
|
+
text: "Never discount",
|
|
848
|
+
kind: "instruction",
|
|
849
|
+
scope: "workspace"
|
|
850
|
+
}), WorkspacePolicyError, "member instruction");
|
|
851
|
+
await rejects(() => toolsFor(store, alice)("memory.remember", {
|
|
852
|
+
text: "Use metric",
|
|
853
|
+
kind: "preference",
|
|
854
|
+
scope: "workspace"
|
|
855
|
+
}), WorkspacePolicyError, "member workspace preference");
|
|
856
|
+
await rejects(() => toolsFor(store, guest)("memory.remember", {
|
|
857
|
+
text: "I am Gus",
|
|
858
|
+
kind: "fact",
|
|
859
|
+
scope: "me"
|
|
860
|
+
}), WorkspacePolicyError, "guest write");
|
|
861
|
+
await rejects(() => toolsFor(store, alice)("files.write", {
|
|
862
|
+
path: "/knowledge/menu.md",
|
|
863
|
+
content: "x",
|
|
864
|
+
ifRevision: null
|
|
865
|
+
}), WorkspacePolicyError, "member knowledge write");
|
|
866
|
+
check((await toolsFor(store, owner)("memory.remember", {
|
|
867
|
+
text: "Never offer discounts",
|
|
868
|
+
kind: "instruction",
|
|
869
|
+
scope: "workspace"
|
|
870
|
+
})).saved, "owner saves an instruction");
|
|
871
|
+
const noPhones = defaultMemoryPolicy({ admit: (_turn, { text }) => /\d{3}-\d{4}/.test(text) ? "Phone numbers are not saved" : null });
|
|
872
|
+
check((await rejects(() => toolsFor(store, alice, noPhones)("memory.remember", {
|
|
873
|
+
text: "Call 555-0100",
|
|
874
|
+
kind: "fact",
|
|
875
|
+
scope: "me"
|
|
876
|
+
}), WorkspacePolicyError, "admit hook")).message === "Phone numbers are not saved", "refusal carries the product reason");
|
|
877
|
+
check((await store.list(W, "/memories/members/alice/")).length === 0, "refused writes store nothing");
|
|
878
|
+
}];
|
|
879
|
+
const fileToolsCase = ["tools: files write, read, CAS conflict, search, delete", async (store) => {
|
|
880
|
+
const call = toolsFor(store, alice);
|
|
881
|
+
const created = await call("files.write", {
|
|
882
|
+
path: "/files/notes/menu.md",
|
|
883
|
+
content: "Tuesday special: lamb",
|
|
884
|
+
ifRevision: null
|
|
885
|
+
});
|
|
886
|
+
await rejects(() => call("files.write", {
|
|
887
|
+
path: "/files/notes/menu.md",
|
|
888
|
+
content: "x",
|
|
889
|
+
ifRevision: null
|
|
890
|
+
}), WorkspaceConflictError, "files.write create over a live file");
|
|
891
|
+
const read = await toolsFor(store, bob)("files.read", { path: "/files/notes/menu.md" });
|
|
892
|
+
check(read.content === "Tuesday special: lamb" && read.revision === created.revision, "files.read returns content and revision");
|
|
893
|
+
const hits = await call("files.search", { query: "lamb special" });
|
|
894
|
+
check(hits.files[0]?.path === "/files/notes/menu.md" && hits.files[0].snippet.includes("lamb"), "files.search finds text");
|
|
895
|
+
check((await call("files.list", {})).files.length === 1, "files.list lists it");
|
|
896
|
+
await call("files.delete", {
|
|
897
|
+
path: "/files/notes/menu.md",
|
|
898
|
+
ifRevision: created.revision
|
|
899
|
+
});
|
|
900
|
+
await rejects(() => call("files.read", { path: "/files/notes/menu.md" }), Error, "read after delete");
|
|
901
|
+
}];
|
|
902
|
+
const pinnedMemoryCase = ["renderPinnedMemory: stable, ordered, budgeted, ends with the recall index", async (store) => {
|
|
903
|
+
await toolsFor(store, alice)("memory.remember", {
|
|
904
|
+
text: "Alice works weekends",
|
|
905
|
+
kind: "fact",
|
|
906
|
+
scope: "me"
|
|
907
|
+
});
|
|
908
|
+
await toolsFor(store, owner)("memory.remember", {
|
|
909
|
+
text: "Always greet by name",
|
|
910
|
+
kind: "instruction",
|
|
911
|
+
scope: "workspace"
|
|
912
|
+
});
|
|
913
|
+
for (let i = 0; i < 40; i += 1) await toolsFor(store, alice)("memory.remember", {
|
|
914
|
+
text: `Supplier ${i} delivers on day ${i % 7}`,
|
|
915
|
+
kind: "fact",
|
|
916
|
+
scope: "workspace"
|
|
917
|
+
});
|
|
918
|
+
const entries = await readTurnMemories(store, alice);
|
|
919
|
+
const block = renderPinnedMemory(entries);
|
|
920
|
+
check(block === renderPinnedMemory([...entries].reverse()), "input order does not change the output");
|
|
921
|
+
check(block === renderPinnedMemory(await readTurnMemories(store, alice)), "identical input renders identically");
|
|
922
|
+
const lines = block.split("\n");
|
|
923
|
+
check(lines.findIndex((l) => l.includes("Always greet")) < lines.findIndex((l) => l.includes("Supplier")), "instructions first");
|
|
924
|
+
check(lines.at(-1) === "</saved-memory>" && lines.at(-2)?.startsWith("Recall more:"), "ends with the recall index");
|
|
925
|
+
check(entries.every((e) => block.includes(`[${e.id}]`)), "every memory is shown with its id under the default budget");
|
|
926
|
+
const small = renderPinnedMemory(entries, { maxTokens: 120 });
|
|
927
|
+
check(small.length <= 480 && /\d+ more not shown\./.test(small), "a small budget truncates and counts");
|
|
928
|
+
}];
|
|
875
929
|
/** The contract as named cases; each case builds its own store. */
|
|
876
930
|
function workspaceStoreContractCases(makeStore, options = {}) {
|
|
877
931
|
const cases = [
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
...base,
|
|
887
|
-
content: "one",
|
|
888
|
-
ifRevision: null
|
|
889
|
-
});
|
|
890
|
-
check(first.revision >= 1 && first.content === "one", "create returns revision and content");
|
|
891
|
-
check(first.createdBy === "alice" && first.createdAt && first.updatedAt, "create stamps author and times");
|
|
892
|
-
check((await rejects(() => store.put({
|
|
893
|
-
...base,
|
|
894
|
-
content: "dup",
|
|
895
|
-
ifRevision: null
|
|
896
|
-
}), WorkspaceConflictError, "create over a live path")).actual === first.revision, "conflict reports the live revision");
|
|
897
|
-
const second = await store.put({
|
|
898
|
-
...base,
|
|
899
|
-
content: "two",
|
|
900
|
-
ifRevision: first.revision,
|
|
901
|
-
actor: "bob"
|
|
902
|
-
});
|
|
903
|
-
check(second.revision > first.revision && second.id === first.id, "update bumps revision, keeps id");
|
|
904
|
-
check(second.createdBy === "alice" && second.updatedBy === "bob", "update keeps creator, records updater");
|
|
905
|
-
await rejects(() => store.put({
|
|
906
|
-
...base,
|
|
907
|
-
content: "stale",
|
|
908
|
-
ifRevision: first.revision
|
|
909
|
-
}), WorkspaceConflictError, "update at a stale revision");
|
|
910
|
-
check((await store.get(W, base.path))?.content === "two", "a refused write changes nothing");
|
|
911
|
-
check((await Promise.allSettled(["x", "y"].map((content) => store.put({
|
|
912
|
-
...base,
|
|
913
|
-
content,
|
|
914
|
-
ifRevision: second.revision
|
|
915
|
-
})))).filter((r) => r.status === "fulfilled").length === 1, "exactly one racing write wins");
|
|
916
|
-
}],
|
|
917
|
-
["entries: exactly one of content or bytes, normalized paths, tenant isolation, sorted list", async (store) => {
|
|
918
|
-
const bytes = {
|
|
919
|
-
key: "r2/obj-1",
|
|
920
|
-
size: 42,
|
|
921
|
-
contentType: "application/pdf"
|
|
922
|
-
};
|
|
923
|
-
const file = await store.put({
|
|
924
|
-
workspaceId: W,
|
|
925
|
-
path: "/files/b.pdf",
|
|
926
|
-
kind: "file",
|
|
927
|
-
actor: "alice",
|
|
928
|
-
bytes,
|
|
929
|
-
ifRevision: null
|
|
930
|
-
});
|
|
931
|
-
check(file.bytes?.key === bytes.key && file.content === void 0, "bytes reference round-trips");
|
|
932
|
-
await rejects(() => store.put({
|
|
933
|
-
workspaceId: W,
|
|
934
|
-
path: "/files/c",
|
|
935
|
-
kind: "file",
|
|
936
|
-
actor: "a",
|
|
937
|
-
ifRevision: null
|
|
938
|
-
}), TypeError, "neither content nor bytes");
|
|
939
|
-
for (const path of [
|
|
940
|
-
"files/x",
|
|
941
|
-
"/files/../x",
|
|
942
|
-
"/files//x",
|
|
943
|
-
"/files/x/"
|
|
944
|
-
]) await rejects(() => store.put({
|
|
945
|
-
workspaceId: W,
|
|
946
|
-
path,
|
|
947
|
-
kind: "file",
|
|
948
|
-
actor: "a",
|
|
949
|
-
content: "x",
|
|
950
|
-
ifRevision: null
|
|
951
|
-
}), TypeError, `path ${path}`);
|
|
952
|
-
await store.put({
|
|
953
|
-
workspaceId: W,
|
|
954
|
-
path: "/files/a.md",
|
|
955
|
-
kind: "file",
|
|
956
|
-
actor: "a",
|
|
957
|
-
content: "a",
|
|
958
|
-
ifRevision: null
|
|
959
|
-
});
|
|
960
|
-
await store.put({
|
|
961
|
-
workspaceId: "other",
|
|
962
|
-
path: "/files/z.md",
|
|
963
|
-
kind: "file",
|
|
964
|
-
actor: "a",
|
|
965
|
-
content: "z",
|
|
966
|
-
ifRevision: null
|
|
967
|
-
});
|
|
968
|
-
check((await store.list(W, "/files/")).map((e) => e.path).join() === "/files/a.md,/files/b.pdf", "list is sorted and tenant-scoped");
|
|
969
|
-
check(await store.get("other", "/files/a.md") === null, "another tenant cannot read the path");
|
|
970
|
-
check((await store.put({
|
|
971
|
-
workspaceId: W,
|
|
972
|
-
path: "/memories/members/alice/m1",
|
|
973
|
-
kind: "fact",
|
|
974
|
-
actor: "alice",
|
|
975
|
-
content: "x",
|
|
976
|
-
ifRevision: null
|
|
977
|
-
})).scope === "member" && file.scope === "workspace", "scope follows the path");
|
|
978
|
-
}],
|
|
979
|
-
["delete erases content and keeps a tombstone; revisions stay monotonic", async (store) => {
|
|
980
|
-
const base = {
|
|
981
|
-
workspaceId: W,
|
|
982
|
-
path: "/files/secret.md",
|
|
983
|
-
kind: "file",
|
|
984
|
-
actor: "alice"
|
|
985
|
-
};
|
|
986
|
-
const entry = await store.put({
|
|
987
|
-
...base,
|
|
988
|
-
content: "card 4111",
|
|
989
|
-
ifRevision: null
|
|
990
|
-
});
|
|
991
|
-
await rejects(() => store.delete({
|
|
992
|
-
workspaceId: W,
|
|
993
|
-
path: base.path,
|
|
994
|
-
ifRevision: entry.revision + 5,
|
|
995
|
-
actor: "alice"
|
|
996
|
-
}), WorkspaceConflictError, "delete at a stale revision");
|
|
997
|
-
const tombstone = await store.delete({
|
|
998
|
-
workspaceId: W,
|
|
999
|
-
path: base.path,
|
|
1000
|
-
ifRevision: entry.revision,
|
|
1001
|
-
actor: "bob"
|
|
1002
|
-
});
|
|
1003
|
-
check(tombstone !== null, "delete returns a tombstone");
|
|
1004
|
-
check(tombstone.id === entry.id && tombstone.revision > entry.revision, "tombstone keeps id, bumps revision");
|
|
1005
|
-
check(tombstone.deletedBy === "bob" && Boolean(tombstone.deletedAt), "tombstone records who and when");
|
|
1006
|
-
check(!JSON.stringify(tombstone).includes("4111"), "tombstone holds no content");
|
|
1007
|
-
check(await store.get(W, base.path) === null, "deleted entry is gone");
|
|
1008
|
-
check((await store.list(W, "/files/")).length === 0, "deleted entry is not listed");
|
|
1009
|
-
const audit = await store.tombstones(W, "/files/");
|
|
1010
|
-
check(audit.length === 1 && !JSON.stringify(audit).includes("4111"), "audit rows hold no content");
|
|
1011
|
-
check(await store.delete({
|
|
1012
|
-
workspaceId: W,
|
|
1013
|
-
path: "/files/none",
|
|
1014
|
-
actor: "a"
|
|
1015
|
-
}) === null, "unconditional delete of a missing path is a no-op");
|
|
1016
|
-
check((await store.put({
|
|
1017
|
-
...base,
|
|
1018
|
-
content: "new",
|
|
1019
|
-
ifRevision: null
|
|
1020
|
-
})).revision > tombstone.revision, "re-created path continues the revision sequence");
|
|
1021
|
-
}],
|
|
1022
|
-
["tools: remember then recall round-trips, and forget erases", async (store) => {
|
|
1023
|
-
const call = toolsFor(store, alice);
|
|
1024
|
-
const saved = await call("memory.remember", {
|
|
1025
|
-
text: "The walk-in freezer code is on the clipboard",
|
|
1026
|
-
kind: "fact",
|
|
1027
|
-
scope: "workspace"
|
|
1028
|
-
});
|
|
1029
|
-
check(saved.saved && saved.path === `/memories/workspace/${saved.id}`, "remember saves under workspace");
|
|
1030
|
-
const found = await toolsFor(store, bob)("memory.recall", { query: "freezer code" });
|
|
1031
|
-
check(found.memories[0]?.id === saved.id, "another member recalls a workspace fact");
|
|
1032
|
-
check(found.memories[0]?.text === "The walk-in freezer code is on the clipboard", "whitespace is collapsed");
|
|
1033
|
-
const updated = await call("memory.remember", {
|
|
1034
|
-
text: "The freezer code is in the office",
|
|
1035
|
-
kind: "fact",
|
|
1036
|
-
scope: "workspace",
|
|
1037
|
-
replaces: saved.id
|
|
1038
|
-
});
|
|
1039
|
-
check(updated.id === saved.id && updated.revision > saved.revision, "replaces updates in place");
|
|
1040
|
-
await call("memory.forget", { id: saved.id });
|
|
1041
|
-
check((await call("memory.recall", { query: "freezer" })).memories.length === 0, "forgotten memory is not recalled");
|
|
1042
|
-
check((await store.tombstones(W)).some((row) => row.id === saved.id), "forget leaves a tombstone");
|
|
1043
|
-
}],
|
|
1044
|
-
["tools: a member cannot see or touch another member’s scope", async (store) => {
|
|
1045
|
-
const mine = await toolsFor(store, alice)("memory.remember", {
|
|
1046
|
-
text: "Alice prefers replies in Spanish",
|
|
1047
|
-
kind: "preference",
|
|
1048
|
-
scope: "me"
|
|
1049
|
-
});
|
|
1050
|
-
check(mine.path === `/memories/members/alice/${mine.id}`, "scope me writes under the member");
|
|
1051
|
-
const asBob = toolsFor(store, bob);
|
|
1052
|
-
check((await asBob("memory.recall", { query: "Spanish replies" })).memories.length === 0, "recall is member-isolated");
|
|
1053
|
-
await rejects(() => asBob("memory.forget", { id: mine.id }), Error, "forget of another member’s memory");
|
|
1054
|
-
check(await store.get(W, mine.path) !== null, "the other member’s memory survives");
|
|
1055
|
-
await rejects(() => asBob("files.read", { path: mine.path }), Error, "files.read outside /files and /knowledge");
|
|
1056
|
-
check(!renderPinnedMemory(await readTurnMemories(store, bob)).includes("Spanish"), "pinned memory is member-isolated");
|
|
1057
|
-
check(renderPinnedMemory(await readTurnMemories(store, alice)).includes("Spanish"), "the owner sees it pinned");
|
|
1058
|
-
}],
|
|
1059
|
-
["tools: default policy and admit hook refuse by name", async (store) => {
|
|
1060
|
-
await rejects(() => toolsFor(store, alice)("memory.remember", {
|
|
1061
|
-
text: "Never discount",
|
|
1062
|
-
kind: "instruction",
|
|
1063
|
-
scope: "workspace"
|
|
1064
|
-
}), WorkspacePolicyError, "member instruction");
|
|
1065
|
-
await rejects(() => toolsFor(store, alice)("memory.remember", {
|
|
1066
|
-
text: "Use metric",
|
|
1067
|
-
kind: "preference",
|
|
1068
|
-
scope: "workspace"
|
|
1069
|
-
}), WorkspacePolicyError, "member workspace preference");
|
|
1070
|
-
await rejects(() => toolsFor(store, guest)("memory.remember", {
|
|
1071
|
-
text: "I am Gus",
|
|
1072
|
-
kind: "fact",
|
|
1073
|
-
scope: "me"
|
|
1074
|
-
}), WorkspacePolicyError, "guest write");
|
|
1075
|
-
await rejects(() => toolsFor(store, alice)("files.write", {
|
|
1076
|
-
path: "/knowledge/menu.md",
|
|
1077
|
-
content: "x",
|
|
1078
|
-
ifRevision: null
|
|
1079
|
-
}), WorkspacePolicyError, "member knowledge write");
|
|
1080
|
-
check((await toolsFor(store, owner)("memory.remember", {
|
|
1081
|
-
text: "Never offer discounts",
|
|
1082
|
-
kind: "instruction",
|
|
1083
|
-
scope: "workspace"
|
|
1084
|
-
})).saved, "owner saves an instruction");
|
|
1085
|
-
const noPhones = defaultMemoryPolicy({ admit: (_turn, { text }) => /\d{3}-\d{4}/.test(text) ? "Phone numbers are not saved" : null });
|
|
1086
|
-
check((await rejects(() => toolsFor(store, alice, noPhones)("memory.remember", {
|
|
1087
|
-
text: "Call 555-0100",
|
|
1088
|
-
kind: "fact",
|
|
1089
|
-
scope: "me"
|
|
1090
|
-
}), WorkspacePolicyError, "admit hook")).message === "Phone numbers are not saved", "refusal carries the product reason");
|
|
1091
|
-
check((await store.list(W, "/memories/members/alice/")).length === 0, "refused writes store nothing");
|
|
1092
|
-
}],
|
|
1093
|
-
["tools: files write, read, CAS conflict, search, delete", async (store) => {
|
|
1094
|
-
const call = toolsFor(store, alice);
|
|
1095
|
-
const created = await call("files.write", {
|
|
1096
|
-
path: "/files/notes/menu.md",
|
|
1097
|
-
content: "Tuesday special: lamb",
|
|
1098
|
-
ifRevision: null
|
|
1099
|
-
});
|
|
1100
|
-
await rejects(() => call("files.write", {
|
|
1101
|
-
path: "/files/notes/menu.md",
|
|
1102
|
-
content: "x",
|
|
1103
|
-
ifRevision: null
|
|
1104
|
-
}), WorkspaceConflictError, "files.write create over a live file");
|
|
1105
|
-
const read = await toolsFor(store, bob)("files.read", { path: "/files/notes/menu.md" });
|
|
1106
|
-
check(read.content === "Tuesday special: lamb" && read.revision === created.revision, "files.read returns content and revision");
|
|
1107
|
-
const hits = await call("files.search", { query: "lamb special" });
|
|
1108
|
-
check(hits.files[0]?.path === "/files/notes/menu.md" && hits.files[0].snippet.includes("lamb"), "files.search finds text");
|
|
1109
|
-
check((await call("files.list", {})).files.length === 1, "files.list lists it");
|
|
1110
|
-
await call("files.delete", {
|
|
1111
|
-
path: "/files/notes/menu.md",
|
|
1112
|
-
ifRevision: created.revision
|
|
1113
|
-
});
|
|
1114
|
-
await rejects(() => call("files.read", { path: "/files/notes/menu.md" }), Error, "read after delete");
|
|
1115
|
-
}],
|
|
1116
|
-
["renderPinnedMemory: stable, ordered, budgeted, ends with the recall index", async (store) => {
|
|
1117
|
-
await toolsFor(store, alice)("memory.remember", {
|
|
1118
|
-
text: "Alice works weekends",
|
|
1119
|
-
kind: "fact",
|
|
1120
|
-
scope: "me"
|
|
1121
|
-
});
|
|
1122
|
-
await toolsFor(store, owner)("memory.remember", {
|
|
1123
|
-
text: "Always greet by name",
|
|
1124
|
-
kind: "instruction",
|
|
1125
|
-
scope: "workspace"
|
|
1126
|
-
});
|
|
1127
|
-
for (let i = 0; i < 40; i += 1) await toolsFor(store, alice)("memory.remember", {
|
|
1128
|
-
text: `Supplier ${i} delivers on day ${i % 7}`,
|
|
1129
|
-
kind: "fact",
|
|
1130
|
-
scope: "workspace"
|
|
1131
|
-
});
|
|
1132
|
-
const entries = await readTurnMemories(store, alice);
|
|
1133
|
-
const block = renderPinnedMemory(entries);
|
|
1134
|
-
check(block === renderPinnedMemory([...entries].reverse()), "input order does not change the output");
|
|
1135
|
-
check(block === renderPinnedMemory(await readTurnMemories(store, alice)), "identical input renders identically");
|
|
1136
|
-
const lines = block.split("\n");
|
|
1137
|
-
check(lines.findIndex((l) => l.includes("Always greet")) < lines.findIndex((l) => l.includes("Supplier")), "instructions first");
|
|
1138
|
-
check(lines.at(-1) === "</saved-memory>" && lines.at(-2)?.startsWith("Recall more:"), "ends with the recall index");
|
|
1139
|
-
check(entries.every((e) => block.includes(`[${e.id}]`)), "every memory is shown with its id under the default budget");
|
|
1140
|
-
const small = renderPinnedMemory(entries, { maxTokens: 120 });
|
|
1141
|
-
check(small.length <= 480 && /\d+ more not shown\./.test(small), "a small budget truncates and counts");
|
|
1142
|
-
}]
|
|
932
|
+
compareAndSetCase,
|
|
933
|
+
entriesCase,
|
|
934
|
+
deleteTombstoneCase,
|
|
935
|
+
rememberRecallCase,
|
|
936
|
+
memberScopeCase,
|
|
937
|
+
policyCase,
|
|
938
|
+
fileToolsCase,
|
|
939
|
+
pinnedMemoryCase
|
|
1143
940
|
];
|
|
1144
941
|
if (options.idempotentOperations) cases.push(idempotentOperationCase);
|
|
1145
942
|
return cases.map(([name, body]) => ({
|
|
@@ -1168,209 +965,6 @@ async function runWorkspaceStoreContract(makeStore, options = {}) {
|
|
|
1168
965
|
return results;
|
|
1169
966
|
}
|
|
1170
967
|
//#endregion
|
|
1171
|
-
|
|
1172
|
-
/** Opening marker for the generated block in a harness's native memory file. */
|
|
1173
|
-
const HARNESS_MEMORY_BEGIN = "<!-- workspace-store:memory begin. Generated from the workspace store; save memories with memory.remember. -->";
|
|
1174
|
-
/** Closing marker for the generated block in a harness's native memory file. */
|
|
1175
|
-
const HARNESS_MEMORY_END = "<!-- workspace-store:memory end -->";
|
|
1176
|
-
/** The generated block a host writes into a harness's native memory location. */
|
|
1177
|
-
function renderHarnessMemory(entries, options = {}) {
|
|
1178
|
-
return [
|
|
1179
|
-
HARNESS_MEMORY_BEGIN,
|
|
1180
|
-
renderPinnedMemory(entries, options),
|
|
1181
|
-
HARNESS_MEMORY_END
|
|
1182
|
-
].join("\n");
|
|
1183
|
-
}
|
|
1184
|
-
/** Replace the generated block in `native`, or append one; text outside it is kept byte for byte. */
|
|
1185
|
-
function projectHarnessMemory(native, block) {
|
|
1186
|
-
const { before, after, found } = splitGenerated(native);
|
|
1187
|
-
if (found) return `${before}${block}${after}`;
|
|
1188
|
-
return native.length === 0 ? `${block}\n` : `${native}${native.endsWith("\n") ? "\n" : "\n\n"}${block}\n`;
|
|
1189
|
-
}
|
|
1190
|
-
function splitGenerated(native) {
|
|
1191
|
-
const start = native.indexOf(HARNESS_MEMORY_BEGIN);
|
|
1192
|
-
const end = start < 0 ? -1 : native.indexOf(HARNESS_MEMORY_END, start);
|
|
1193
|
-
if (start < 0 || end < 0) return {
|
|
1194
|
-
before: native,
|
|
1195
|
-
block: "",
|
|
1196
|
-
after: "",
|
|
1197
|
-
found: false
|
|
1198
|
-
};
|
|
1199
|
-
const stop = end + 35;
|
|
1200
|
-
return {
|
|
1201
|
-
before: native.slice(0, start),
|
|
1202
|
-
block: native.slice(start, stop),
|
|
1203
|
-
after: native.slice(stop),
|
|
1204
|
-
found: true
|
|
1205
|
-
};
|
|
1206
|
-
}
|
|
1207
|
-
/** Ids listed in a generated block (`- [id] kind, scope: text`). */
|
|
1208
|
-
function projectedIds(block) {
|
|
1209
|
-
const ids = /* @__PURE__ */ new Map();
|
|
1210
|
-
for (const match of block.matchAll(/^- \[([A-Za-z0-9_-]{1,64})\] [a-z]+, (?:me|workspace): (.*)$/gm)) ids.set(match[1], match[2]);
|
|
1211
|
-
return ids;
|
|
1212
|
-
}
|
|
1213
|
-
/** Lowercase, collapse spaces, drop a leading "Remember:" and trailing punctuation: the duplicate key. */
|
|
1214
|
-
function normalizeMemoryText(text) {
|
|
1215
|
-
return text.replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)?/, "").replace(/^(?:remember|note)\b\s*(?:that\b)?\s*:?\s*/i, "").replace(/\s+/g, " ").trim().replace(/[.!;,\s]+$/, "").toLowerCase();
|
|
1216
|
-
}
|
|
1217
|
-
function stripFrontmatter(text) {
|
|
1218
|
-
return text.startsWith("---\n") ? text.slice(text.indexOf("\n---", 4) + 4).replace(/^\n+/, "") : text;
|
|
1219
|
-
}
|
|
1220
|
-
/** Lines that read as one memory: bullets and plain sentences, not headings, markers or fences. */
|
|
1221
|
-
function memoryLines(text) {
|
|
1222
|
-
const lines = [];
|
|
1223
|
-
let fenced = false;
|
|
1224
|
-
for (const raw of stripFrontmatter(text).split("\n")) {
|
|
1225
|
-
const line = raw.trim();
|
|
1226
|
-
if (line.startsWith("```")) {
|
|
1227
|
-
fenced = !fenced;
|
|
1228
|
-
continue;
|
|
1229
|
-
}
|
|
1230
|
-
if (fenced || !line || line.startsWith("#") || line.startsWith("<!--") || line.startsWith("@")) continue;
|
|
1231
|
-
const fact = line.replace(/^(?:[-*+]\s+|\d+[.)]\s+)/, "").trim();
|
|
1232
|
-
if (fact.length >= 3 && fact.length <= 1e3) lines.push(fact);
|
|
1233
|
-
}
|
|
1234
|
-
return lines;
|
|
1235
|
-
}
|
|
1236
|
-
/**
|
|
1237
|
-
* What the harness added during the turn, outside the generated block: new
|
|
1238
|
-
* lines in files that existed, and the body of new files (a Claude Code memory
|
|
1239
|
-
* directory entry). Lines removed or present before are ignored; a removal is
|
|
1240
|
-
* not a forget, which only `memory.forget` performs.
|
|
1241
|
-
*/
|
|
1242
|
-
function captureHarnessMemory(before, after) {
|
|
1243
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1244
|
-
const facts = [];
|
|
1245
|
-
for (const [path, next] of Object.entries(after)) {
|
|
1246
|
-
if (next === void 0) continue;
|
|
1247
|
-
const old = new Set(memoryLines(outside(before[path] ?? "")).map(normalizeMemoryText));
|
|
1248
|
-
for (const fact of memoryLines(outside(next))) {
|
|
1249
|
-
const key = normalizeMemoryText(fact);
|
|
1250
|
-
if (!key || old.has(key) || seen.has(key)) continue;
|
|
1251
|
-
seen.add(key);
|
|
1252
|
-
facts.push(fact);
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
return facts;
|
|
1256
|
-
}
|
|
1257
|
-
function outside(native) {
|
|
1258
|
-
const { before, after, found } = splitGenerated(native);
|
|
1259
|
-
return found ? `${before}\n${after}` : native;
|
|
1260
|
-
}
|
|
1261
|
-
const ACTOR = /^harness:([a-z0-9][a-z0-9._-]{0,40})\/turn:([^/\s]{1,128})\/member:([^/\s]{1,128})$/;
|
|
1262
|
-
/** The entry `actor` for a captured memory, so `createdBy` carries its provenance. */
|
|
1263
|
-
function harnessActor({ harness, turnId, memberId }) {
|
|
1264
|
-
const actor = `harness:${harness}/turn:${turnId}/member:${memberId}`;
|
|
1265
|
-
if (!ACTOR.test(actor)) throw new TypeError(`invalid harness provenance: ${actor}`);
|
|
1266
|
-
return actor;
|
|
1267
|
-
}
|
|
1268
|
-
/** Read the harness, turn, and member provenance encoded by `harnessActor`. */
|
|
1269
|
-
function parseHarnessActor(actor) {
|
|
1270
|
-
const match = ACTOR.exec(actor);
|
|
1271
|
-
return match ? {
|
|
1272
|
-
harness: match[1],
|
|
1273
|
-
turnId: match[2],
|
|
1274
|
-
memberId: match[3]
|
|
1275
|
-
} : null;
|
|
1276
|
-
}
|
|
1277
|
-
/**
|
|
1278
|
-
* Save captured facts as workspace memories through the same policy as
|
|
1279
|
-
* `memory.remember`, with the harness and turn as the actor. Duplicates of a
|
|
1280
|
-
* memory the turn can see, or of an earlier fact in the batch, are skipped.
|
|
1281
|
-
*/
|
|
1282
|
-
async function ingestHarnessMemories(context, input) {
|
|
1283
|
-
const turn = await context.activeTurn();
|
|
1284
|
-
const actor = harnessActor({
|
|
1285
|
-
harness: input.harness,
|
|
1286
|
-
turnId: input.turnId,
|
|
1287
|
-
memberId: turn.memberId
|
|
1288
|
-
});
|
|
1289
|
-
const newId = context.newId ?? (() => crypto.randomUUID());
|
|
1290
|
-
const known = new Set((await readTurnMemories(context.store, turn)).map((entry) => normalizeMemoryText(entry.content ?? "")));
|
|
1291
|
-
const saved = [];
|
|
1292
|
-
const duplicates = [];
|
|
1293
|
-
const refused = [];
|
|
1294
|
-
for (const raw of input.facts) {
|
|
1295
|
-
const text = raw.replace(/\s+/g, " ").trim();
|
|
1296
|
-
const key = normalizeMemoryText(text);
|
|
1297
|
-
if (!key) continue;
|
|
1298
|
-
if (known.has(key)) {
|
|
1299
|
-
duplicates.push(text);
|
|
1300
|
-
continue;
|
|
1301
|
-
}
|
|
1302
|
-
const id = newId();
|
|
1303
|
-
const path = `/memories/workspace/${id}`;
|
|
1304
|
-
try {
|
|
1305
|
-
await guard(context, turn, {
|
|
1306
|
-
op: "write",
|
|
1307
|
-
path,
|
|
1308
|
-
kind: "fact"
|
|
1309
|
-
}, text);
|
|
1310
|
-
} catch (error) {
|
|
1311
|
-
refused.push({
|
|
1312
|
-
text,
|
|
1313
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
1314
|
-
});
|
|
1315
|
-
continue;
|
|
1316
|
-
}
|
|
1317
|
-
known.add(key);
|
|
1318
|
-
saved.push(await context.store.put({
|
|
1319
|
-
workspaceId: turn.workspaceId,
|
|
1320
|
-
path,
|
|
1321
|
-
kind: "fact",
|
|
1322
|
-
ifRevision: null,
|
|
1323
|
-
actor,
|
|
1324
|
-
content: text,
|
|
1325
|
-
id
|
|
1326
|
-
}));
|
|
1327
|
-
}
|
|
1328
|
-
return {
|
|
1329
|
-
saved,
|
|
1330
|
-
duplicates,
|
|
1331
|
-
refused
|
|
1332
|
-
};
|
|
1333
|
-
}
|
|
1334
|
-
/**
|
|
1335
|
-
* Compare each native file with the store as `turn` sees it, after ingest ran.
|
|
1336
|
-
* `before` is the files as projected at session start, `after` as they are now:
|
|
1337
|
-
* only what the harness added since then can be uncaptured, so the profile's own
|
|
1338
|
-
* generated text in the same file never counts. Files that held a generated
|
|
1339
|
-
* block at session start are projection targets: losing the block counts every
|
|
1340
|
-
* visible memory as missing. Other files (a new memory-directory entry) can
|
|
1341
|
-
* only hold uncaptured facts.
|
|
1342
|
-
*/
|
|
1343
|
-
async function memoryDivergence(store, turn, before, after) {
|
|
1344
|
-
const entries = (await readTurnMemories(store, turn)).filter((entry) => entry.content !== void 0);
|
|
1345
|
-
const byId = new Map(entries.map((entry) => [entry.id, entry.content.replace(/\s+/g, " ").trim()]));
|
|
1346
|
-
const stored = new Set(entries.map((entry) => normalizeMemoryText(entry.content)));
|
|
1347
|
-
const files = {};
|
|
1348
|
-
let missing = 0;
|
|
1349
|
-
let stale = 0;
|
|
1350
|
-
let uncaptured = 0;
|
|
1351
|
-
for (const [path, native] of Object.entries(after)) {
|
|
1352
|
-
if (native === void 0) continue;
|
|
1353
|
-
const target = splitGenerated(before[path] ?? "").found || splitGenerated(native).found;
|
|
1354
|
-
const listed = projectedIds(splitGenerated(native).block);
|
|
1355
|
-
const report = {
|
|
1356
|
-
missing: target ? [...byId.keys()].filter((id) => !listed.has(id)) : [],
|
|
1357
|
-
stale: [...listed].filter(([id, text]) => byId.get(id) !== text).map(([id]) => id),
|
|
1358
|
-
uncaptured: captureHarnessMemory({ [path]: before[path] }, { [path]: native }).filter((fact) => !stored.has(normalizeMemoryText(fact)))
|
|
1359
|
-
};
|
|
1360
|
-
missing += report.missing.length;
|
|
1361
|
-
stale += report.stale.length;
|
|
1362
|
-
uncaptured += report.uncaptured.length;
|
|
1363
|
-
files[path] = report;
|
|
1364
|
-
}
|
|
1365
|
-
return {
|
|
1366
|
-
total: missing + stale + uncaptured,
|
|
1367
|
-
missing,
|
|
1368
|
-
stale,
|
|
1369
|
-
uncaptured,
|
|
1370
|
-
files
|
|
1371
|
-
};
|
|
1372
|
-
}
|
|
1373
|
-
//#endregion
|
|
1374
|
-
export { HARNESS_MEMORY_BEGIN, SEALED_CONTENT_PREFIX, WorkspaceCodecError, WorkspaceConflictError, aesGcmContentCodecFromEnv, assertWorkspacePath, captureHarnessMemory, checkWorkspacePut, createAesGcmContentCodec, createFileTools, createInMemoryWorkspaceStore, createMemoryTools, defaultMemoryPolicy, ingestHarnessMemories, memoryDivergence, openWorkspaceBytes, parseHarnessActor, projectHarnessMemory, readTurnMemories, renderHarnessMemory, renderPinnedMemory, runWorkspaceStoreContract, sealWorkspaceBytes, withContentCodec, workspaceStoreContractCases };
|
|
968
|
+
export { WorkspaceConflictError, assertWorkspacePath, checkWorkspacePut, createFileTools, createInMemoryWorkspaceStore, createMemoryTools, defaultMemoryPolicy, readTurnMemories, renderPinnedMemory, runWorkspaceStoreContract, workspaceStoreContractCases };
|
|
1375
969
|
|
|
1376
970
|
//# sourceMappingURL=index.js.map
|