@frockbot/plugin-memory 0.0.0 → 0.1.1
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/frockbot.json +15 -0
- package/package.json +35 -6
- package/src/agent.test.ts +590 -0
- package/src/agent.ts +1135 -0
- package/src/chunker.ts +104 -0
- package/src/documents.ts +97 -0
- package/src/embeddings.ts +23 -0
- package/src/facts.test.ts +126 -0
- package/src/facts.ts +258 -0
- package/src/index.ts +15 -0
- package/src/indexer.test.ts +91 -0
- package/src/indexer.ts +0 -0
- package/src/manifest.ts +3 -0
- package/src/projects.ts +85 -0
- package/src/render.test.ts +438 -0
- package/src/render.ts +478 -0
- package/src/roots.ts +158 -0
- package/src/searcher.ts +159 -0
- package/src/secrets.ts +45 -0
- package/src/store.test.ts +475 -0
- package/src/store.ts +568 -0
- package/src/testing.ts +98 -0
- package/src/types.ts +54 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/frockbot.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 3,
|
|
3
|
+
"id": "memory",
|
|
4
|
+
"displayName": "Memory",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"compatibility": {
|
|
7
|
+
"frockbot": ">=0.0.1"
|
|
8
|
+
},
|
|
9
|
+
"contributions": {
|
|
10
|
+
"runtime": {
|
|
11
|
+
"entry": "./agent"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"permissions": ["memory:read", "memory:write"]
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./agent": "./src/agent.ts",
|
|
9
|
+
"./manifest": "./src/manifest.ts",
|
|
10
|
+
"./projects": "./src/projects.ts",
|
|
11
|
+
"./roots": "./src/roots.ts",
|
|
12
|
+
"./store": "./src/store.ts",
|
|
13
|
+
"./testing": "./src/testing.ts",
|
|
14
|
+
"./frockbot.json": "./frockbot.json",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"frockbot": {
|
|
18
|
+
"manifest": "./frockbot.json"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "bun test src",
|
|
22
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@frockbot/kernel-agent-loop": "0.1.1",
|
|
26
|
+
"@frockbot/kernel-contracts": "0.1.1",
|
|
27
|
+
"@frockbot/secret-shapes": "0.1.1",
|
|
28
|
+
"@frockbot/workspace-store": "0.1.1",
|
|
29
|
+
"cordis": "4.0.0-rc.8"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/bun": "1.4.0",
|
|
33
|
+
"typescript": "^7.0.2"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
6
38
|
"repository": {
|
|
7
39
|
"type": "git",
|
|
8
40
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
41
|
"directory": "packages/plugin-memory"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
42
|
}
|
|
14
43
|
}
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// The Memory runtime Contribution: what it injects, what it records, and what
|
|
2
|
+
// its tools refuse.
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { SessionStore, type Session } from "@frockbot/kernel-contracts";
|
|
5
|
+
import { Context } from "cordis";
|
|
6
|
+
import type { WorkspaceFilesV1 } from "@frockbot/kernel-contracts";
|
|
7
|
+
import {
|
|
8
|
+
createMemoryForgetTool,
|
|
9
|
+
createMemorySearchTool,
|
|
10
|
+
createMemoryWriteTool,
|
|
11
|
+
createProjectTools,
|
|
12
|
+
MemoryProjection,
|
|
13
|
+
type MemoryRuntimeHostV1,
|
|
14
|
+
} from "./agent.ts";
|
|
15
|
+
import { userMemoryRootV1 } from "./roots.ts";
|
|
16
|
+
import { MemoryStore, MEMORY_MAX_FILES_PER_TIER } from "./store.ts";
|
|
17
|
+
import {
|
|
18
|
+
createInMemoryMemoryProjectsV1,
|
|
19
|
+
createTestMemoryFilesV1,
|
|
20
|
+
} from "./testing.ts";
|
|
21
|
+
|
|
22
|
+
const OWNER = { userId: "user-1", botId: "bot-1" };
|
|
23
|
+
const WRITER = { sessionId: "user-1:bot-1", turnId: "turn-4", runId: "run-9" };
|
|
24
|
+
const AT = new Date("2026-08-31T10:00:00.000Z");
|
|
25
|
+
|
|
26
|
+
const CONTEXT = {
|
|
27
|
+
botId: "bot-1",
|
|
28
|
+
agentId: "bot-1",
|
|
29
|
+
sessionId: "user-1:bot-1",
|
|
30
|
+
compositionGenerationId: "2026-08-31T00:00:00.000Z:0123456789abcdef",
|
|
31
|
+
turnType: "chat" as const,
|
|
32
|
+
effectId: "tool:1:1:0",
|
|
33
|
+
signal: new AbortController().signal,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
async function openSession(): Promise<{
|
|
37
|
+
session: Session;
|
|
38
|
+
sessions: { get(id: string): Session | undefined };
|
|
39
|
+
dispose(): Promise<void>;
|
|
40
|
+
}> {
|
|
41
|
+
const root = new Context();
|
|
42
|
+
await root.plugin(SessionStore);
|
|
43
|
+
const session = root.sessions.create("user-1:bot-1");
|
|
44
|
+
session.appendBatch([
|
|
45
|
+
{ type: "turn/start", turn: 4 },
|
|
46
|
+
{ type: "step/start", turn: 4, step: 2 },
|
|
47
|
+
]);
|
|
48
|
+
return {
|
|
49
|
+
session,
|
|
50
|
+
sessions: root.sessions,
|
|
51
|
+
dispose: () => root.fiber.dispose(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hostFor(
|
|
56
|
+
botId = "bot-1",
|
|
57
|
+
files = createTestMemoryFilesV1({ userId: "user-1" }),
|
|
58
|
+
): MemoryRuntimeHostV1 & {
|
|
59
|
+
writer: NonNullable<MemoryRuntimeHostV1["writer"]>;
|
|
60
|
+
} {
|
|
61
|
+
return {
|
|
62
|
+
owner: { userId: "user-1", botId },
|
|
63
|
+
store: new MemoryStore({
|
|
64
|
+
files,
|
|
65
|
+
owner: { userId: "user-1", botId },
|
|
66
|
+
botNames: { "bot-1": "General", "bot-2": "School" },
|
|
67
|
+
clock: () => AT,
|
|
68
|
+
}),
|
|
69
|
+
writer: WRITER,
|
|
70
|
+
// The Turn's clock, which the note fade's cutoff is derived from.
|
|
71
|
+
clock: () => AT,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The Bot provenance one shard's writes carry. */
|
|
76
|
+
function botWriter(botId: string) {
|
|
77
|
+
return {
|
|
78
|
+
kind: "bot" as const,
|
|
79
|
+
botId,
|
|
80
|
+
sessionId: `user-1:${botId}`,
|
|
81
|
+
turnId: "turn-4",
|
|
82
|
+
runId: "run-9",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A Workspace surface that serves reads normally and stops accepting writes
|
|
88
|
+
* after `allowed` of them, so a change that spans two files can be interrupted
|
|
89
|
+
* between them.
|
|
90
|
+
*/
|
|
91
|
+
function writesFailAfter(
|
|
92
|
+
files: WorkspaceFilesV1,
|
|
93
|
+
allowed: number,
|
|
94
|
+
): WorkspaceFilesV1 {
|
|
95
|
+
let seen = 0;
|
|
96
|
+
return {
|
|
97
|
+
read: (path) => files.read(path),
|
|
98
|
+
list: (request) => files.list(request),
|
|
99
|
+
stat: (path) => files.stat(path),
|
|
100
|
+
write: (request) => {
|
|
101
|
+
seen += 1;
|
|
102
|
+
if (seen > allowed) {
|
|
103
|
+
return Promise.resolve({
|
|
104
|
+
status: "unavailable" as const,
|
|
105
|
+
reason: "the bucket went away",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return files.write(request);
|
|
109
|
+
},
|
|
110
|
+
delete: (request) => files.delete(request),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe("memory_write", () => {
|
|
115
|
+
test("records intent before the effect, then the generation it produced", async () => {
|
|
116
|
+
const host = hostFor();
|
|
117
|
+
const { session, sessions, dispose } = await openSession();
|
|
118
|
+
const projection = new MemoryProjection(host);
|
|
119
|
+
const tool = createMemoryWriteTool(host, sessions, projection);
|
|
120
|
+
|
|
121
|
+
const result = await tool.execute(
|
|
122
|
+
{ scope: "user", tier: "profile", fact: "Tim lives in Wollongong." },
|
|
123
|
+
CONTEXT,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(result.isError).toBe(false);
|
|
127
|
+
const intent = session.events.find(
|
|
128
|
+
(event) => event.type === "memory/write-intent",
|
|
129
|
+
);
|
|
130
|
+
const written = session.events.find(
|
|
131
|
+
(event) => event.type === "memory/written",
|
|
132
|
+
);
|
|
133
|
+
expect(intent).toMatchObject({
|
|
134
|
+
turn: 4,
|
|
135
|
+
step: 2,
|
|
136
|
+
action: "write",
|
|
137
|
+
scope: "user",
|
|
138
|
+
tier: "profile",
|
|
139
|
+
});
|
|
140
|
+
expect(written).toMatchObject({ action: "write", scope: "user" });
|
|
141
|
+
expect(intent!.seq).toBeLessThan(written!.seq);
|
|
142
|
+
if (written?.type !== "memory/written") throw new Error("unreachable");
|
|
143
|
+
expect(written.path).toBe("by-agent/bot-1/profile.md");
|
|
144
|
+
expect(written.effectId).toBe(
|
|
145
|
+
intent?.type === "memory/write-intent" ? intent.effectId : "",
|
|
146
|
+
);
|
|
147
|
+
await dispose();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("refuses a credential-shaped fact, visibly, and writes nothing", async () => {
|
|
151
|
+
const host = hostFor();
|
|
152
|
+
const { session, sessions, dispose } = await openSession();
|
|
153
|
+
const tool = createMemoryWriteTool(
|
|
154
|
+
host,
|
|
155
|
+
sessions,
|
|
156
|
+
new MemoryProjection(host),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const result = await tool.execute(
|
|
160
|
+
{ fact: "The key is sk-abcdefghijklmnopqrstuvwxyz." },
|
|
161
|
+
CONTEXT,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(result.isError).toBe(true);
|
|
165
|
+
expect(result.content).toContain("no secrets");
|
|
166
|
+
// The intent is still recorded: the refusal is an observable outcome of an
|
|
167
|
+
// attempt, not an event that never happened.
|
|
168
|
+
expect(
|
|
169
|
+
session.events.some((event) => event.type === "memory/write-intent"),
|
|
170
|
+
).toBe(true);
|
|
171
|
+
expect(
|
|
172
|
+
session.events.some((event) => event.type === "memory/written"),
|
|
173
|
+
).toBe(false);
|
|
174
|
+
await dispose();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("memory_forget", () => {
|
|
179
|
+
test("retracts another Bot's shared fact in this Bot's own shard", async () => {
|
|
180
|
+
const files = createTestMemoryFilesV1({ userId: "user-1" });
|
|
181
|
+
const other = hostFor("bot-2", files);
|
|
182
|
+
await other.store.write({
|
|
183
|
+
root: userMemoryRootV1(OWNER),
|
|
184
|
+
tier: "profile",
|
|
185
|
+
fact: "Tim teaches on Tuesdays.",
|
|
186
|
+
writer: {
|
|
187
|
+
kind: "bot",
|
|
188
|
+
botId: "bot-2",
|
|
189
|
+
sessionId: "user-1:bot-2",
|
|
190
|
+
turnId: "t",
|
|
191
|
+
runId: "r",
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
const host = hostFor("bot-1", files);
|
|
195
|
+
const { session, sessions, dispose } = await openSession();
|
|
196
|
+
const tool = createMemoryForgetTool(
|
|
197
|
+
host,
|
|
198
|
+
sessions,
|
|
199
|
+
new MemoryProjection(host),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const result = await tool.execute(
|
|
203
|
+
{ scope: "user", fact: "Tim teaches on Tuesdays." },
|
|
204
|
+
CONTEXT,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(result.isError).toBe(false);
|
|
208
|
+
expect(result.content).toContain("retraction");
|
|
209
|
+
const written = session.events.find(
|
|
210
|
+
(event) => event.type === "memory/written",
|
|
211
|
+
);
|
|
212
|
+
if (written?.type !== "memory/written") throw new Error("unreachable");
|
|
213
|
+
expect(written.action).toBe("forget");
|
|
214
|
+
expect(written.path).toBe("by-agent/bot-1/log/2026-08.md");
|
|
215
|
+
await dispose();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("the note fade", () => {
|
|
220
|
+
test("drops a stale note from the prompt and records the fade beside the cutoff", async () => {
|
|
221
|
+
const files = createTestMemoryFilesV1({ userId: "user-1" });
|
|
222
|
+
const host = hostFor("bot-1", files);
|
|
223
|
+
const writer = botWriter("bot-1");
|
|
224
|
+
const root = userMemoryRootV1(OWNER);
|
|
225
|
+
// Two notes, one either side of the 14-day cutoff, and one durable log
|
|
226
|
+
// fact that never fades. All three stay on disk.
|
|
227
|
+
await host.store.write({
|
|
228
|
+
root,
|
|
229
|
+
tier: "note",
|
|
230
|
+
fact: "[note] the standup moved to nine",
|
|
231
|
+
writer,
|
|
232
|
+
at: new Date("2026-08-30T10:00:00.000Z"),
|
|
233
|
+
});
|
|
234
|
+
await host.store.write({
|
|
235
|
+
root,
|
|
236
|
+
tier: "note",
|
|
237
|
+
fact: "[note] the standup moved to eight",
|
|
238
|
+
writer,
|
|
239
|
+
at: new Date("2026-08-01T10:00:00.000Z"),
|
|
240
|
+
});
|
|
241
|
+
await host.store.write({
|
|
242
|
+
root,
|
|
243
|
+
tier: "log",
|
|
244
|
+
fact: "Tim teaches on Tuesdays.",
|
|
245
|
+
writer,
|
|
246
|
+
at: new Date("2026-08-01T10:00:00.000Z"),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const { session, dispose } = await openSession();
|
|
250
|
+
const injection = await new MemoryProjection(host).refresh(4, session);
|
|
251
|
+
|
|
252
|
+
expect(injection.text).toContain("[note] the standup moved to nine");
|
|
253
|
+
expect(injection.text).not.toContain("moved to eight");
|
|
254
|
+
expect(injection.text).toContain("Tim teaches on Tuesdays.");
|
|
255
|
+
|
|
256
|
+
const injected = session.events.find(
|
|
257
|
+
(event) => event.type === "memory/injected",
|
|
258
|
+
);
|
|
259
|
+
if (injected?.type !== "memory/injected") throw new Error("unreachable");
|
|
260
|
+
// The cutoff is on the log, so the request reconstructs exactly.
|
|
261
|
+
expect(injected.noteTtlDays).toBe(14);
|
|
262
|
+
expect(injected.noteCutoff).toBe("2026-08-17");
|
|
263
|
+
expect(injected.faded).toEqual([
|
|
264
|
+
{ scope: "user", projectId: "", count: 1 },
|
|
265
|
+
]);
|
|
266
|
+
// A fade is not an omission.
|
|
267
|
+
expect(injected.omissions).toEqual([]);
|
|
268
|
+
|
|
269
|
+
// NO WRITES. The faded note is still on disk, in the same generation, and
|
|
270
|
+
// is still forgettable — the fade is a read-time filter and nothing else.
|
|
271
|
+
const tier = await host.store.read(root);
|
|
272
|
+
expect(tier.recent.map((entry) => entry.text)).toContain(
|
|
273
|
+
"[note] the standup moved to eight",
|
|
274
|
+
);
|
|
275
|
+
const forgotten = await host.store.forget({
|
|
276
|
+
root,
|
|
277
|
+
fact: "the standup moved to eight",
|
|
278
|
+
writer,
|
|
279
|
+
});
|
|
280
|
+
expect(forgotten.status).toBe("ok");
|
|
281
|
+
await dispose();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("the Turn projection", () => {
|
|
286
|
+
test("records exactly what it injected, generations included", async () => {
|
|
287
|
+
const files = createTestMemoryFilesV1({ userId: "user-1" });
|
|
288
|
+
const other = hostFor("bot-2", files);
|
|
289
|
+
const shared = await other.store.write({
|
|
290
|
+
root: userMemoryRootV1(OWNER),
|
|
291
|
+
tier: "profile",
|
|
292
|
+
fact: "Tim teaches on Tuesdays.",
|
|
293
|
+
writer: {
|
|
294
|
+
kind: "bot",
|
|
295
|
+
botId: "bot-2",
|
|
296
|
+
sessionId: "user-1:bot-2",
|
|
297
|
+
turnId: "t",
|
|
298
|
+
runId: "r",
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
const host = hostFor("bot-1", files);
|
|
302
|
+
const { session, dispose } = await openSession();
|
|
303
|
+
const projection = new MemoryProjection(host);
|
|
304
|
+
|
|
305
|
+
const injection = await projection.refresh(4, session);
|
|
306
|
+
|
|
307
|
+
expect(injection.text).toContain(
|
|
308
|
+
"- (learned 2026-08-31) [via School] Tim teaches on Tuesdays.",
|
|
309
|
+
);
|
|
310
|
+
const injected = session.events.find(
|
|
311
|
+
(event) => event.type === "memory/injected",
|
|
312
|
+
);
|
|
313
|
+
if (injected?.type !== "memory/injected") throw new Error("unreachable");
|
|
314
|
+
expect(injected.turn).toBe(4);
|
|
315
|
+
expect(injected.facts).toEqual([
|
|
316
|
+
{
|
|
317
|
+
scope: "user",
|
|
318
|
+
projectId: "",
|
|
319
|
+
tier: "profile",
|
|
320
|
+
via: "School",
|
|
321
|
+
learnedAt: "2026-08-31",
|
|
322
|
+
text: "Tim teaches on Tuesdays.",
|
|
323
|
+
},
|
|
324
|
+
]);
|
|
325
|
+
expect(injected.sources).toEqual([
|
|
326
|
+
{
|
|
327
|
+
scope: "user",
|
|
328
|
+
projectId: "",
|
|
329
|
+
path: "by-agent/bot-2/profile.md",
|
|
330
|
+
generationId:
|
|
331
|
+
shared.status === "ok" ? shared.generationId : "unreachable",
|
|
332
|
+
contentHash:
|
|
333
|
+
shared.status === "ok" ? shared.contentHash : "unreachable",
|
|
334
|
+
},
|
|
335
|
+
]);
|
|
336
|
+
expect(projection.loadedTurn()).toBe(4);
|
|
337
|
+
await dispose();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("the Project tools", () => {
|
|
342
|
+
test("create is join, and membership reaches durable state through the authority", async () => {
|
|
343
|
+
const host = { ...hostFor(), projects: createInMemoryMemoryProjectsV1() };
|
|
344
|
+
const { session, sessions, dispose } = await openSession();
|
|
345
|
+
const projection = new MemoryProjection(host);
|
|
346
|
+
const [create, join, leave] = createProjectTools(
|
|
347
|
+
host,
|
|
348
|
+
sessions,
|
|
349
|
+
projection,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const created = await create!.execute(
|
|
353
|
+
{
|
|
354
|
+
project: "ghetto-movement",
|
|
355
|
+
name: "Ghetto Movement",
|
|
356
|
+
description: "The gym build.",
|
|
357
|
+
},
|
|
358
|
+
CONTEXT,
|
|
359
|
+
);
|
|
360
|
+
expect(created.isError).toBe(false);
|
|
361
|
+
expect(await host.projects.joined()).toEqual([
|
|
362
|
+
{
|
|
363
|
+
projectId: "ghetto-movement",
|
|
364
|
+
name: "Ghetto Movement",
|
|
365
|
+
description: "The gym build.",
|
|
366
|
+
},
|
|
367
|
+
]);
|
|
368
|
+
|
|
369
|
+
// The descriptor is a Memory file, written with the User's authority.
|
|
370
|
+
const descriptor = await host.store.reads.read({
|
|
371
|
+
root: {
|
|
372
|
+
kind: "project-memory",
|
|
373
|
+
userId: "user-1",
|
|
374
|
+
projectId: "ghetto-movement",
|
|
375
|
+
},
|
|
376
|
+
path: "projects/ghetto-movement/project.md",
|
|
377
|
+
});
|
|
378
|
+
expect(descriptor.status).toBe("ok");
|
|
379
|
+
expect(
|
|
380
|
+
descriptor.status === "ok"
|
|
381
|
+
? descriptor.file.generation.writer
|
|
382
|
+
: undefined,
|
|
383
|
+
).toEqual({ kind: "user", userId: "user-1" });
|
|
384
|
+
|
|
385
|
+
const intent = session.events.find(
|
|
386
|
+
(event) => event.type === "memory/project-intent",
|
|
387
|
+
);
|
|
388
|
+
const changed = session.events.find(
|
|
389
|
+
(event) => event.type === "memory/project-changed",
|
|
390
|
+
);
|
|
391
|
+
expect(intent).toMatchObject({
|
|
392
|
+
action: "create",
|
|
393
|
+
projectId: "ghetto-movement",
|
|
394
|
+
});
|
|
395
|
+
expect(changed).toMatchObject({ projects: ["ghetto-movement"] });
|
|
396
|
+
expect(intent!.seq).toBeLessThan(changed!.seq);
|
|
397
|
+
|
|
398
|
+
await leave!.execute({ project: "ghetto-movement" }, CONTEXT);
|
|
399
|
+
expect(await host.projects.joined()).toEqual([]);
|
|
400
|
+
const rejoined = await join!.execute(
|
|
401
|
+
{ project: "ghetto-movement" },
|
|
402
|
+
CONTEXT,
|
|
403
|
+
);
|
|
404
|
+
expect(rejoined.isError).toBe(false);
|
|
405
|
+
expect((await host.projects.joined()).map((p) => p.projectId)).toEqual([
|
|
406
|
+
"ghetto-movement",
|
|
407
|
+
]);
|
|
408
|
+
await dispose();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe("a Memory read that a bound cut short", () => {
|
|
413
|
+
test("records an omission naming the tier rather than a complete-looking injection", async () => {
|
|
414
|
+
const files = createTestMemoryFilesV1({ userId: "user-1" });
|
|
415
|
+
// One profile shard per Bot, one more than the tier read bound.
|
|
416
|
+
const shards = MEMORY_MAX_FILES_PER_TIER + 1;
|
|
417
|
+
for (let index = 0; index < shards; index += 1) {
|
|
418
|
+
const botId = `bot-${String(index).padStart(3, "0")}`;
|
|
419
|
+
const store = new MemoryStore({
|
|
420
|
+
files,
|
|
421
|
+
owner: { userId: "user-1", botId },
|
|
422
|
+
clock: () => AT,
|
|
423
|
+
});
|
|
424
|
+
const written = await store.write({
|
|
425
|
+
root: userMemoryRootV1(OWNER),
|
|
426
|
+
tier: "profile",
|
|
427
|
+
fact: `Shard ${index} learned something.`,
|
|
428
|
+
writer: botWriter(botId),
|
|
429
|
+
});
|
|
430
|
+
expect(written.status).toBe("ok");
|
|
431
|
+
}
|
|
432
|
+
const host = hostFor("bot-000", files);
|
|
433
|
+
const { session, dispose } = await openSession();
|
|
434
|
+
|
|
435
|
+
await new MemoryProjection(host).refresh(4, session);
|
|
436
|
+
|
|
437
|
+
const injected = session.events.find(
|
|
438
|
+
(event) => event.type === "memory/injected",
|
|
439
|
+
);
|
|
440
|
+
if (injected?.type !== "memory/injected") throw new Error("unreachable");
|
|
441
|
+
const omission = injected.omissions.find(
|
|
442
|
+
(entry) =>
|
|
443
|
+
entry.scope === "user" && entry.reason.includes("read bound were not"),
|
|
444
|
+
);
|
|
445
|
+
expect(omission).toBeDefined();
|
|
446
|
+
expect(omission?.reason).toContain(`1 Memory file(s)`);
|
|
447
|
+
await dispose();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
describe("a project-scope change to a Project the Bot never joined", () => {
|
|
452
|
+
test("is refused, and nothing is recorded or written", async () => {
|
|
453
|
+
const host = { ...hostFor(), projects: createInMemoryMemoryProjectsV1() };
|
|
454
|
+
const { session, sessions, dispose } = await openSession();
|
|
455
|
+
const projection = new MemoryProjection(host);
|
|
456
|
+
const write = createMemoryWriteTool(host, sessions, projection);
|
|
457
|
+
const forget = createMemoryForgetTool(host, sessions, projection);
|
|
458
|
+
|
|
459
|
+
const written = await write.execute(
|
|
460
|
+
{ scope: "project", project: "never-joined", fact: "A shared fact." },
|
|
461
|
+
CONTEXT,
|
|
462
|
+
);
|
|
463
|
+
const forgotten = await forget.execute(
|
|
464
|
+
{ scope: "project", project: "never-joined", fact: "A shared fact." },
|
|
465
|
+
CONTEXT,
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
expect(written.isError).toBe(true);
|
|
469
|
+
expect(written.content).toContain("you have not joined");
|
|
470
|
+
expect(forgotten.isError).toBe(true);
|
|
471
|
+
expect(forgotten.content).toContain("you have not joined");
|
|
472
|
+
expect(
|
|
473
|
+
session.events.some((event) => event.type === "memory/written"),
|
|
474
|
+
).toBe(false);
|
|
475
|
+
expect(
|
|
476
|
+
session.events.some((event) => event.type === "memory/write-intent"),
|
|
477
|
+
).toBe(false);
|
|
478
|
+
await dispose();
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe("a memory_forget that changes one file and then fails", () => {
|
|
483
|
+
test("records the generation it did write, so the log matches the files", async () => {
|
|
484
|
+
const files = createTestMemoryFilesV1({ userId: "user-1" });
|
|
485
|
+
const seed = new MemoryStore({ files, owner: OWNER, clock: () => AT });
|
|
486
|
+
const fact = "Tim teaches on Tuesdays.";
|
|
487
|
+
for (const tier of ["profile", "log"] as const) {
|
|
488
|
+
const written = await seed.write({
|
|
489
|
+
root: userMemoryRootV1(OWNER),
|
|
490
|
+
tier,
|
|
491
|
+
fact,
|
|
492
|
+
writer: botWriter("bot-1"),
|
|
493
|
+
});
|
|
494
|
+
expect(written.status).toBe("ok");
|
|
495
|
+
}
|
|
496
|
+
// The forget rewrites log/2026-08.md then profile.md; only the first lands.
|
|
497
|
+
const host = hostFor("bot-1", writesFailAfter(files, 1));
|
|
498
|
+
const { session, sessions, dispose } = await openSession();
|
|
499
|
+
const tool = createMemoryForgetTool(
|
|
500
|
+
host,
|
|
501
|
+
sessions,
|
|
502
|
+
new MemoryProjection(host),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const result = await tool.execute({ scope: "user", fact }, CONTEXT);
|
|
506
|
+
|
|
507
|
+
expect(result.isError).toBe(true);
|
|
508
|
+
expect(result.content).toContain("after changing 1 file(s)");
|
|
509
|
+
const written = session.events.filter(
|
|
510
|
+
(event) => event.type === "memory/written",
|
|
511
|
+
);
|
|
512
|
+
expect(written).toHaveLength(1);
|
|
513
|
+
expect(written[0]).toMatchObject({
|
|
514
|
+
action: "forget",
|
|
515
|
+
path: "by-agent/bot-1/log/2026-08.md",
|
|
516
|
+
});
|
|
517
|
+
// The event log names the file that really changed on disk.
|
|
518
|
+
const remaining = await host.store.read(userMemoryRootV1(OWNER));
|
|
519
|
+
expect(remaining.recent.map((entry) => entry.text)).toEqual([]);
|
|
520
|
+
expect(remaining.profile.map((entry) => entry.text)).toEqual([fact]);
|
|
521
|
+
await dispose();
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
describe("project_create when the descriptor write conflicts", () => {
|
|
526
|
+
test("is a visible refusal, and no membership change is recorded", async () => {
|
|
527
|
+
const files = createTestMemoryFilesV1({ userId: "user-1" });
|
|
528
|
+
const conflicting: WorkspaceFilesV1 = {
|
|
529
|
+
read: (path) => files.read(path),
|
|
530
|
+
list: (request) => files.list(request),
|
|
531
|
+
stat: (path) => files.stat(path),
|
|
532
|
+
write: () =>
|
|
533
|
+
Promise.resolve({
|
|
534
|
+
status: "conflict" as const,
|
|
535
|
+
reason: "another writer holds a newer generation",
|
|
536
|
+
}),
|
|
537
|
+
delete: (request) => files.delete(request),
|
|
538
|
+
};
|
|
539
|
+
const host = {
|
|
540
|
+
...hostFor("bot-1", conflicting),
|
|
541
|
+
projects: createInMemoryMemoryProjectsV1(),
|
|
542
|
+
};
|
|
543
|
+
const { session, sessions, dispose } = await openSession();
|
|
544
|
+
const [create] = createProjectTools(
|
|
545
|
+
host,
|
|
546
|
+
sessions,
|
|
547
|
+
new MemoryProjection(host),
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
const created = await create!.execute(
|
|
551
|
+
{ project: "ghetto-movement", name: "Ghetto Movement" },
|
|
552
|
+
CONTEXT,
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
expect(created.isError).toBe(true);
|
|
556
|
+
expect(created.content).toContain("conflict");
|
|
557
|
+
expect(
|
|
558
|
+
session.events.some((event) => event.type === "memory/project-changed"),
|
|
559
|
+
).toBe(false);
|
|
560
|
+
expect(await host.projects.joined()).toEqual([]);
|
|
561
|
+
await dispose();
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe("memory_search", () => {
|
|
566
|
+
test("decodes its input at the seam, refusing an unknown field", async () => {
|
|
567
|
+
const host = hostFor();
|
|
568
|
+
const projection = new MemoryProjection(host);
|
|
569
|
+
const tool = createMemorySearchTool(host, projection);
|
|
570
|
+
|
|
571
|
+
expect(tool.validate?.({ query: "tuesdays", limit: 3 })).toBe(false);
|
|
572
|
+
const unknown = await tool.execute(
|
|
573
|
+
{ query: "tuesdays", limit: 3 },
|
|
574
|
+
CONTEXT,
|
|
575
|
+
);
|
|
576
|
+
expect(unknown.isError).toBe(true);
|
|
577
|
+
expect(unknown.content).toContain("unknown fields");
|
|
578
|
+
|
|
579
|
+
expect(tool.validate?.({ query: "tuesdays", maxResults: 99 })).toBe(false);
|
|
580
|
+
const range = await tool.execute(
|
|
581
|
+
{ query: "tuesdays", maxResults: 99 },
|
|
582
|
+
CONTEXT,
|
|
583
|
+
);
|
|
584
|
+
expect(range.isError).toBe(true);
|
|
585
|
+
expect(range.content).toContain("maxResults");
|
|
586
|
+
|
|
587
|
+
const ok = await tool.execute({ query: "tuesdays" }, CONTEXT);
|
|
588
|
+
expect(ok.isError).toBe(false);
|
|
589
|
+
});
|
|
590
|
+
});
|