@frockbot/plugin-memory 0.3.5 → 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 +5 -5
- package/src/agent.test.ts +4 -1
- package/src/agent.ts +153 -29
- package/src/documents.ts +90 -25
- package/src/embeddings.ts +6 -3
- package/src/indexer.test.ts +59 -1
- package/src/indexer.ts +0 -0
- package/src/render.ts +45 -3
- package/src/roots.ts +35 -4
- package/src/searcher.ts +30 -9
- package/src/store.test.ts +103 -0
- package/src/store.ts +165 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-memory",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
26
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
27
|
-
"@frockbot/secret-shapes": "0.3.
|
|
28
|
-
"@frockbot/workspace-store": "0.3.
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
type
|
|
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
|
-
/**
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
/**
|
|
311
|
-
|
|
312
|
-
|
|
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<
|
|
375
|
+
private async documents(): Promise<MemoryDocumentListingV1> {
|
|
318
376
|
const { own, user, projects } = await this.roots();
|
|
319
|
-
return
|
|
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
|
-
? `
|
|
612
|
-
: `
|
|
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:
|
|
682
|
-
|
|
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
|
-
? `
|
|
736
|
-
: `Forgotten
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
66
|
+
export async function readMemoryDocumentsV1(
|
|
52
67
|
reads: WorkspaceReadsV1,
|
|
53
68
|
root: WorkspaceMemoryRootV1,
|
|
54
|
-
): Promise<
|
|
69
|
+
): Promise<MemoryDocumentListingV1> {
|
|
55
70
|
const scope = memoryScopeOfRootV1(root);
|
|
56
71
|
const projectId = memoryProjectIdOfRootV1(root);
|
|
57
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
88
|
-
export async function
|
|
128
|
+
/** The documents of one tier, without saying whether the tier was read whole. */
|
|
129
|
+
export async function listMemoryDocumentsV1(
|
|
89
130
|
reads: WorkspaceReadsV1,
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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;
|
package/src/indexer.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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"
|
|
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
|
-
/**
|
|
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
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
314
|
-
|
|
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
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
//
|
|
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
|
-
|
|
403
|
-
|
|
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,
|