@frockbot/plugin-memory 0.3.6 → 0.3.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-memory",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,10 +22,10 @@
22
22
  "typecheck": "tsc --noEmit -p tsconfig.json"
23
23
  },
24
24
  "dependencies": {
25
- "@frockbot/kernel-agent-loop": "0.3.6",
26
- "@frockbot/kernel-contracts": "0.3.6",
27
- "@frockbot/secret-shapes": "0.3.6",
28
- "@frockbot/workspace-store": "0.3.6",
25
+ "@frockbot/kernel-agent-loop": "0.3.7",
26
+ "@frockbot/kernel-contracts": "0.3.7",
27
+ "@frockbot/secret-shapes": "0.3.7",
28
+ "@frockbot/workspace-store": "0.3.7",
29
29
  "cordis": "4.0.0-rc.8"
30
30
  },
31
31
  "devDependencies": {
package/src/agent.test.ts CHANGED
@@ -205,7 +205,10 @@ describe("memory_forget", () => {
205
205
  );
206
206
 
207
207
  expect(result.isError).toBe(false);
208
- expect(result.content).toContain("retraction");
208
+ // The result the model paraphrases says what happened, not how: the
209
+ // retraction, the shard and "newest wins" are this Package's mechanics.
210
+ expect(result.content).toContain("Forgotten");
211
+ expect(result.content).not.toContain("shard");
209
212
  const written = session.events.find(
210
213
  (event) => event.type === "memory/written",
211
214
  );
package/src/agent.ts CHANGED
@@ -32,8 +32,8 @@ import type {} from "@frockbot/kernel-agent-loop/agent";
32
32
  import type { Plugin } from "cordis";
33
33
  import { createMemoryEmbedder } from "./embeddings.js";
34
34
  import {
35
- listAllMemoryDocumentsV1,
36
- type MemoryDocumentV1,
35
+ readAllMemoryDocumentsV1,
36
+ type MemoryDocumentListingV1,
37
37
  } from "./documents.js";
38
38
  import {
39
39
  buildMemoryIndexV1,
@@ -46,6 +46,7 @@ import {
46
46
  parseProjectDocumentV1,
47
47
  projectDocumentPathV1,
48
48
  renderProjectDocumentV1,
49
+ type MemoryProjectsOutcomeV1,
49
50
  type MemoryProjectsV1,
50
51
  } from "./projects.js";
51
52
  export type {
@@ -166,6 +167,15 @@ export class MemoryProjection {
166
167
  };
167
168
  #index: MemoryIndexV1 = emptyMemoryIndexV1();
168
169
  #turn: number | undefined;
170
+ /** This Turn's one Project-membership read, shared by injection and index. */
171
+ #roots:
172
+ | Promise<{
173
+ own: WorkspaceMemoryRootV1;
174
+ user: WorkspaceMemoryRootV1;
175
+ projects: MemoryProjectV1[];
176
+ unavailable?: string;
177
+ }>
178
+ | undefined;
169
179
 
170
180
  constructor(host: MemoryRuntimeHostV1) {
171
181
  this.#host = host;
@@ -197,6 +207,21 @@ export class MemoryProjection {
197
207
  user: WorkspaceMemoryRootV1;
198
208
  projects: MemoryProjectV1[];
199
209
  unavailable?: string;
210
+ }> {
211
+ // One membership read per Turn, shared by the injection and the index.
212
+ // Two calls meant two cross-Durable-Object round trips, and worse: if the
213
+ // second failed, `roots()` answered "no Projects" and the index silently
214
+ // omitted every Project document the injection had just included, with
215
+ // nothing recording that they disagreed.
216
+ this.#roots ??= this.readRoots();
217
+ return this.#roots;
218
+ }
219
+
220
+ private async readRoots(): Promise<{
221
+ own: WorkspaceMemoryRootV1;
222
+ user: WorkspaceMemoryRootV1;
223
+ projects: MemoryProjectV1[];
224
+ unavailable?: string;
200
225
  }> {
201
226
  const owner = this.#host.owner;
202
227
  const roots = {
@@ -221,6 +246,8 @@ export class MemoryProjection {
221
246
  async refresh(turn: number, session: Session): Promise<MemoryInjectionV1> {
222
247
  const store = this.#host.store;
223
248
  const owner = this.#host.owner;
249
+ // A new Turn reads membership again; within one Turn the read is shared.
250
+ this.#roots = undefined;
224
251
  const { own, user, projects, unavailable } = await this.roots();
225
252
  const ownTier = await store.read(own);
226
253
  const userTier = await store.read(user);
@@ -295,10 +322,31 @@ export class MemoryProjection {
295
322
  return this.#injection;
296
323
  }
297
324
 
298
- /** Rebuilds the derived index incrementally from the current files. */
299
- async reindex(): Promise<{ documentsChanged: number; chunksTotal: number }> {
300
- const documents = await this.documents();
301
- const update = await updateMemoryIndexV1(this.#index, documents);
325
+ /**
326
+ * Rebuilds the derived index incrementally from the current files.
327
+ *
328
+ * A listing that could not be read whole updates nothing. The indexer reads
329
+ * an absent document as a deleted one, so applying a short listing turned a
330
+ * transient object-storage blip into a permanent, silent deletion of that
331
+ * document's chunks — `memory_search` simply found less, with no event and
332
+ * no omission to say why. Keeping the previous index costs at worst one
333
+ * stale chunk until the next Turn.
334
+ */
335
+ async reindex(): Promise<{
336
+ documentsChanged: number;
337
+ chunksTotal: number;
338
+ /** True when the files could not be read whole and nothing was applied. */
339
+ deferred?: true;
340
+ }> {
341
+ const listing = await this.documents();
342
+ if (!listing.complete) {
343
+ return {
344
+ documentsChanged: 0,
345
+ chunksTotal: this.#index.chunks.length,
346
+ deferred: true,
347
+ };
348
+ }
349
+ const update = await updateMemoryIndexV1(this.#index, listing.documents);
302
350
  this.#index = update.index;
303
351
  await this.embed();
304
352
  return {
@@ -307,16 +355,26 @@ export class MemoryProjection {
307
355
  };
308
356
  }
309
357
 
310
- /** Throws the derived index away and builds it again from the files. */
311
- async rebuild(): Promise<{ chunksTotal: number }> {
312
- this.#index = await buildMemoryIndexV1(await this.documents());
358
+ /**
359
+ * Throws the derived index away and builds it again from the files.
360
+ *
361
+ * An explicit rebuild on a partial listing is refused rather than half
362
+ * done: "rebuild the index" that quietly drops what it could not read is
363
+ * worse than a rebuild that says it could not run.
364
+ */
365
+ async rebuild(): Promise<{ chunksTotal: number; deferred?: true }> {
366
+ const listing = await this.documents();
367
+ if (!listing.complete) {
368
+ return { chunksTotal: this.#index.chunks.length, deferred: true };
369
+ }
370
+ this.#index = await buildMemoryIndexV1(listing.documents);
313
371
  await this.embed();
314
372
  return { chunksTotal: this.#index.chunks.length };
315
373
  }
316
374
 
317
- private async documents(): Promise<MemoryDocumentV1[]> {
375
+ private async documents(): Promise<MemoryDocumentListingV1> {
318
376
  const { own, user, projects } = await this.roots();
319
- return listAllMemoryDocumentsV1(this.#host.store.reads, [
377
+ return readAllMemoryDocumentsV1(this.#host.store.reads, [
320
378
  own,
321
379
  user,
322
380
  ...projects.map((project) =>
@@ -342,6 +400,9 @@ export class MemoryProjection {
342
400
  this.#injection = { text: "", facts: [], omissions: [], faded: [] };
343
401
  this.#index = emptyMemoryIndexV1();
344
402
  this.#turn = undefined;
403
+ // Membership is exactly the thing a `project_*` tool just changed, so the
404
+ // memoized read goes with the rest of the projection.
405
+ this.#roots = undefined;
345
406
  }
346
407
  }
347
408
 
@@ -607,9 +668,13 @@ export function createMemoryWriteTool(
607
668
  await session.flush();
608
669
  await projection.reindex();
609
670
  return {
671
+ // What the model paraphrases to the user. A path, a generation id
672
+ // and "it reaches your prompt on your next Turn" are this Package's
673
+ // mechanics, and we watched a Bot read them straight back to someone
674
+ // who had only said where they lived.
610
675
  content: outcome.duplicate
611
- ? `That fact was already recorded in ${decoded.scope} memory; nothing changed.`
612
- : `Recorded in ${decoded.scope} memory (${decoded.tier}) at ${outcome.path} as generation ${outcome.generationId}. It reaches your prompt on your next Turn.`,
676
+ ? `Already remembered; nothing changed.`
677
+ : `Remembered.`,
613
678
  isError: false,
614
679
  };
615
680
  },
@@ -678,8 +743,14 @@ export function createMemoryForgetTool(
678
743
  action: "forget",
679
744
  scope: decoded.scope,
680
745
  projectId: decoded.project ?? "",
681
- tier: "log",
682
- path: `${decoded.scope}/forget`,
746
+ // A forget is not a tier and has no path until it has run: it may
747
+ // rewrite the profile file, one or more log files, or write a
748
+ // retraction. Naming `log` and `<scope>/forget` here made the intent
749
+ // disagree with its own outcome — a forget of a profile fact recorded
750
+ // `tier: "log", path: "bot/forget"` and then `path: "profile.md"`.
751
+ // `pending` says plainly that the files are not known yet.
752
+ tier: "pending",
753
+ path: "",
683
754
  contentHash,
684
755
  });
685
756
  await session.flush();
@@ -732,8 +803,8 @@ export function createMemoryForgetTool(
732
803
  await projection.reindex();
733
804
  return {
734
805
  content: outcome.retracted
735
- ? `That fact was recorded by another Bot, so it was not edited. A retraction is now in your own shard and newest wins, so it stops being injected on your next Turn.`
736
- : `Forgotten. The line is gone from ${outcome.path}.`,
806
+ ? `Forgotten. Another of your Bots had recorded it too, and it will stop coming up for them as well.`
807
+ : `Forgotten.`,
737
808
  isError: false,
738
809
  };
739
810
  },
@@ -1003,17 +1074,46 @@ export function createProjectTools(
1003
1074
  }
1004
1075
  }
1005
1076
 
1006
- const outcome =
1007
- action === "create"
1008
- ? await host.projects.create({
1009
- projectId: decoded.project,
1010
- name: decoded.name || decoded.project,
1011
- description: decoded.description ?? "",
1012
- })
1013
- : action === "join"
1014
- ? await host.projects.join(decoded.project)
1015
- : await host.projects.leave(decoded.project);
1077
+ // The descriptor above is already durable in object storage. If the
1078
+ // membership authority now refuses or throws, the file is real, the
1079
+ // membership is unchanged, and — before this — nothing was recorded at
1080
+ // all, so the durable log said no Project change happened while a
1081
+ // descriptor for it sat in R2. Whatever the answer, it is recorded.
1082
+ let outcome: MemoryProjectsOutcomeV1;
1083
+ try {
1084
+ outcome =
1085
+ action === "create"
1086
+ ? await host.projects.create({
1087
+ projectId: decoded.project,
1088
+ name: decoded.name || decoded.project,
1089
+ description: decoded.description ?? "",
1090
+ })
1091
+ : action === "join"
1092
+ ? await host.projects.join(decoded.project)
1093
+ : await host.projects.leave(decoded.project);
1094
+ } catch (error) {
1095
+ outcome = {
1096
+ status: "refused",
1097
+ reason:
1098
+ error instanceof Error
1099
+ ? error.message
1100
+ : "the Project membership authority is unavailable",
1101
+ };
1102
+ }
1016
1103
  if (outcome.status !== "ok") {
1104
+ session.append({
1105
+ type: "memory/project-changed",
1106
+ ...position,
1107
+ effectId,
1108
+ action,
1109
+ projectId: decoded.project,
1110
+ // Membership did not change, and the event says so by carrying the
1111
+ // membership as it stands rather than the one that was asked for.
1112
+ projects: (await host.projects.joined().catch(() => [])).map(
1113
+ (project) => project.projectId,
1114
+ ),
1115
+ });
1116
+ await session.flush();
1017
1117
  return refusal(`${name} was refused: ${outcome.reason}`);
1018
1118
  }
1019
1119
  session.append({
@@ -1030,7 +1130,7 @@ export function createProjectTools(
1030
1130
  content: `Projects you have joined: ${
1031
1131
  outcome.joined.map((project) => project.projectId).join(", ") ||
1032
1132
  "none"
1033
- }. Project memory changes reach your prompt on your next Turn.`,
1133
+ }.`,
1034
1134
  isError: false,
1035
1135
  };
1036
1136
  },
@@ -1118,7 +1218,31 @@ export function createMemoryRuntimePlugin(
1118
1218
  // own prompt on the next Turn, which is what makes the injected block
1119
1219
  // and the `memory/injected` record describe the same thing.
1120
1220
  if (step === 1 || projection.loadedTurn() !== turn) {
1121
- await projection.refresh(turn, agent.session);
1221
+ try {
1222
+ await projection.refresh(turn, agent.session);
1223
+ } catch (error) {
1224
+ // Memory is remote, and a remote read that throws used to fail
1225
+ // the whole Turn as `model-error`. A Turn with no Memory is a
1226
+ // worse Turn; a Turn that does not happen is no Turn at all. The
1227
+ // gap is recorded so it is visible in durable state rather than
1228
+ // being a silent change in the Bot's behaviour.
1229
+ agent.session.append({
1230
+ type: "memory/injected",
1231
+ turn,
1232
+ sources: [],
1233
+ facts: [],
1234
+ omissions: [
1235
+ {
1236
+ scope: "bot",
1237
+ reason:
1238
+ error instanceof Error
1239
+ ? error.message
1240
+ : "Memory could not be read for this Turn",
1241
+ },
1242
+ ],
1243
+ });
1244
+ await agent.session.flush();
1245
+ }
1122
1246
  }
1123
1247
  return next();
1124
1248
  }),
package/src/documents.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import type {
11
11
  MemoryScopeNameV1,
12
12
  WorkspaceMemoryRootV1,
13
+ WorkspacePathV1,
13
14
  WorkspaceReadsV1,
14
15
  } from "@frockbot/kernel-contracts";
15
16
  import {
@@ -17,7 +18,7 @@ import {
17
18
  memoryProjectIdOfRootV1,
18
19
  memoryScopeOfRootV1,
19
20
  } from "./roots.js";
20
- import { MEMORY_MAX_FILES_PER_TIER, MEMORY_MAX_LIST_PAGES } from "./store.js";
21
+ import { MEMORY_MAX_LIST_PAGES, selectNewestMemoryFilesV1 } from "./store.js";
21
22
 
22
23
  /** One Memory file, addressed by its content and its generation. */
23
24
  export interface MemoryDocumentV1 {
@@ -42,56 +43,120 @@ export function memoryDocumentKeyV1(document: {
42
43
  return `${document.scope}:${document.projectId}:${document.path}`;
43
44
  }
44
45
 
46
+ /**
47
+ * Every document of one tier, and whether the tier was read whole.
48
+ *
49
+ * `complete: false` means something under this root could not be read. That
50
+ * distinction is the whole point of the shape: the indexer treats an absent
51
+ * document as a deleted one, so a partial listing offered as if it were whole
52
+ * made a transient object-storage blip delete chunks from the search index
53
+ * permanently and silently.
54
+ */
55
+ export interface MemoryDocumentListingV1 {
56
+ documents: MemoryDocumentV1[];
57
+ complete: boolean;
58
+ }
59
+
45
60
  /**
46
61
  * Reads every Memory file under one root. A file that cannot be read is
47
- * skipped rather than thrown: an index is derived state, and a partial rebuild
62
+ * skipped rather than thrown an index is derived state, and a partial read
48
63
  * that says so beats a Turn that fails because one object was briefly
49
- * unreachable.
64
+ * unreachable — and the listing says it was partial.
50
65
  */
51
- export async function listMemoryDocumentsV1(
66
+ export async function readMemoryDocumentsV1(
52
67
  reads: WorkspaceReadsV1,
53
68
  root: WorkspaceMemoryRootV1,
54
- ): Promise<MemoryDocumentV1[]> {
69
+ ): Promise<MemoryDocumentListingV1> {
55
70
  const scope = memoryScopeOfRootV1(root);
56
71
  const projectId = memoryProjectIdOfRootV1(root);
57
- const documents: MemoryDocumentV1[] = [];
72
+ const candidates: Array<{
73
+ path: WorkspacePathV1;
74
+ kind: "profile" | "log";
75
+ shard: string;
76
+ generation: { writtenAt: string; generationId: string };
77
+ }> = [];
78
+ let complete = true;
58
79
  let cursor: string | undefined;
59
- for (let page = 0; page < MEMORY_MAX_LIST_PAGES; page += 1) {
80
+ let pages = 0;
81
+ for (; pages < MEMORY_MAX_LIST_PAGES; pages += 1) {
60
82
  const outcome = await reads.list(
61
83
  cursor === undefined ? { root } : { root, cursor },
62
84
  );
63
- if (outcome.status !== "ok") return documents;
85
+ if (outcome.status !== "ok") return { documents: [], complete: false };
64
86
  for (const entry of outcome.entries) {
65
- if (documents.length >= MEMORY_MAX_FILES_PER_TIER) return documents;
66
87
  const classified = memoryFileKindV1(root, entry.path.path);
67
88
  if (!classified) continue;
68
- const read = await reads.read(entry.path);
69
- if (read.status !== "ok") continue;
70
- documents.push({
71
- scope,
72
- projectId,
73
- path: entry.path.path,
74
- botId: classified.shard,
89
+ candidates.push({
90
+ path: entry.path,
75
91
  kind: classified.kind,
76
- text: new TextDecoder().decode(read.file.bytes),
77
- contentHash: read.file.generation.contentHash,
78
- generationId: read.file.generation.generationId,
92
+ shard: classified.shard,
93
+ generation: entry.generation,
79
94
  });
80
95
  }
81
96
  if (!outcome.cursor) break;
82
97
  cursor = outcome.cursor;
83
98
  }
84
- return documents;
99
+ if (cursor !== undefined && pages >= MEMORY_MAX_LIST_PAGES) complete = false;
100
+ // The same selection the injected block makes, and for the same reason:
101
+ // the two used to disagree — the block kept the *newest* files by recorded
102
+ // generation while this kept the *first* in listing order, so past the cap
103
+ // injection covered recent Memory and `memory_search` covered ancient
104
+ // Memory, with nothing recording the divergence.
105
+ const selected = selectNewestMemoryFilesV1(candidates);
106
+ if (selected.length < candidates.length) complete = false;
107
+ const documents: MemoryDocumentV1[] = [];
108
+ for (const candidate of selected) {
109
+ const read = await reads.read(candidate.path);
110
+ if (read.status !== "ok") {
111
+ complete = false;
112
+ continue;
113
+ }
114
+ documents.push({
115
+ scope,
116
+ projectId,
117
+ path: candidate.path.path,
118
+ botId: candidate.shard,
119
+ kind: candidate.kind,
120
+ text: new TextDecoder().decode(read.file.bytes),
121
+ contentHash: read.file.generation.contentHash,
122
+ generationId: read.file.generation.generationId,
123
+ });
124
+ }
125
+ return { documents, complete };
85
126
  }
86
127
 
87
- /** Every Memory document of every root a Bot can see, in tier order. */
88
- export async function listAllMemoryDocumentsV1(
128
+ /** The documents of one tier, without saying whether the tier was read whole. */
129
+ export async function listMemoryDocumentsV1(
89
130
  reads: WorkspaceReadsV1,
90
- roots: WorkspaceMemoryRootV1[],
131
+ root: WorkspaceMemoryRootV1,
91
132
  ): Promise<MemoryDocumentV1[]> {
133
+ return (await readMemoryDocumentsV1(reads, root)).documents;
134
+ }
135
+
136
+ /**
137
+ * Every Memory document of every root a Bot can see, in tier order, and
138
+ * whether every one of those roots was read whole. One partial tier makes the
139
+ * whole listing partial: the index is updated across tiers at once, and a
140
+ * caller that cannot tell which tier was short cannot safely delete from it.
141
+ */
142
+ export async function readAllMemoryDocumentsV1(
143
+ reads: WorkspaceReadsV1,
144
+ roots: WorkspaceMemoryRootV1[],
145
+ ): Promise<MemoryDocumentListingV1> {
92
146
  const documents: MemoryDocumentV1[] = [];
147
+ let complete = true;
93
148
  for (const root of roots) {
94
- documents.push(...(await listMemoryDocumentsV1(reads, root)));
149
+ const listing = await readMemoryDocumentsV1(reads, root);
150
+ documents.push(...listing.documents);
151
+ complete &&= listing.complete;
95
152
  }
96
- return documents;
153
+ return { documents, complete };
154
+ }
155
+
156
+ /** Every Memory document of every root a Bot can see, in tier order. */
157
+ export async function listAllMemoryDocumentsV1(
158
+ reads: WorkspaceReadsV1,
159
+ roots: WorkspaceMemoryRootV1[],
160
+ ): Promise<MemoryDocumentV1[]> {
161
+ return (await readAllMemoryDocumentsV1(reads, roots)).documents;
97
162
  }
package/src/embeddings.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { remoteCallV1 } from "@frockbot/kernel-contracts";
1
2
  import {
2
3
  EMBEDDING_MODEL,
3
4
  type EmbedMemory,
@@ -13,9 +14,11 @@ export function createMemoryEmbedder(
13
14
  return async (texts) => {
14
15
  const vectors: number[][] = [];
15
16
  for (let index = 0; index < texts.length; index += MAX_BATCH_SIZE) {
16
- const response = await ai.run(model, {
17
- text: texts.slice(index, index + MAX_BATCH_SIZE),
18
- });
17
+ // Workers AI is remote in every environment. Without a deadline a hung
18
+ // binding held the whole Turn open to the platform limit.
19
+ const response = await remoteCallV1("the embedding model", () =>
20
+ ai.run(model, { text: texts.slice(index, index + MAX_BATCH_SIZE) }),
21
+ );
19
22
  vectors.push(...response.data);
20
23
  }
21
24
  return vectors;
@@ -4,7 +4,10 @@
4
4
  // the files in one pass.
5
5
  import { describe, expect, test } from "bun:test";
6
6
  import type { WorkspaceWriterV1 } from "@frockbot/kernel-contracts";
7
- import { listAllMemoryDocumentsV1 } from "./documents.ts";
7
+ import {
8
+ listAllMemoryDocumentsV1,
9
+ readMemoryDocumentsV1,
10
+ } from "./documents.ts";
8
11
  import {
9
12
  buildMemoryIndexV1,
10
13
  emptyMemoryIndexV1,
@@ -89,3 +92,58 @@ describe("the derived Memory index", () => {
89
92
  expect(emptied.index).toEqual(await buildMemoryIndexV1([]));
90
93
  });
91
94
  });
95
+
96
+ describe("a tier that could not be read whole says so", () => {
97
+ test("a failed listing is partial, not empty", async () => {
98
+ const files = createTestMemoryFilesV1({ userId: "user-1" });
99
+ const store = new MemoryStore({
100
+ files,
101
+ owner: OWNER,
102
+ clock: () => new Date("2026-08-31T10:00:00.000Z"),
103
+ });
104
+ const root = botMemoryRootV1(OWNER);
105
+ await store.write({
106
+ root,
107
+ tier: "profile",
108
+ fact: "A fact worth chunking.",
109
+ writer: WRITER,
110
+ });
111
+ expect((await readMemoryDocumentsV1(files, root)).complete).toBe(true);
112
+
113
+ // Object storage blips on the listing exactly once.
114
+ const list = files.list.bind(files);
115
+ files.list = () =>
116
+ Promise.resolve({ status: "unavailable" as const, reason: "R2 blip" });
117
+
118
+ const listing = await readMemoryDocumentsV1(files, root);
119
+ // The distinction that matters: nothing was read, and the caller is told,
120
+ // so the indexer cannot mistake "unreadable" for "deleted".
121
+ expect(listing.documents).toEqual([]);
122
+ expect(listing.complete).toBe(false);
123
+
124
+ files.list = list;
125
+ expect((await readMemoryDocumentsV1(files, root)).complete).toBe(true);
126
+ });
127
+
128
+ test("a file that cannot be read leaves the tier partial", async () => {
129
+ const files = createTestMemoryFilesV1({ userId: "user-1" });
130
+ const store = new MemoryStore({
131
+ files,
132
+ owner: OWNER,
133
+ clock: () => new Date("2026-08-31T10:00:00.000Z"),
134
+ });
135
+ const root = botMemoryRootV1(OWNER);
136
+ await store.write({
137
+ root,
138
+ tier: "profile",
139
+ fact: "A fact worth chunking.",
140
+ writer: WRITER,
141
+ });
142
+ files.read = () =>
143
+ Promise.resolve({ status: "unavailable" as const, reason: "R2 blip" });
144
+
145
+ const listing = await readMemoryDocumentsV1(files, root);
146
+ expect(listing.documents).toEqual([]);
147
+ expect(listing.complete).toBe(false);
148
+ });
149
+ });
package/src/indexer.ts CHANGED
Binary file
package/src/render.ts CHANGED
@@ -56,6 +56,15 @@ export const MEMORY_USER_CAPS_V1: MemoryRenderCapsV1 = {
56
56
  factClamp: 500,
57
57
  };
58
58
 
59
+ /**
60
+ * The whole injected Memory block's ceiling, over and above the per-tier caps.
61
+ *
62
+ * The per-tier caps bound each section but not their sum, so a Bot in three
63
+ * Projects injected the sum of every cap on every Turn. This is the one number
64
+ * that bounds what Memory costs a request, whatever a Bot has joined.
65
+ */
66
+ export const MEMORY_INJECTION_BUDGET_V1 = 12_000;
67
+
59
68
  /** profileLimit 25 / recentLimit 10, char budgets 2500 / 1500. */
60
69
  export const MEMORY_PROJECT_CAPS_V1: MemoryRenderCapsV1 = {
61
70
  profileLimit: 25,
@@ -124,7 +133,12 @@ export interface MemoryInjectionInputV1 {
124
133
  export interface InjectedMemoryFactV1 {
125
134
  scope: MemoryScopeNameV1;
126
135
  projectId: string;
127
- tier: "profile" | "log";
136
+ /**
137
+ * The tier the fact was written as, not the file it happens to sit in. A
138
+ * note lives in the log file; recording it as `log` left a reader of the
139
+ * durable event unable to tell the two apart.
140
+ */
141
+ tier: "profile" | "log" | "note";
128
142
  via: string;
129
143
  learnedAt: string;
130
144
  text: string;
@@ -205,7 +219,12 @@ function injected(
205
219
  return facts.map((fact) => ({
206
220
  scope,
207
221
  projectId,
208
- tier,
222
+ // A note lives in the log file, and the event used to say `log` while the
223
+ // write that produced it said `note` — so a reader of the durable record
224
+ // could not tell the two tiers apart, and the only sign was a `[note] `
225
+ // prefix inside the text. The marker on the recorded text is what the
226
+ // tier was; the file it sits in is not.
227
+ tier: parseMemoryMarkerV1(fact.text).marker === "note" ? "note" : tier,
209
228
  via: withVia ? fact.via : "",
210
229
  learnedAt: fact.date,
211
230
  text: fact.text,
@@ -474,5 +493,28 @@ export function renderMemoryInjectionV1(
474
493
  });
475
494
  }
476
495
 
477
- return { text: blocks.join("\n\n"), facts, omissions, faded };
496
+ // A global bound over the per-tier ones. Each tier is capped on its own, so
497
+ // a Bot in three Projects could inject the sum of every cap — about 26 000
498
+ // characters — on every Turn, growing with membership rather than with
499
+ // anything the User did. The block is assembled most-general first and the
500
+ // Bot's own memory last, so the trim drops from the front: the most
501
+ // specific tier, which wins on conflict anyway, is the one kept.
502
+ const text = blocks.join("\n\n");
503
+ if (text.length <= MEMORY_INJECTION_BUDGET_V1) {
504
+ return { text, facts, omissions, faded };
505
+ }
506
+ const kept: string[] = [];
507
+ let spent = 0;
508
+ for (const block of blocks.toReversed()) {
509
+ if (spent + block.length > MEMORY_INJECTION_BUDGET_V1) break;
510
+ kept.unshift(block);
511
+ spent += block.length;
512
+ }
513
+ omissions.push({
514
+ scope: "user",
515
+ reason: `the injected Memory block exceeded ${MEMORY_INJECTION_BUDGET_V1} characters; ${
516
+ blocks.length - kept.length
517
+ } of ${blocks.length} section(s), least specific first, were not injected`,
518
+ });
519
+ return { text: kept.join("\n\n"), facts, omissions, faded };
478
520
  }
package/src/roots.ts CHANGED
@@ -91,13 +91,29 @@ export function memoryProjectIdOfRootV1(root: WorkspaceMemoryRootV1): string {
91
91
  return root.kind === "project-memory" ? root.projectId : "";
92
92
  }
93
93
 
94
- /** `log/YYYY-MM.md`, the monthly file a dated fact is appended to. */
95
- export function memoryLogRelativeV1(at: Date): string {
94
+ /**
95
+ * `log/YYYY-MM.md`, the monthly file a dated fact is appended to, or
96
+ * `log/YYYY-MM.NN.md` once that month has rolled over.
97
+ *
98
+ * A month rolls over when its file reaches the per-file byte cap. Without
99
+ * that, a busy month's file grew past the cap and then the *whole tier*
100
+ * vanished from injection — the read skips an oversized file and `forget`
101
+ * answers `unavailable` for the tier, so no tool could trim it back.
102
+ */
103
+ export function memoryLogRelativeV1(at: Date, part = 0): string {
96
104
  const year = at.getUTCFullYear().toString().padStart(4, "0");
97
105
  const month = (at.getUTCMonth() + 1).toString().padStart(2, "0");
98
- return `${MEMORY_LOG_DIRECTORY}/${year}-${month}.md`;
106
+ // `p` and not a bare number: the tier merge relies on path order, and
107
+ // `2026-08.01.md` sorts *before* `2026-08.md` while `2026-08.p01.md` sorts
108
+ // after it. The first file of a month keeps its existing name, so nothing
109
+ // already written moves.
110
+ const suffix = part > 0 ? `.p${part.toString().padStart(2, "0")}` : "";
111
+ return `${MEMORY_LOG_DIRECTORY}/${year}-${month}${suffix}.md`;
99
112
  }
100
113
 
114
+ /** How many rollover parts one month may have before a write is refused. */
115
+ export const MEMORY_MAX_LOG_PARTS_V1 = 99;
116
+
101
117
  /** The relative path, inside a shard, one tier writes to. */
102
118
  export function memoryTierRelativeV1(tier: MemoryTierV1, at: Date): string {
103
119
  return tier === "profile" ? MEMORY_PROFILE_FILE : memoryLogRelativeV1(at);
@@ -117,6 +133,16 @@ export function memoryFilePathV1(
117
133
  return memoryShardPathV1(root, botId, memoryTierRelativeV1(tier, at));
118
134
  }
119
135
 
136
+ /** One month's log file, or one of its rollover parts, in a Bot's shard. */
137
+ export function memoryLogPathV1(
138
+ root: WorkspaceMemoryRootV1,
139
+ botId: string,
140
+ at: Date,
141
+ part: number,
142
+ ): WorkspacePathV1 {
143
+ return memoryShardPathV1(root, botId, memoryLogRelativeV1(at, part));
144
+ }
145
+
120
146
  /** The prefix a Bot's own files sit under; `""` for the Bot Memory root. */
121
147
  export function memoryShardOfV1(
122
148
  root: WorkspaceMemoryRootV1,
@@ -153,6 +179,11 @@ export function memoryFileKindV1(
153
179
  shard = root.botId;
154
180
  }
155
181
  if (tail === MEMORY_PROFILE_FILE) return { kind: "profile", shard };
156
- if (/^log\/\d{4}-\d{2}\.md$/.test(tail)) return { kind: "log", shard };
182
+ // `log/YYYY-MM.md` and its rollover parts `log/YYYY-MM.NN.md`. Both sort
183
+ // after the month they belong to and before the next one, which is the
184
+ // order the tier merge relies on.
185
+ if (/^log\/\d{4}-\d{2}(\.p\d{2})?\.md$/.test(tail)) {
186
+ return { kind: "log", shard };
187
+ }
157
188
  return undefined;
158
189
  }
package/src/searcher.ts CHANGED
@@ -9,7 +9,10 @@
9
9
  // Precedence here is the Memory precedence: own (`bot`) before `project`
10
10
  // before `user`, "the most specific wins", applied after scoring so a strong
11
11
  // shared hit still ranks above a weak own one within the same document.
12
- import type { MemoryScopeNameV1 } from "@frockbot/kernel-contracts";
12
+ import {
13
+ remoteCallV1,
14
+ type MemoryScopeNameV1,
15
+ } from "@frockbot/kernel-contracts";
13
16
  import {
14
17
  memoryVectorNamespaceV1,
15
18
  type MemoryIndexChunkV1,
@@ -89,19 +92,37 @@ export async function searchMemoryV1(
89
92
  const [vector] = await options.embed([query]);
90
93
  if (vector) {
91
94
  const namespaces = new Set(candidates.map(memoryVectorNamespaceV1));
92
- const byHash = new Map(
93
- candidates.map((chunk) => [chunk.hash, chunk] as const),
95
+ // Keyed on the document *and* the chunk hash. Keying on the chunk
96
+ // hash alone made two identical chunks in different documents collide,
97
+ // so one of the two locations was unreachable from a vector hit.
98
+ const byHash = new Map<string, MemoryIndexChunkV1>(
99
+ candidates.map((chunk) => [
100
+ `${chunk.documentKey} ${chunk.hash}`,
101
+ chunk,
102
+ ]),
94
103
  );
95
104
  for (const namespace of namespaces) {
96
- const response = await options.vectorize.query(vector, {
97
- topK: Math.min(options.maxResults * 3, 20),
98
- namespace,
99
- returnMetadata: "all",
100
- });
105
+ const response = await remoteCallV1("the memory index", () =>
106
+ options.vectorize!.query(vector, {
107
+ topK: Math.min(options.maxResults * 3, 20),
108
+ namespace,
109
+ returnMetadata: "all",
110
+ }),
111
+ );
101
112
  for (const match of response.matches) {
102
113
  const hash = match.metadata?.hash;
114
+ // The document key is rebuilt from the metadata the upsert wrote,
115
+ // so a chunk is matched to the document it actually came from.
116
+ const scope = match.metadata?.scope;
117
+ const projectId = match.metadata?.projectId;
118
+ const path = match.metadata?.path;
103
119
  const chunk =
104
- typeof hash === "string" ? byHash.get(hash) : undefined;
120
+ typeof hash === "string" &&
121
+ typeof scope === "string" &&
122
+ typeof projectId === "string" &&
123
+ typeof path === "string"
124
+ ? byHash.get(`${scope}:${projectId}:${path} ${hash}`)
125
+ : undefined;
105
126
  if (!chunk) continue;
106
127
  scored.set(
107
128
  chunk,
package/src/store.test.ts CHANGED
@@ -171,6 +171,65 @@ describe("the Memory writer", () => {
171
171
  });
172
172
  });
173
173
 
174
+ describe("a month's log rolls over instead of killing its tier", () => {
175
+ test("keeps writing, keeps reading, and every fact survives", async () => {
176
+ const { files, store } = storeFor("bot-1");
177
+ const root = botMemoryRootV1(OWNER);
178
+ const writer = writerFor("bot-1");
179
+ // Facts big enough that a few hundred fill one 256 KB file.
180
+ const filler = "y".repeat(1_900);
181
+
182
+ for (let index = 0; index < 200; index += 1) {
183
+ const outcome = await store.write({
184
+ root,
185
+ tier: "log",
186
+ fact: `${index} ${filler}`,
187
+ writer,
188
+ });
189
+ // The write that used to push the file past the cap and strand the
190
+ // whole tier now lands in the next part instead.
191
+ expect(outcome.status).toBe("ok");
192
+ }
193
+
194
+ const listing = await files.list({ root });
195
+ expect(listing.status).toBe("ok");
196
+ if (listing.status !== "ok") return;
197
+ const logs = listing.entries
198
+ .map((entry) => entry.path.path)
199
+ .filter((path) => path.startsWith("log/"))
200
+ .sort();
201
+ // It rolled: the month has more than one file, named so it still sorts
202
+ // inside its own month.
203
+ expect(logs.length).toBeGreaterThan(1);
204
+ // The first file keeps its existing name and the parts sort after it, so
205
+ // the tier merge's "newest month last" still holds inside the month.
206
+ expect(logs[0]).toBe("log/2026-08.md");
207
+ expect(logs[1]).toBe("log/2026-08.p01.md");
208
+ // No file is past the cap, so the read skips nothing…
209
+ for (const entry of listing.entries) {
210
+ expect(entry.generation.size).toBeLessThanOrEqual(256 * 1024);
211
+ }
212
+ // …and the tier is whole rather than `unavailable`.
213
+ const tier = await store.read(root);
214
+ expect(tier.unavailable).toBeUndefined();
215
+ expect(tier.recent.length).toBeGreaterThan(0);
216
+ });
217
+
218
+ test("refuses a single write that cannot fit in any file", async () => {
219
+ const { store } = storeFor("bot-1");
220
+ const root = botMemoryRootV1(OWNER);
221
+ // The profile tier is curated and does not roll: an oversized commit is a
222
+ // refusal the User can see, never a file the reader has to skip.
223
+ const outcome = await store.write({
224
+ root,
225
+ tier: "profile",
226
+ fact: "z".repeat(2_001),
227
+ writer: writerFor("bot-1"),
228
+ });
229
+ expect(outcome.status).toBe("refused");
230
+ });
231
+ });
232
+
174
233
  describe("forgetting", () => {
175
234
  test("removes a fact this Bot recorded from its own shard", async () => {
176
235
  const { store } = storeFor("bot-1");
@@ -225,6 +284,50 @@ describe("forgetting", () => {
225
284
  expect((await two.store.read(root)).profile).toEqual([]);
226
285
  });
227
286
 
287
+ test("retracts a shared fact that this Bot's own shard also holds", async () => {
288
+ const files = createTestMemoryFilesV1({ userId: "user-1" });
289
+ const root = userMemoryRootV1(OWNER);
290
+ const one = storeFor("bot-1", files);
291
+ const two = storeFor("bot-2", files);
292
+ // Both Bots learned the same thing. Mine is a plain fact and theirs is
293
+ // marked as a note, so the merge keeps both — this is the case where my
294
+ // own shard *and* another's hold it.
295
+ await one.store.write({
296
+ root,
297
+ tier: "profile",
298
+ fact: "Tim teaches on Tuesdays.",
299
+ writer: writerFor("bot-1"),
300
+ });
301
+ await two.store.write({
302
+ root,
303
+ tier: "profile",
304
+ fact: "[note] Tim teaches on Tuesdays.",
305
+ writer: writerFor("bot-2"),
306
+ });
307
+ expect((await one.store.read(root)).profile).toHaveLength(2);
308
+
309
+ const forgotten = await one.store.forget({
310
+ root,
311
+ fact: "Tim teaches on Tuesdays.",
312
+ writer: writerFor("bot-1"),
313
+ });
314
+
315
+ // Removing my own line used to return here, leaving the other Bot's copy
316
+ // injected forever under a result that said it was forgotten.
317
+ expect(forgotten).toMatchObject({ status: "ok", retracted: true });
318
+ const mine = await files.read({
319
+ root,
320
+ path: "by-agent/bot-1/profile.md",
321
+ });
322
+ expect(
323
+ mine.status === "ok" ? new TextDecoder().decode(mine.file.bytes) : "",
324
+ ).not.toContain("Tim teaches on Tuesdays.");
325
+ // The other Bot's shard is never edited, and the merged tier is clean for
326
+ // both readers.
327
+ expect((await one.store.read(root)).profile).toEqual([]);
328
+ expect((await two.store.read(root)).profile).toEqual([]);
329
+ });
330
+
228
331
  test("removes every marker variant of a fact, given the body or the marker", async () => {
229
332
  const { store } = storeFor("bot-1");
230
333
  const root = botMemoryRootV1(OWNER);
package/src/store.ts CHANGED
@@ -40,7 +40,9 @@ import {
40
40
  import {
41
41
  memoryFileKindV1,
42
42
  memoryFilePathV1,
43
+ memoryLogPathV1,
43
44
  memoryShardOfV1,
45
+ MEMORY_MAX_LOG_PARTS_V1,
44
46
  type MemoryOwnerV1,
45
47
  type MemoryTierV1,
46
48
  } from "./roots.js";
@@ -50,6 +52,46 @@ import { refuseMemorySecretV1 } from "./secrets.js";
50
52
  export const MEMORY_MAX_LIST_PAGES = 8;
51
53
  /** Most Memory files read to render one tier. */
52
54
  export const MEMORY_MAX_FILES_PER_TIER = 64;
55
+
56
+ /**
57
+ * How much longer than a fact a retraction of it may be: the `[forgotten] `
58
+ * prefix, and the marker the retracted text may already carry.
59
+ */
60
+ const MEMORY_RETRACTION_HEADROOM = 32;
61
+
62
+ /**
63
+ * The files of one tier that a bounded read keeps, in path order.
64
+ *
65
+ * One function, used by the injected block and by the search index, because
66
+ * they used to choose differently: the block kept the newest files by
67
+ * recorded generation and the index kept the first in listing order. Past the
68
+ * cap that meant injection covered recent Memory while `memory_search`
69
+ * covered ancient Memory, and nothing recorded that they disagreed.
70
+ *
71
+ * The newest are kept, by `writtenAt` with the generation id breaking a tie —
72
+ * both recorded by the write that produced the file — and the survivors are
73
+ * returned in path order, which is the order the tier merge relies on.
74
+ */
75
+ export function selectNewestMemoryFilesV1<
76
+ T extends {
77
+ path: { path: string };
78
+ generation: { writtenAt: string; generationId: string };
79
+ },
80
+ >(files: readonly T[], limit = MEMORY_MAX_FILES_PER_TIER): T[] {
81
+ const newest = [...files]
82
+ .sort(
83
+ (left, right) =>
84
+ left.generation.writtenAt.localeCompare(right.generation.writtenAt) ||
85
+ left.generation.generationId.localeCompare(
86
+ right.generation.generationId,
87
+ ),
88
+ )
89
+ .slice(-limit);
90
+ const kept = new Set(newest.map((file) => file.path.path));
91
+ return files
92
+ .filter((file) => kept.has(file.path.path))
93
+ .sort((left, right) => left.path.path.localeCompare(right.path.path));
94
+ }
53
95
  /** The longest fact this Package will record. */
54
96
  export const MEMORY_MAX_FACT_LENGTH = 2_000;
55
97
  /** The largest Memory file this Package will rewrite. */
@@ -73,7 +115,12 @@ export interface MemoryTierReadV1 {
73
115
  profile: SourcedMemoryFactV1[];
74
116
  recent: SourcedMemoryFactV1[];
75
117
  sources: MemorySourceV1[];
76
- /** Log facts held on disk beyond what `recent` carries, before any cap. */
118
+ /**
119
+ * How many log facts the tier read resolved. It equals `recent.length`:
120
+ * `read` applies no cap of its own, so there is nothing "beyond" it — the
121
+ * caps live in the renderer. The field was documented as the surplus and
122
+ * assigned the total, which is a difference nothing could act on.
123
+ */
77
124
  logTotal: number;
78
125
  /** Set when the tier could not be read in full; rendered as an omission. */
79
126
  unavailable?: string;
@@ -214,26 +261,22 @@ export class MemoryStore {
214
261
  .sort((left, right) =>
215
262
  left.entry.path.path.localeCompare(right.entry.path.path),
216
263
  );
217
- // The bound keeps the *newest* files, by recorded generation: what Memory
218
- // is for is injecting recent facts, so a tier past the bound loses its
219
- // oldest months rather than its newest. `writtenAt` orders them and the
220
- // generation id breaks a tie, because both are recorded by the write that
221
- // produced the file. The kept files are then restored to path order, which
222
- // is the order the merge below relies on.
223
- const newest = [...classifiedFiles]
224
- .sort((left, right) => {
225
- const a = left.entry.generation;
226
- const b = right.entry.generation;
227
- return (
228
- a.writtenAt.localeCompare(b.writtenAt) ||
229
- a.generationId.localeCompare(b.generationId)
230
- );
231
- })
232
- .slice(-MEMORY_MAX_FILES_PER_TIER);
233
- const kept = new Set(newest.map(({ entry }) => entry.path.path));
234
- const files = classifiedFiles.filter(({ entry }) =>
235
- kept.has(entry.path.path),
264
+ // The bound keeps the *newest* files: what Memory is for is injecting
265
+ // recent facts, so a tier past the bound loses its oldest months rather
266
+ // than its newest. The selection is shared with the search index, so the
267
+ // injected block and `memory_search` cover the same files.
268
+ const selected = selectNewestMemoryFilesV1(
269
+ classifiedFiles.map(({ entry, classified }) => ({
270
+ path: entry.path,
271
+ generation: entry.generation,
272
+ entry,
273
+ classified,
274
+ })),
236
275
  );
276
+ const files = selected.map(({ entry, classified }) => ({
277
+ entry,
278
+ classified,
279
+ }));
237
280
  if (classifiedFiles.length > files.length) {
238
281
  const dropped = classifiedFiles.length - files.length;
239
282
  omissions.push(
@@ -242,14 +285,21 @@ export class MemoryStore {
242
285
  }
243
286
  if (omissions.length > 0) result.omitted = omissions.join("; ");
244
287
 
288
+ // Every unreadable file is named, not just the last one. Assigning
289
+ // `result.unavailable` per file overwrote the reason each time, so a tier
290
+ // where three files failed reported one reason and was injected as if it
291
+ // were whole.
292
+ const unreadable: string[] = [];
245
293
  for (const { entry, classified } of files) {
246
294
  if (entry.generation.size > MEMORY_MAX_FILE_BYTES) {
247
- result.unavailable = `a Memory file exceeds ${MEMORY_MAX_FILE_BYTES} bytes`;
295
+ unreadable.push(
296
+ `"${entry.path.path}" exceeds ${MEMORY_MAX_FILE_BYTES} bytes`,
297
+ );
248
298
  continue;
249
299
  }
250
300
  const read = await this.#files.read(entry.path);
251
301
  if (read.status !== "ok") {
252
- result.unavailable = read.reason;
302
+ unreadable.push(`"${entry.path.path}": ${read.reason}`);
253
303
  continue;
254
304
  }
255
305
  result.sources.push({
@@ -271,6 +321,10 @@ export class MemoryStore {
271
321
  else log.push(...sourced);
272
322
  }
273
323
 
324
+ if (unreadable.length > 0) {
325
+ result.unavailable = `${unreadable.length} Memory file(s) could not be read: ${unreadable.join("; ")}`;
326
+ }
327
+
274
328
  // Retractions cross files inside a tier: a `[forgotten]` line in the log
275
329
  // suppresses the profile fact it names, which is what "newest wins" means
276
330
  // once forgetting exists at all.
@@ -299,9 +353,19 @@ export class MemoryStore {
299
353
  fact: string;
300
354
  writer: WorkspaceWriterV1;
301
355
  at?: Date;
356
+ /** Set only by `forget`: a retraction of a fact already on disk. */
357
+ retraction?: true;
302
358
  }): Promise<MemoryWriteOutcomeV1> {
303
359
  const text = request.fact.trim();
304
- if (!text || text.length > MEMORY_MAX_FACT_LENGTH) {
360
+ // The cap bounds what a Bot may *record*. A retraction is a fact the
361
+ // Package writes about a fact that is already on disk, so measuring the
362
+ // retraction against the same cap made a fact longer than
363
+ // `MEMORY_MAX_FACT_LENGTH` minus the prefix impossible to forget — the
364
+ // one operation that shrinks Memory refused because Memory was too big.
365
+ const cap = request.retraction
366
+ ? MEMORY_MAX_FACT_LENGTH + MEMORY_RETRACTION_HEADROOM
367
+ : MEMORY_MAX_FACT_LENGTH;
368
+ if (!text || text.length > cap) {
305
369
  return {
306
370
  status: "refused",
307
371
  reason: `a fact must be between 1 and ${MEMORY_MAX_FACT_LENGTH} characters`,
@@ -310,12 +374,8 @@ export class MemoryStore {
310
374
  const secret = refuseMemorySecretV1(text);
311
375
  if (secret) return { status: "refused", reason: secret.reason };
312
376
  const at = request.at ?? this.#clock();
313
- const path = memoryFilePathV1(
314
- request.root,
315
- this.owner.botId,
316
- request.tier,
317
- at,
318
- );
377
+ const path = await this.tierWritePath(request.root, request.tier, at);
378
+ if ("status" in path) return path;
319
379
  const refusal = this.refuseForeignShard(path, request.writer);
320
380
  if (refusal) return refusal;
321
381
  const line: MemoryFactV1 = { date: memoryDayV1(at), text };
@@ -328,6 +388,47 @@ export class MemoryStore {
328
388
  });
329
389
  }
330
390
 
391
+ /**
392
+ * The file this tier's next fact goes in, rolling the log over when the
393
+ * current file is full.
394
+ *
395
+ * A log file that grew past the per-file cap used to take its whole tier
396
+ * down with it: the read skips an oversized file, so the tier vanished from
397
+ * injection, and `forget` answered `unavailable` for it, so no tool could
398
+ * trim it back. Rolling to `log/YYYY-MM.NN.md` keeps every fact, keeps
399
+ * every file readable, and needs no migration — the existing month file is
400
+ * part 0 and stays exactly where it is.
401
+ *
402
+ * The profile tier does not roll: it is a bounded, curated file, and one
403
+ * that reached the cap is a real refusal the User should see.
404
+ */
405
+ private async tierWritePath(
406
+ root: WorkspaceMemoryRootV1,
407
+ tier: MemoryTierV1,
408
+ at: Date,
409
+ ): Promise<WorkspacePathV1 | MemoryWriteOutcomeV1> {
410
+ const path = memoryFilePathV1(root, this.owner.botId, tier, at);
411
+ if (tier === "profile") return path;
412
+ for (let part = 0; part <= MEMORY_MAX_LOG_PARTS_V1; part += 1) {
413
+ const candidate = memoryLogPathV1(root, this.owner.botId, at, part);
414
+ const head = await this.#files.stat(candidate);
415
+ if (head.status === "not-found") return candidate;
416
+ if (head.status !== "ok")
417
+ return { status: head.status, reason: head.reason };
418
+ // Room for at least one more fact of the maximum size, so a write never
419
+ // pushes a file past the cap and strands it.
420
+ if (
421
+ head.entry.generation.size + MEMORY_MAX_FACT_LENGTH <
422
+ MEMORY_MAX_FILE_BYTES
423
+ )
424
+ return candidate;
425
+ }
426
+ return {
427
+ status: "refused",
428
+ reason: `this month's Memory log already has ${MEMORY_MAX_LOG_PARTS_V1} files`,
429
+ };
430
+ }
431
+
331
432
  /**
332
433
  * Forgets one fact by its recorded text, ignoring any marker on it.
333
434
  *
@@ -381,33 +482,36 @@ export class MemoryStore {
381
482
  const mine = [...tier.profile, ...tier.recent].filter(
382
483
  (fact) => fact.botId === this.owner.botId && matches(fact.text),
383
484
  );
485
+ // Both halves always run. Removing my own line and returning left another
486
+ // Bot's copy of the same fact being injected forever, under a tool result
487
+ // that said "Forgotten. The line is gone from …" — so a shared fact two
488
+ // Bots had recorded came back on the very next Turn.
489
+ const ownWrites: MemoryFileChangeV1[] = [];
490
+ let ownLast: MemoryWriteOutcomeV1 | undefined;
384
491
  if (mine.length > 0) {
385
- // The Bot owns every file the fact sits in, so removing the line is both
386
- // permitted and the honest record: nothing else recorded it.
387
- let last: MemoryWriteOutcomeV1 | undefined;
388
- // Each rewritten file is recorded as it lands, so a failure part-way
389
- // through still answers with the generations that already exist on disk.
390
- const written: MemoryFileChangeV1[] = [];
492
+ // Removing the line from my own shard is permitted and is the honest
493
+ // record for the copies I wrote. Each rewritten file is recorded as it
494
+ // lands, so a failure part-way through still answers with the
495
+ // generations that already exist on disk.
391
496
  for (const source of tier.sources) {
392
497
  if (source.botId !== this.owner.botId) continue;
393
498
  const path: WorkspacePathV1 = { root: request.root, path: source.path };
394
499
  const refusal = this.refuseForeignShard(path, request.writer);
395
- if (refusal) return { ...refusal, written };
500
+ if (refusal) return { ...refusal, written: ownWrites };
396
501
  const outcome = await this.rewrite(path, request.writer, (facts) => {
397
502
  const kept = facts.filter((fact) => !matches(fact.text));
398
503
  return kept.length === facts.length ? "unchanged" : kept;
399
504
  });
400
- if (outcome.status !== "ok") return { ...outcome, written };
505
+ if (outcome.status !== "ok") return { ...outcome, written: ownWrites };
401
506
  if (!outcome.duplicate) {
402
- last = outcome;
403
- written.push({
507
+ ownLast = outcome;
508
+ ownWrites.push({
404
509
  path: outcome.path,
405
510
  generationId: outcome.generationId,
406
511
  contentHash: outcome.contentHash,
407
512
  });
408
513
  }
409
514
  }
410
- if (last) return { ...last, written };
411
515
  }
412
516
 
413
517
  const elsewhere = [
@@ -419,13 +523,16 @@ export class MemoryStore {
419
523
  ),
420
524
  ];
421
525
  if (elsewhere.length === 0) {
526
+ // Nothing else holds it. My own removal, if there was one, is the whole
527
+ // answer.
528
+ if (ownLast) return { ...ownLast, written: ownWrites };
422
529
  return {
423
530
  status: "refused",
424
531
  reason: `no fact matching "${text}" is recorded in this tier`,
425
532
  };
426
533
  }
427
- const written: MemoryFileChangeV1[] = [];
428
- let last: MemoryWriteOutcomeV1 | undefined;
534
+ const written: MemoryFileChangeV1[] = [...ownWrites];
535
+ let last: MemoryWriteOutcomeV1 | undefined = ownLast;
429
536
  for (const recorded of elsewhere) {
430
537
  const retraction = await this.write({
431
538
  root: request.root,
@@ -433,6 +540,7 @@ export class MemoryStore {
433
540
  fact: memoryRetractionTextV1(recorded),
434
541
  writer: request.writer,
435
542
  at,
543
+ retraction: true,
436
544
  });
437
545
  if (retraction.status !== "ok") return { ...retraction, written };
438
546
  last = retraction;
@@ -541,6 +649,21 @@ export class MemoryStore {
541
649
  writer: WorkspaceWriterV1,
542
650
  current: WorkspaceGenerationV1 | undefined,
543
651
  ): Promise<MemoryWriteOutcomeV1> {
652
+ // The cap is checked here rather than only in `writeFile`, because this is
653
+ // the path every fact takes. A commit that sailed past it left a file the
654
+ // read then skipped, taking the whole tier out of injection with no tool
655
+ // able to trim it. A `forget` shrinking an already-oversized file is the
656
+ // one thing that must still get through: refusing it would make the
657
+ // condition unrecoverable.
658
+ if (
659
+ bytes.byteLength > MEMORY_MAX_FILE_BYTES &&
660
+ bytes.byteLength >= (current?.size ?? 0)
661
+ ) {
662
+ return {
663
+ status: "refused",
664
+ reason: `this Memory file would exceed ${MEMORY_MAX_FILE_BYTES} bytes`,
665
+ };
666
+ }
544
667
  const outcome = await this.#files.write({
545
668
  path,
546
669
  bytes,