@frockbot/plugin-memory 0.0.0 → 0.1.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.
@@ -0,0 +1,91 @@
1
+ // "Indexes, embeddings, and summaries are derived from Memory files and are
2
+ // always rebuildable from them." The check that makes that operational: an
3
+ // index built incrementally over a sequence of writes equals one rebuilt from
4
+ // the files in one pass.
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { WorkspaceWriterV1 } from "@frockbot/kernel-contracts";
7
+ import { listAllMemoryDocumentsV1 } from "./documents.ts";
8
+ import {
9
+ buildMemoryIndexV1,
10
+ emptyMemoryIndexV1,
11
+ updateMemoryIndexV1,
12
+ } from "./indexer.ts";
13
+ import { botMemoryRootV1, userMemoryRootV1 } from "./roots.ts";
14
+ import { MemoryStore } from "./store.ts";
15
+ import { createTestMemoryFilesV1 } from "./testing.ts";
16
+
17
+ const OWNER = { userId: "user-1", botId: "bot-1" };
18
+ const WRITER: WorkspaceWriterV1 = {
19
+ kind: "bot",
20
+ botId: "bot-1",
21
+ sessionId: "user-1:bot-1",
22
+ turnId: "turn-1",
23
+ runId: "run-1",
24
+ };
25
+
26
+ describe("the derived Memory index", () => {
27
+ test("a rebuilt index equals an incrementally updated one", async () => {
28
+ const at = new Date("2026-08-31T10:00:00.000Z");
29
+ const files = createTestMemoryFilesV1({ userId: "user-1" });
30
+ const store = new MemoryStore({ files, owner: OWNER, clock: () => at });
31
+ const roots = [botMemoryRootV1(OWNER), userMemoryRootV1(OWNER)];
32
+
33
+ let incremental = emptyMemoryIndexV1();
34
+ const facts: Array<{ root: (typeof roots)[number]; text: string }> = [
35
+ {
36
+ root: roots[0]!,
37
+ text: "Tim lives in Wollongong and likes rubber floors.",
38
+ },
39
+ { root: roots[0]!, text: "Term ends on the twelfth of December." },
40
+ { root: roots[1]!, text: "Shared: the gym build starts in spring." },
41
+ { root: roots[0]!, text: "The shoot is on Friday at the beach." },
42
+ ];
43
+ for (const entry of facts) {
44
+ await store.write({
45
+ root: entry.root,
46
+ tier: "log",
47
+ fact: entry.text,
48
+ writer: WRITER,
49
+ });
50
+ const documents = await listAllMemoryDocumentsV1(files, roots);
51
+ incremental = (await updateMemoryIndexV1(incremental, documents)).index;
52
+ }
53
+
54
+ // A forget changes a document too, and the incremental path must follow.
55
+ await store.forget({
56
+ root: roots[0]!,
57
+ fact: "Term ends on the twelfth of December.",
58
+ writer: WRITER,
59
+ });
60
+ const documents = await listAllMemoryDocumentsV1(files, roots);
61
+ const update = await updateMemoryIndexV1(incremental, documents);
62
+ incremental = update.index;
63
+
64
+ const rebuilt = await buildMemoryIndexV1(documents);
65
+
66
+ expect(incremental.chunks.length).toBeGreaterThan(0);
67
+ expect(incremental).toEqual(rebuilt);
68
+ expect(update.documentsChanged).toBe(1);
69
+ });
70
+
71
+ test("drops the chunks of a document that is gone", async () => {
72
+ const at = new Date("2026-08-31T10:00:00.000Z");
73
+ const files = createTestMemoryFilesV1({ userId: "user-1" });
74
+ const store = new MemoryStore({ files, owner: OWNER, clock: () => at });
75
+ const root = botMemoryRootV1(OWNER);
76
+ await store.write({
77
+ root,
78
+ tier: "profile",
79
+ fact: "A fact worth chunking.",
80
+ writer: WRITER,
81
+ });
82
+ const documents = await listAllMemoryDocumentsV1(files, [root]);
83
+ const full = await buildMemoryIndexV1(documents);
84
+ expect(full.chunks).toHaveLength(1);
85
+
86
+ const emptied = await updateMemoryIndexV1(full, []);
87
+ expect(emptied.index.chunks).toEqual([]);
88
+ expect(emptied.documentsRemoved).toBe(1);
89
+ expect(emptied.index).toEqual(await buildMemoryIndexV1([]));
90
+ });
91
+ });
package/src/indexer.ts ADDED
Binary file
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
@@ -0,0 +1,85 @@
1
+ // Project membership: the seam, not the authority.
2
+ //
3
+ // "A Project is an opt-in grouping a Bot creates or joins that carries its own
4
+ // shared Memory tier; only the Projects a Bot has joined are injected into its
5
+ // prompts." Membership is durable *User-scoped* state — "The User's Durable
6
+ // Object is the authority for everything User-scoped" — so this Package
7
+ // declares the interface and the Cloudflare app implements it over the User
8
+ // Durable Object. Nothing here stores anything.
9
+ //
10
+ // What *is* a Memory file is the Project descriptor: `projects/<slug>/
11
+ // project.md`, GrokBot's own path, with `name` and `description` in a
12
+ // frontmatter fence. It sits in the Project's own Memory root, outside any
13
+ // `by-agent/` shard, so `writerOwnsMemoryPathV1` allows only the User to write
14
+ // it — which is right: creating a Project is a User-scoped act, and the Bot
15
+ // performing it does so with its User's authority, recorded as such.
16
+ import type { MemoryProjectV1 } from "./render.js";
17
+
18
+ export type { MemoryProjectV1 };
19
+
20
+ /** The membership change the authority applied, or its refusal. */
21
+ export type MemoryProjectsOutcomeV1 =
22
+ | { status: "ok"; joined: MemoryProjectV1[] }
23
+ | { status: "refused"; reason: string };
24
+
25
+ /**
26
+ * The durable Project authority, as this Package consumes it. Implemented by
27
+ * the User Durable Object; `create` is join-if-it-exists, exactly as GrokBot's
28
+ * `update_state project create` is.
29
+ */
30
+ export interface MemoryProjectsV1 {
31
+ joined(): Promise<MemoryProjectV1[]>;
32
+ create(project: MemoryProjectV1): Promise<MemoryProjectsOutcomeV1>;
33
+ join(projectId: string): Promise<MemoryProjectsOutcomeV1>;
34
+ leave(projectId: string): Promise<MemoryProjectsOutcomeV1>;
35
+ }
36
+
37
+ /** The descriptor path inside a Project's Memory root. GrokBot's own layout. */
38
+ export function projectDocumentPathV1(projectId: string): string {
39
+ return `projects/${projectId}/project.md`;
40
+ }
41
+
42
+ const FENCE = "---";
43
+
44
+ /** Renders `project.md`: a frontmatter fence, then the description as prose. */
45
+ export function renderProjectDocumentV1(project: MemoryProjectV1): string {
46
+ return [
47
+ FENCE,
48
+ `name: ${project.name.replace(/[\r\n]+/g, " ").trim()}`,
49
+ `description: ${project.description.replace(/[\r\n]+/g, " ").trim()}`,
50
+ FENCE,
51
+ "",
52
+ project.description.trim(),
53
+ "",
54
+ ].join("\n");
55
+ }
56
+
57
+ /**
58
+ * Reads `project.md` back. Deliberately minimal, like the Skill frontmatter
59
+ * reader: flat `key: value` lines and nothing else, refused rather than
60
+ * partially parsed, because this text reaches a system prompt.
61
+ */
62
+ export function parseProjectDocumentV1(
63
+ projectId: string,
64
+ text: string,
65
+ ): MemoryProjectV1 | undefined {
66
+ const lines = text.split("\n");
67
+ if ((lines[0] ?? "").trim() !== FENCE) return undefined;
68
+ const fields: Record<string, string> = {};
69
+ for (let index = 1; index < lines.length && index < 32; index += 1) {
70
+ const line = (lines[index] ?? "").trim();
71
+ if (line === FENCE) {
72
+ const name = fields.name?.trim();
73
+ if (!name) return undefined;
74
+ return {
75
+ projectId,
76
+ name: name.slice(0, 128),
77
+ description: (fields.description ?? "").trim().slice(0, 512),
78
+ };
79
+ }
80
+ const match = /^([a-z][a-z0-9_-]{0,31}):\s*(.*)$/.exec(line);
81
+ if (!match) return undefined;
82
+ fields[match[1] ?? ""] = match[2] ?? "";
83
+ }
84
+ return undefined;
85
+ }
@@ -0,0 +1,438 @@
1
+ // The injected Memory block: GrokBot's shape, order, labels, and caps.
2
+ import { describe, expect, test } from "bun:test";
3
+ import type { SourcedMemoryFactV1 } from "./facts.ts";
4
+ import {
5
+ MEMORY_NOTE_TTL_DAYS,
6
+ MEMORY_PROJECT_INJECTED_CAP,
7
+ renderMemoryInjectionV1,
8
+ type MemoryProjectV1,
9
+ } from "./render.ts";
10
+ import {
11
+ botMemoryRootV1,
12
+ projectMemoryRootV1,
13
+ userMemoryRootV1,
14
+ } from "./roots.ts";
15
+ import type { MemoryTierReadV1 } from "./store.ts";
16
+
17
+ const OWNER = { userId: "user-1", botId: "bot-1" };
18
+ // Every fact in these fixtures is dated 2026-08-30 and unmarked, so the fade
19
+ // is inert here; the fade's own cases pick their cutoffs deliberately.
20
+ const CUTOFF = "2026-08-17";
21
+
22
+ function fact(
23
+ text: string,
24
+ overrides: Partial<SourcedMemoryFactV1> = {},
25
+ ): SourcedMemoryFactV1 {
26
+ return {
27
+ date: "2026-08-30",
28
+ text,
29
+ botId: "bot-2",
30
+ via: "School",
31
+ kind: "profile",
32
+ generationId: "000000000000001-000001",
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function tier(
38
+ root: MemoryTierReadV1["root"],
39
+ profile: SourcedMemoryFactV1[] = [],
40
+ recent: SourcedMemoryFactV1[] = [],
41
+ ): MemoryTierReadV1 {
42
+ return {
43
+ root,
44
+ profile,
45
+ recent,
46
+ sources: [],
47
+ logTotal: recent.length,
48
+ };
49
+ }
50
+
51
+ const PROJECT: MemoryProjectV1 = {
52
+ projectId: "ghetto-movement",
53
+ name: "Ghetto Movement",
54
+ description: "The gym build.",
55
+ };
56
+
57
+ describe("the injected Memory block", () => {
58
+ test("renders user, then project, then own, as labelled paragraphs", () => {
59
+ const injection = renderMemoryInjectionV1({
60
+ botId: "bot-1",
61
+ noteCutoff: CUTOFF,
62
+ user: tier(userMemoryRootV1(OWNER), [fact("Tim lives in Wollongong.")]),
63
+ projects: [
64
+ {
65
+ project: PROJECT,
66
+ tier: tier(projectMemoryRootV1(OWNER, PROJECT.projectId), [
67
+ fact("The floor is rubber.", { via: "General", botId: "bot-1" }),
68
+ ]),
69
+ },
70
+ ],
71
+ joined: [PROJECT],
72
+ own: tier(
73
+ botMemoryRootV1(OWNER),
74
+ [fact("Tim prefers blunt answers.", { via: "", botId: "bot-1" })],
75
+ [
76
+ fact("Term ends on the 12th.", {
77
+ kind: "log",
78
+ via: "",
79
+ botId: "bot-1",
80
+ date: "2026-08-31",
81
+ }),
82
+ ],
83
+ ),
84
+ });
85
+
86
+ const blocks = injection.text.split("\n\n");
87
+ expect(blocks[0]?.startsWith("User memory:")).toBe(true);
88
+ expect(
89
+ blocks[1]?.startsWith('Project "Ghetto Movement" (ghetto-movement)'),
90
+ ).toBe(true);
91
+ expect(blocks[2]?.startsWith("Memory:")).toBe(true);
92
+ // Labelled paragraphs, not headings.
93
+ expect(injection.text).not.toContain("## ");
94
+ expect(injection.text).toContain("About the user (shared):");
95
+ expect(injection.text).toContain("About this project (shared):");
96
+ expect(injection.text).toContain("About the user:");
97
+ expect(injection.text).toContain("Recently:");
98
+ expect(injection.text).toContain("your shard: by-agent/bot-1/");
99
+ });
100
+
101
+ test("tags a shared fact with the Bot that learned it and omits [via] on own facts", () => {
102
+ const injection = renderMemoryInjectionV1({
103
+ botId: "bot-1",
104
+ noteCutoff: CUTOFF,
105
+ user: tier(userMemoryRootV1(OWNER), [fact("Tim lives in Wollongong.")]),
106
+ projects: [],
107
+ joined: [],
108
+ own: tier(botMemoryRootV1(OWNER), [
109
+ fact("Tim prefers blunt answers.", { via: "", botId: "bot-1" }),
110
+ ]),
111
+ });
112
+
113
+ expect(injection.text).toContain(
114
+ "- (learned 2026-08-30) [via School] Tim lives in Wollongong.",
115
+ );
116
+ expect(injection.text).toContain(
117
+ "- (learned 2026-08-30) Tim prefers blunt answers.",
118
+ );
119
+ });
120
+
121
+ test("own memory wins over project, and project over user, on the same fact", () => {
122
+ const shared = "Tim lives in Wollongong.";
123
+ const injection = renderMemoryInjectionV1({
124
+ botId: "bot-1",
125
+ noteCutoff: CUTOFF,
126
+ user: tier(userMemoryRootV1(OWNER), [fact(shared)]),
127
+ projects: [
128
+ {
129
+ project: PROJECT,
130
+ tier: tier(projectMemoryRootV1(OWNER, PROJECT.projectId), [
131
+ fact(shared),
132
+ ]),
133
+ },
134
+ ],
135
+ joined: [PROJECT],
136
+ own: tier(botMemoryRootV1(OWNER), [
137
+ fact(shared, { via: "", botId: "bot-1" }),
138
+ ]),
139
+ });
140
+
141
+ // Exactly once, in the own block, with no `[via …]`.
142
+ expect(injection.text.split(shared)).toHaveLength(2);
143
+ expect(injection.facts.filter((entry) => entry.text === shared)).toEqual([
144
+ {
145
+ scope: "bot",
146
+ projectId: "",
147
+ tier: "profile",
148
+ via: "",
149
+ learnedAt: "2026-08-30",
150
+ text: shared,
151
+ },
152
+ ]);
153
+ expect(injection.text).toContain("No shared facts recorded yet.");
154
+ expect(injection.text).toContain(
155
+ "No shared facts recorded yet for this project.",
156
+ );
157
+ });
158
+
159
+ test("applies GrokBot's caps: 3 projects, 50/15 user, 25/10 project, 30 own recent", () => {
160
+ const many = (count: number, prefix: string) =>
161
+ Array.from({ length: count }, (_, index) =>
162
+ fact(`${prefix} ${index}`, {
163
+ kind: "log",
164
+ date: "2026-08-30",
165
+ generationId: `000000000000001-${String(index).padStart(6, "0")}`,
166
+ }),
167
+ );
168
+ const projects = Array.from({ length: 5 }, (_, index) => ({
169
+ project: {
170
+ projectId: `project-${index}`,
171
+ name: `Project ${index}`,
172
+ description: "",
173
+ },
174
+ tier: tier(
175
+ projectMemoryRootV1(OWNER, `project-${index}`),
176
+ [],
177
+ many(20, `p${index}`),
178
+ ),
179
+ }));
180
+
181
+ const injection = renderMemoryInjectionV1({
182
+ botId: "bot-1",
183
+ noteCutoff: CUTOFF,
184
+ user: tier(
185
+ userMemoryRootV1(OWNER),
186
+ many(80, "u").map((entry) => ({
187
+ ...entry,
188
+ kind: "profile" as const,
189
+ })),
190
+ many(40, "ur"),
191
+ ),
192
+ projects,
193
+ joined: projects.map((entry) => entry.project),
194
+ own: tier(botMemoryRootV1(OWNER), [], many(60, "o")),
195
+ });
196
+
197
+ const count = (scope: string, kind: string, projectId?: string) =>
198
+ injection.facts.filter(
199
+ (entry) =>
200
+ entry.scope === scope &&
201
+ entry.tier === kind &&
202
+ (projectId === undefined || entry.projectId === projectId),
203
+ ).length;
204
+
205
+ expect(count("user", "profile")).toBe(50);
206
+ expect(count("user", "log")).toBe(15);
207
+ expect(count("bot", "log")).toBe(30);
208
+ expect(count("project", "log", "project-0")).toBe(10);
209
+ expect(
210
+ new Set(
211
+ injection.facts
212
+ .filter((entry) => entry.scope === "project")
213
+ .map((entry) => entry.projectId),
214
+ ).size,
215
+ ).toBe(MEMORY_PROJECT_INJECTED_CAP);
216
+ // The cut is visible in durable state, not silent.
217
+ expect(
218
+ injection.omissions.some((omission) =>
219
+ omission.reason.includes("at most 3 joined Projects"),
220
+ ),
221
+ ).toBe(true);
222
+ expect(injection.text).toContain("more log facts on disk");
223
+ });
224
+
225
+ test("clamps a single fact at 500 characters", () => {
226
+ const long = "x".repeat(900);
227
+ const injection = renderMemoryInjectionV1({
228
+ botId: "bot-1",
229
+ noteCutoff: CUTOFF,
230
+ user: tier(userMemoryRootV1(OWNER)),
231
+ projects: [],
232
+ joined: [],
233
+ own: tier(botMemoryRootV1(OWNER), [
234
+ fact(long, { via: "", botId: "bot-1" }),
235
+ ]),
236
+ });
237
+ const line = injection.text
238
+ .split("\n")
239
+ .find((candidate) => candidate.includes("xxx"));
240
+ expect(line).toBeDefined();
241
+ expect(line?.endsWith("…")).toBe(true);
242
+ expect(line?.length).toBeLessThanOrEqual(
243
+ "- (learned 2026-08-30) ".length + 500,
244
+ );
245
+ });
246
+
247
+ test("records a tier it could not read as an omission rather than staying silent", () => {
248
+ const unreadable = {
249
+ ...tier(userMemoryRootV1(OWNER)),
250
+ unavailable: "the bucket is unreachable",
251
+ };
252
+ const injection = renderMemoryInjectionV1({
253
+ botId: "bot-1",
254
+ noteCutoff: CUTOFF,
255
+ user: unreadable,
256
+ projects: [],
257
+ joined: [],
258
+ own: tier(botMemoryRootV1(OWNER)),
259
+ });
260
+ expect(injection.omissions).toContainEqual({
261
+ scope: "user",
262
+ reason: "the bucket is unreachable",
263
+ });
264
+ expect(injection.text).toContain("No facts recorded yet.");
265
+ });
266
+ });
267
+
268
+ describe("the note fade", () => {
269
+ // The cutoff is the oldest day a marked fact is still injected on, so a
270
+ // note dated exactly on it is live and one dated the day before is faded.
271
+ const CUT = "2026-08-18";
272
+ const own = (
273
+ text: string,
274
+ date: string,
275
+ kind: "profile" | "log" = "log",
276
+ ): SourcedMemoryFactV1 =>
277
+ fact(text, { text, date, kind, via: "", botId: "bot-1" });
278
+
279
+ test("14 days, and the boundary is exact in both directions", () => {
280
+ expect(MEMORY_NOTE_TTL_DAYS).toBe(14);
281
+ const injection = renderMemoryInjectionV1({
282
+ botId: "bot-1",
283
+ noteCutoff: CUT,
284
+ user: tier(userMemoryRootV1(OWNER)),
285
+ projects: [],
286
+ joined: [],
287
+ own: tier(
288
+ botMemoryRootV1(OWNER),
289
+ [],
290
+ [
291
+ own("[note] on the cutoff", CUT),
292
+ own("[note] the day before", "2026-08-17"),
293
+ own("an old log fact", "2026-01-01"),
294
+ ],
295
+ ),
296
+ });
297
+
298
+ expect(injection.text).toContain("[note] on the cutoff");
299
+ expect(injection.text).not.toContain("the day before");
300
+ // An unmarked log fact never fades, however old.
301
+ expect(injection.text).toContain("an old log fact");
302
+ expect(injection.faded).toEqual([
303
+ { scope: "bot", projectId: "", count: 1 },
304
+ ]);
305
+ // A fade is the feature working, not a gap to repair.
306
+ expect(injection.omissions).toEqual([]);
307
+ });
308
+
309
+ test("`[episode]` fades on the same rule as `[note]`", () => {
310
+ const injection = renderMemoryInjectionV1({
311
+ botId: "bot-1",
312
+ noteCutoff: CUT,
313
+ user: tier(userMemoryRootV1(OWNER)),
314
+ projects: [],
315
+ joined: [],
316
+ own: tier(
317
+ botMemoryRootV1(OWNER),
318
+ [own("[episode] last spring", "2026-08-17", "profile")],
319
+ [],
320
+ ),
321
+ });
322
+ expect(injection.text).not.toContain("last spring");
323
+ expect(injection.faded).toEqual([
324
+ { scope: "bot", projectId: "", count: 1 },
325
+ ]);
326
+ });
327
+
328
+ test("a faded note does not consume a cap slot a live fact could use", () => {
329
+ const stale = Array.from({ length: 20 }, (_, index) =>
330
+ own(`[note] stale ${index}`, "2026-08-17"),
331
+ );
332
+ const live = Array.from({ length: 30 }, (_, index) =>
333
+ own(`live ${index}`, "2026-08-30"),
334
+ );
335
+ const injection = renderMemoryInjectionV1({
336
+ botId: "bot-1",
337
+ noteCutoff: CUT,
338
+ user: tier(userMemoryRootV1(OWNER)),
339
+ projects: [],
340
+ joined: [],
341
+ // The stale notes come first, so a fade applied *after* the cap would
342
+ // have eaten every one of the 30 own-recent slots.
343
+ own: tier(botMemoryRootV1(OWNER), [], [...stale, ...live]),
344
+ });
345
+
346
+ expect(
347
+ injection.facts.filter((entry) => entry.tier === "log"),
348
+ ).toHaveLength(30);
349
+ expect(
350
+ injection.facts.every((entry) => entry.text.startsWith("live ")),
351
+ ).toBe(true);
352
+ // The faded notes are still on disk, so the pointer still counts them…
353
+ expect(injection.text).toContain(
354
+ "(20 more log facts on disk — grep the log/ folder for them.)",
355
+ );
356
+ // …and they are not reported as a cap omission, because no cap bit.
357
+ expect(injection.omissions).toEqual([]);
358
+ expect(injection.faded).toEqual([
359
+ { scope: "bot", projectId: "", count: 20 },
360
+ ]);
361
+ });
362
+
363
+ test("counts fades per scope and per project", () => {
364
+ const stale = (text: string) =>
365
+ fact(text, { text, date: "2026-08-17", kind: "log" });
366
+ const injection = renderMemoryInjectionV1({
367
+ botId: "bot-1",
368
+ noteCutoff: CUT,
369
+ user: tier(
370
+ userMemoryRootV1(OWNER),
371
+ [],
372
+ [stale("[note] user one"), stale("[note] user two")],
373
+ ),
374
+ projects: [
375
+ {
376
+ project: PROJECT,
377
+ tier: tier(
378
+ projectMemoryRootV1(OWNER, PROJECT.projectId),
379
+ [],
380
+ [stale("[note] project one")],
381
+ ),
382
+ },
383
+ ],
384
+ joined: [PROJECT],
385
+ own: tier(botMemoryRootV1(OWNER)),
386
+ });
387
+
388
+ expect(injection.faded).toEqual([
389
+ { scope: "user", projectId: "", count: 2 },
390
+ { scope: "project", projectId: PROJECT.projectId, count: 1 },
391
+ ]);
392
+ expect(injection.facts).toEqual([]);
393
+ });
394
+
395
+ test("a whole tier of faded notes renders the empty-tier text, not a blank block", () => {
396
+ const injection = renderMemoryInjectionV1({
397
+ botId: "bot-1",
398
+ noteCutoff: CUT,
399
+ user: tier(
400
+ userMemoryRootV1(OWNER),
401
+ [],
402
+ [fact("[note] gone", { text: "[note] gone", date: "2026-08-17" })],
403
+ ),
404
+ projects: [],
405
+ joined: [],
406
+ own: tier(
407
+ botMemoryRootV1(OWNER),
408
+ [],
409
+ [own("[note] also gone", "2026-08-17")],
410
+ ),
411
+ });
412
+ expect(injection.text).toContain("No shared facts recorded yet.");
413
+ expect(injection.text).toContain("No facts recorded yet.");
414
+ expect(injection.text).not.toContain("gone");
415
+ });
416
+
417
+ test("precedence runs on the survivors: a faded own note frees the shared one", () => {
418
+ const shared = "we ship on Friday";
419
+ const injection = renderMemoryInjectionV1({
420
+ botId: "bot-1",
421
+ noteCutoff: CUT,
422
+ user: tier(userMemoryRootV1(OWNER), [fact(shared)]),
423
+ projects: [],
424
+ joined: [],
425
+ own: tier(
426
+ botMemoryRootV1(OWNER),
427
+ [],
428
+ [own(`[note] ${shared}`, "2026-08-17")],
429
+ ),
430
+ });
431
+ // The own note faded, so it does not claim the text away from the User
432
+ // block — the User's durable fact is injected instead of nothing at all.
433
+ expect(injection.text).toContain(`[via School] ${shared}`);
434
+ expect(injection.faded).toEqual([
435
+ { scope: "bot", projectId: "", count: 1 },
436
+ ]);
437
+ });
438
+ });