@frockbot/plugin-memory 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frockbot.json +15 -0
- package/package.json +35 -6
- package/src/agent.test.ts +590 -0
- package/src/agent.ts +1135 -0
- package/src/chunker.ts +104 -0
- package/src/documents.ts +97 -0
- package/src/embeddings.ts +23 -0
- package/src/facts.test.ts +126 -0
- package/src/facts.ts +258 -0
- package/src/index.ts +15 -0
- package/src/indexer.test.ts +91 -0
- package/src/indexer.ts +0 -0
- package/src/manifest.ts +3 -0
- package/src/projects.ts +85 -0
- package/src/render.test.ts +438 -0
- package/src/render.ts +478 -0
- package/src/roots.ts +158 -0
- package/src/searcher.ts +159 -0
- package/src/secrets.ts +45 -0
- package/src/store.test.ts +475 -0
- package/src/store.ts +568 -0
- package/src/testing.ts +98 -0
- package/src/types.ts +54 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/store.ts
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// The Memory Package's single writer, and the reader that merges shards.
|
|
2
|
+
//
|
|
3
|
+
// "The Memory Package is the single writer of Memory roots, and within a
|
|
4
|
+
// shared root each Bot's shard is written only on that Bot's behalf: it writes
|
|
5
|
+
// object storage, every write produces a generation recorded in the owning
|
|
6
|
+
// Durable Object, and the Workspace presents Memory roots read-only through
|
|
7
|
+
// the durable-root sync."
|
|
8
|
+
//
|
|
9
|
+
// Every byte in and out of this module goes through `WorkspaceFilesV1`. There
|
|
10
|
+
// is no second store, no cache that outlives a call, and — the point of the
|
|
11
|
+
// whole design — no Computer type anywhere on the path.
|
|
12
|
+
//
|
|
13
|
+
// HIBERNATION SEAM. "The Agent loop, Memory, Skills, Package composition, and
|
|
14
|
+
// Routines function correctly while the Computer is hibernated and do not wake
|
|
15
|
+
// it." Whoever supplies `WorkspaceFilesV1` owns that promise; in production it
|
|
16
|
+
// is the object-storage store of ADR 0013, so a read here is an object-storage
|
|
17
|
+
// read whether or not a Computer host is running.
|
|
18
|
+
import {
|
|
19
|
+
writerOwnsMemoryPathV1,
|
|
20
|
+
type WorkspaceEntryV1,
|
|
21
|
+
type WorkspaceFilesV1,
|
|
22
|
+
type WorkspaceGenerationV1,
|
|
23
|
+
type WorkspaceMemoryRootV1,
|
|
24
|
+
type WorkspacePathV1,
|
|
25
|
+
type WorkspaceReadsV1,
|
|
26
|
+
type WorkspaceWriterV1,
|
|
27
|
+
} from "@frockbot/kernel-contracts";
|
|
28
|
+
import {
|
|
29
|
+
memoryDayV1,
|
|
30
|
+
memoryFactBodyV1,
|
|
31
|
+
memoryFactKeyV1,
|
|
32
|
+
memoryRetractionTextV1,
|
|
33
|
+
parseMemoryFileV1,
|
|
34
|
+
renderMemoryFileV1,
|
|
35
|
+
resolveMemoryFactsV1,
|
|
36
|
+
sortMemoryFactsV1,
|
|
37
|
+
type MemoryFactV1,
|
|
38
|
+
type SourcedMemoryFactV1,
|
|
39
|
+
} from "./facts.js";
|
|
40
|
+
import {
|
|
41
|
+
memoryFileKindV1,
|
|
42
|
+
memoryFilePathV1,
|
|
43
|
+
memoryShardOfV1,
|
|
44
|
+
type MemoryOwnerV1,
|
|
45
|
+
type MemoryTierV1,
|
|
46
|
+
} from "./roots.js";
|
|
47
|
+
import { refuseMemorySecretV1 } from "./secrets.js";
|
|
48
|
+
|
|
49
|
+
/** Most `list` pages walked before enumeration stops. */
|
|
50
|
+
export const MEMORY_MAX_LIST_PAGES = 8;
|
|
51
|
+
/** Most Memory files read to render one tier. */
|
|
52
|
+
export const MEMORY_MAX_FILES_PER_TIER = 64;
|
|
53
|
+
/** The longest fact this Package will record. */
|
|
54
|
+
export const MEMORY_MAX_FACT_LENGTH = 2_000;
|
|
55
|
+
/** The largest Memory file this Package will rewrite. */
|
|
56
|
+
export const MEMORY_MAX_FILE_BYTES = 256 * 1024;
|
|
57
|
+
|
|
58
|
+
const encoder = new TextEncoder();
|
|
59
|
+
const decoder = new TextDecoder();
|
|
60
|
+
|
|
61
|
+
/** One Memory file the reader consumed, named by its exact generation. */
|
|
62
|
+
export interface MemorySourceV1 {
|
|
63
|
+
path: string;
|
|
64
|
+
kind: "profile" | "log";
|
|
65
|
+
botId: string;
|
|
66
|
+
generationId: string;
|
|
67
|
+
contentHash: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One tier, merged across every shard, newest first, retractions applied. */
|
|
71
|
+
export interface MemoryTierReadV1 {
|
|
72
|
+
root: WorkspaceMemoryRootV1;
|
|
73
|
+
profile: SourcedMemoryFactV1[];
|
|
74
|
+
recent: SourcedMemoryFactV1[];
|
|
75
|
+
sources: MemorySourceV1[];
|
|
76
|
+
/** Log facts held on disk beyond what `recent` carries, before any cap. */
|
|
77
|
+
logTotal: number;
|
|
78
|
+
/** Set when the tier could not be read in full; rendered as an omission. */
|
|
79
|
+
unavailable?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Set when the read completed but a declared bound cut it short, so some
|
|
82
|
+
* Memory on disk never reached this result.
|
|
83
|
+
*
|
|
84
|
+
* Distinct from `unavailable`: the tier *was* read, and the facts here are
|
|
85
|
+
* sound, so a write or a forget against it is still meaningful. What is not
|
|
86
|
+
* sound is treating this read as the whole tier — "an injection gap is
|
|
87
|
+
* visible in durable state rather than silently changing the Bot's
|
|
88
|
+
* behavior", so the renderer turns this into an omission on the tier's scope.
|
|
89
|
+
*/
|
|
90
|
+
omitted?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type MemoryWriteOutcomeV1 =
|
|
94
|
+
| {
|
|
95
|
+
status: "ok";
|
|
96
|
+
path: string;
|
|
97
|
+
generationId: string;
|
|
98
|
+
contentHash: string;
|
|
99
|
+
/** True when the fact was already recorded and nothing was written. */
|
|
100
|
+
duplicate: boolean;
|
|
101
|
+
}
|
|
102
|
+
| { status: "refused"; reason: string }
|
|
103
|
+
| { status: "conflict"; reason: string }
|
|
104
|
+
| { status: "unavailable"; reason: string };
|
|
105
|
+
|
|
106
|
+
/** One file a multi-file change actually rewrote, and the generation it made. */
|
|
107
|
+
export interface MemoryFileChangeV1 {
|
|
108
|
+
path: string;
|
|
109
|
+
generationId: string;
|
|
110
|
+
contentHash: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A forget, which may touch more than one file.
|
|
115
|
+
*
|
|
116
|
+
* `written` names every file this call actually rewrote, and it is present on
|
|
117
|
+
* a failure too: a forget that mutates the first of two files and then fails
|
|
118
|
+
* on the second has changed durable state, and "Failures are observable
|
|
119
|
+
* through durable state" means the caller has to be able to record what did
|
|
120
|
+
* change rather than reporting a clean failure the files contradict.
|
|
121
|
+
*/
|
|
122
|
+
export type MemoryForgetOutcomeV1 = MemoryWriteOutcomeV1 & {
|
|
123
|
+
retracted?: boolean;
|
|
124
|
+
written?: MemoryFileChangeV1[];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export interface MemoryStoreOptionsV1 {
|
|
128
|
+
files: WorkspaceFilesV1;
|
|
129
|
+
owner: MemoryOwnerV1;
|
|
130
|
+
/** Display names per Bot id, for the `[via …]` tag. Missing ids show the id. */
|
|
131
|
+
botNames?: Readonly<Record<string, string>>;
|
|
132
|
+
clock?: () => Date;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The deep module this Package is built on: three public operations over a
|
|
137
|
+
* Workspace file surface, and every rule about shards, dedupe, retraction, and
|
|
138
|
+
* secrets decided inside.
|
|
139
|
+
*/
|
|
140
|
+
export class MemoryStore {
|
|
141
|
+
readonly owner: MemoryOwnerV1;
|
|
142
|
+
#files: WorkspaceFilesV1;
|
|
143
|
+
#names: Readonly<Record<string, string>>;
|
|
144
|
+
#clock: () => Date;
|
|
145
|
+
|
|
146
|
+
constructor(options: MemoryStoreOptionsV1) {
|
|
147
|
+
this.#files = options.files;
|
|
148
|
+
this.owner = options.owner;
|
|
149
|
+
this.#names = options.botNames ?? {};
|
|
150
|
+
this.#clock = options.clock ?? (() => new Date());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The read-only projection the reader half needs, for callers that want it. */
|
|
154
|
+
get reads(): WorkspaceReadsV1 {
|
|
155
|
+
return this.#files;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private via(botId: string): string {
|
|
159
|
+
return this.#names[botId] ?? botId;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reads one whole tier: every shard, merged, newest fact winning, with a
|
|
164
|
+
* retraction in any shard suppressing the fact it names.
|
|
165
|
+
*/
|
|
166
|
+
async read(root: WorkspaceMemoryRootV1): Promise<MemoryTierReadV1> {
|
|
167
|
+
const result: MemoryTierReadV1 = {
|
|
168
|
+
root,
|
|
169
|
+
profile: [],
|
|
170
|
+
recent: [],
|
|
171
|
+
sources: [],
|
|
172
|
+
logTotal: 0,
|
|
173
|
+
};
|
|
174
|
+
const entries: WorkspaceEntryV1[] = [];
|
|
175
|
+
let cursor: string | undefined;
|
|
176
|
+
for (let page = 0; page < MEMORY_MAX_LIST_PAGES; page += 1) {
|
|
177
|
+
const outcome = await this.#files.list(
|
|
178
|
+
cursor === undefined ? { root } : { root, cursor },
|
|
179
|
+
);
|
|
180
|
+
if (outcome.status !== "ok") {
|
|
181
|
+
// "unavailable" is an ordinary answer: a tier that cannot be read
|
|
182
|
+
// contributes no facts and says so, rather than failing the Turn.
|
|
183
|
+
result.unavailable = outcome.reason;
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
entries.push(...outcome.entries);
|
|
187
|
+
if (!outcome.cursor) {
|
|
188
|
+
cursor = undefined;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
cursor = outcome.cursor;
|
|
192
|
+
}
|
|
193
|
+
// Every omission this read makes is kept, not the last one: two bounds can
|
|
194
|
+
// both bite, and a caller that refuses on an incomplete read needs to be
|
|
195
|
+
// told everything that was left out rather than whichever omission was
|
|
196
|
+
// written most recently.
|
|
197
|
+
const omissions: string[] = [];
|
|
198
|
+
if (cursor !== undefined) {
|
|
199
|
+
// The listing was still going when the page bound ran out. Some shards
|
|
200
|
+
// were never seen, so this read is not the whole tier and must say so.
|
|
201
|
+
omissions.push(
|
|
202
|
+
`the tier did not finish listing within ${MEMORY_MAX_LIST_PAGES} pages, so some shards were not read`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const profile: SourcedMemoryFactV1[] = [];
|
|
207
|
+
const log: SourcedMemoryFactV1[] = [];
|
|
208
|
+
const classifiedFiles = entries
|
|
209
|
+
.flatMap((entry) => {
|
|
210
|
+
const classified = memoryFileKindV1(root, entry.path.path);
|
|
211
|
+
return classified ? [{ entry, classified }] : [];
|
|
212
|
+
})
|
|
213
|
+
// Newest month last, so a merge that ties on day still has an order.
|
|
214
|
+
.sort((left, right) =>
|
|
215
|
+
left.entry.path.path.localeCompare(right.entry.path.path),
|
|
216
|
+
);
|
|
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),
|
|
236
|
+
);
|
|
237
|
+
if (classifiedFiles.length > files.length) {
|
|
238
|
+
const dropped = classifiedFiles.length - files.length;
|
|
239
|
+
omissions.push(
|
|
240
|
+
`${dropped} Memory file(s) beyond the ${MEMORY_MAX_FILES_PER_TIER}-file read bound were not read; the newest ${MEMORY_MAX_FILES_PER_TIER} were kept`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (omissions.length > 0) result.omitted = omissions.join("; ");
|
|
244
|
+
|
|
245
|
+
for (const { entry, classified } of files) {
|
|
246
|
+
if (entry.generation.size > MEMORY_MAX_FILE_BYTES) {
|
|
247
|
+
result.unavailable = `a Memory file exceeds ${MEMORY_MAX_FILE_BYTES} bytes`;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const read = await this.#files.read(entry.path);
|
|
251
|
+
if (read.status !== "ok") {
|
|
252
|
+
result.unavailable = read.reason;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
result.sources.push({
|
|
256
|
+
path: entry.path.path,
|
|
257
|
+
kind: classified.kind,
|
|
258
|
+
botId: classified.shard,
|
|
259
|
+
generationId: read.file.generation.generationId,
|
|
260
|
+
contentHash: read.file.generation.contentHash,
|
|
261
|
+
});
|
|
262
|
+
const parsed = parseMemoryFileV1(decoder.decode(read.file.bytes));
|
|
263
|
+
const sourced = parsed.map((fact) => ({
|
|
264
|
+
...fact,
|
|
265
|
+
botId: classified.shard,
|
|
266
|
+
via: this.via(classified.shard),
|
|
267
|
+
kind: classified.kind,
|
|
268
|
+
generationId: read.file.generation.generationId,
|
|
269
|
+
}));
|
|
270
|
+
if (classified.kind === "profile") profile.push(...sourced);
|
|
271
|
+
else log.push(...sourced);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Retractions cross files inside a tier: a `[forgotten]` line in the log
|
|
275
|
+
// suppresses the profile fact it names, which is what "newest wins" means
|
|
276
|
+
// once forgetting exists at all.
|
|
277
|
+
const resolved = resolveMemoryFactsV1([...profile, ...log]);
|
|
278
|
+
result.profile = sortMemoryFactsV1(
|
|
279
|
+
resolved.filter((fact) => fact.kind === "profile"),
|
|
280
|
+
);
|
|
281
|
+
result.recent = sortMemoryFactsV1(
|
|
282
|
+
resolved.filter((fact) => fact.kind === "log"),
|
|
283
|
+
);
|
|
284
|
+
result.logTotal = result.recent.length;
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Records one fact in this Bot's own shard of a root.
|
|
290
|
+
*
|
|
291
|
+
* Refusals come first and are values, not throws: a secret-shaped fact, a
|
|
292
|
+
* fact this writer may not place at this path, an oversized fact. A fact
|
|
293
|
+
* already recorded is answered as a duplicate with no write at all, which is
|
|
294
|
+
* both GrokBot's dedupe and what makes a resumed Turn free.
|
|
295
|
+
*/
|
|
296
|
+
async write(request: {
|
|
297
|
+
root: WorkspaceMemoryRootV1;
|
|
298
|
+
tier: MemoryTierV1;
|
|
299
|
+
fact: string;
|
|
300
|
+
writer: WorkspaceWriterV1;
|
|
301
|
+
at?: Date;
|
|
302
|
+
}): Promise<MemoryWriteOutcomeV1> {
|
|
303
|
+
const text = request.fact.trim();
|
|
304
|
+
if (!text || text.length > MEMORY_MAX_FACT_LENGTH) {
|
|
305
|
+
return {
|
|
306
|
+
status: "refused",
|
|
307
|
+
reason: `a fact must be between 1 and ${MEMORY_MAX_FACT_LENGTH} characters`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const secret = refuseMemorySecretV1(text);
|
|
311
|
+
if (secret) return { status: "refused", reason: secret.reason };
|
|
312
|
+
const at = request.at ?? this.#clock();
|
|
313
|
+
const path = memoryFilePathV1(
|
|
314
|
+
request.root,
|
|
315
|
+
this.owner.botId,
|
|
316
|
+
request.tier,
|
|
317
|
+
at,
|
|
318
|
+
);
|
|
319
|
+
const refusal = this.refuseForeignShard(path, request.writer);
|
|
320
|
+
if (refusal) return refusal;
|
|
321
|
+
const line: MemoryFactV1 = { date: memoryDayV1(at), text };
|
|
322
|
+
return this.rewrite(path, request.writer, (facts) => {
|
|
323
|
+
const key = memoryFactKeyV1(text);
|
|
324
|
+
if (facts.some((fact) => memoryFactKeyV1(fact.text) === key)) {
|
|
325
|
+
return "unchanged";
|
|
326
|
+
}
|
|
327
|
+
return [...facts, line];
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Forgets one fact by its recorded text, ignoring any marker on it.
|
|
333
|
+
*
|
|
334
|
+
* In this Bot's own shard the line is removed. In a shared tier a fact
|
|
335
|
+
* another Bot recorded is *not* edited — "Never edit another assistant's
|
|
336
|
+
* shard" — so the forget is recorded as a retraction in this Bot's own log,
|
|
337
|
+
* and newest-wins does the rest. That is the same correction mechanism
|
|
338
|
+
* GrokBot documents for shared facts, applied to removal.
|
|
339
|
+
*
|
|
340
|
+
* MARKERS. The match is on the marker-stripped body, so `forget("x")`
|
|
341
|
+
* removes `x`, `[note] x` and `[episode] x` alike, and a caller who does
|
|
342
|
+
* pass `[note] x` still matches because the input is stripped too. GrokBot's
|
|
343
|
+
* rule is "forget matches the exact recorded text" (§2.2), and a literal
|
|
344
|
+
* reading of it would make a User unable to forget a note whose `[note] `
|
|
345
|
+
* prefix they were never shown — the marker is a tier the *host* wrote, not
|
|
346
|
+
* a word the User said. The asymmetry with `write`, which still dedupes on
|
|
347
|
+
* the full text, is deliberate and in the safe direction: writing twice
|
|
348
|
+
* keeps both records, forgetting once removes both.
|
|
349
|
+
*
|
|
350
|
+
* A shared fact is retracted once per *recorded* text, because a retraction
|
|
351
|
+
* suppresses the exact text it names: forgetting `x` when another Bot holds
|
|
352
|
+
* `[note] x` writes `[forgotten] [note] x`.
|
|
353
|
+
*/
|
|
354
|
+
async forget(request: {
|
|
355
|
+
root: WorkspaceMemoryRootV1;
|
|
356
|
+
fact: string;
|
|
357
|
+
writer: WorkspaceWriterV1;
|
|
358
|
+
at?: Date;
|
|
359
|
+
}): Promise<MemoryForgetOutcomeV1> {
|
|
360
|
+
const text = request.fact.trim();
|
|
361
|
+
if (!text) return { status: "refused", reason: "a fact text is required" };
|
|
362
|
+
const at = request.at ?? this.#clock();
|
|
363
|
+
const key = memoryFactKeyV1(memoryFactBodyV1(text));
|
|
364
|
+
const matches = (candidate: string): boolean =>
|
|
365
|
+
memoryFactKeyV1(memoryFactBodyV1(candidate)) === key;
|
|
366
|
+
const tier = await this.read(request.root);
|
|
367
|
+
if (tier.unavailable) {
|
|
368
|
+
return { status: "unavailable", reason: tier.unavailable };
|
|
369
|
+
}
|
|
370
|
+
if (tier.omitted) {
|
|
371
|
+
// A forget decided on part of a tier is a lie: the fact may sit in a
|
|
372
|
+
// file the read bound cut, and answering "ok" would leave it on disk,
|
|
373
|
+
// injected on the next Turn, with the User told it was forgotten. An
|
|
374
|
+
// incomplete read is therefore an incomplete answer.
|
|
375
|
+
return {
|
|
376
|
+
status: "unavailable",
|
|
377
|
+
reason: `the tier could not be read in full, so a forget cannot be complete: ${tier.omitted}`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const mine = [...tier.profile, ...tier.recent].filter(
|
|
382
|
+
(fact) => fact.botId === this.owner.botId && matches(fact.text),
|
|
383
|
+
);
|
|
384
|
+
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[] = [];
|
|
391
|
+
for (const source of tier.sources) {
|
|
392
|
+
if (source.botId !== this.owner.botId) continue;
|
|
393
|
+
const path: WorkspacePathV1 = { root: request.root, path: source.path };
|
|
394
|
+
const refusal = this.refuseForeignShard(path, request.writer);
|
|
395
|
+
if (refusal) return { ...refusal, written };
|
|
396
|
+
const outcome = await this.rewrite(path, request.writer, (facts) => {
|
|
397
|
+
const kept = facts.filter((fact) => !matches(fact.text));
|
|
398
|
+
return kept.length === facts.length ? "unchanged" : kept;
|
|
399
|
+
});
|
|
400
|
+
if (outcome.status !== "ok") return { ...outcome, written };
|
|
401
|
+
if (!outcome.duplicate) {
|
|
402
|
+
last = outcome;
|
|
403
|
+
written.push({
|
|
404
|
+
path: outcome.path,
|
|
405
|
+
generationId: outcome.generationId,
|
|
406
|
+
contentHash: outcome.contentHash,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (last) return { ...last, written };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const elsewhere = [
|
|
414
|
+
...new Set(
|
|
415
|
+
[...tier.profile, ...tier.recent]
|
|
416
|
+
.filter((fact) => fact.botId !== this.owner.botId)
|
|
417
|
+
.filter((fact) => matches(fact.text))
|
|
418
|
+
.map((fact) => fact.text),
|
|
419
|
+
),
|
|
420
|
+
];
|
|
421
|
+
if (elsewhere.length === 0) {
|
|
422
|
+
return {
|
|
423
|
+
status: "refused",
|
|
424
|
+
reason: `no fact matching "${text}" is recorded in this tier`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const written: MemoryFileChangeV1[] = [];
|
|
428
|
+
let last: MemoryWriteOutcomeV1 | undefined;
|
|
429
|
+
for (const recorded of elsewhere) {
|
|
430
|
+
const retraction = await this.write({
|
|
431
|
+
root: request.root,
|
|
432
|
+
tier: "log",
|
|
433
|
+
fact: memoryRetractionTextV1(recorded),
|
|
434
|
+
writer: request.writer,
|
|
435
|
+
at,
|
|
436
|
+
});
|
|
437
|
+
if (retraction.status !== "ok") return { ...retraction, written };
|
|
438
|
+
last = retraction;
|
|
439
|
+
if (!retraction.duplicate) {
|
|
440
|
+
written.push({
|
|
441
|
+
path: retraction.path,
|
|
442
|
+
generationId: retraction.generationId,
|
|
443
|
+
contentHash: retraction.contentHash,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!last) {
|
|
448
|
+
return {
|
|
449
|
+
status: "refused",
|
|
450
|
+
reason: `no fact matching "${text}" is recorded in this tier`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
return { ...last, retracted: true, written };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Writes one arbitrary Memory file this Bot owns — the Project descriptor,
|
|
458
|
+
* and nothing else today. It goes through the same shard guard and the same
|
|
459
|
+
* conditional write as a fact, because the constitution's rule is about the
|
|
460
|
+
* root, not about what the bytes mean.
|
|
461
|
+
*/
|
|
462
|
+
async writeFile(request: {
|
|
463
|
+
path: WorkspacePathV1;
|
|
464
|
+
text: string;
|
|
465
|
+
writer: WorkspaceWriterV1;
|
|
466
|
+
}): Promise<MemoryWriteOutcomeV1> {
|
|
467
|
+
const bytes = encoder.encode(request.text);
|
|
468
|
+
if (bytes.byteLength > MEMORY_MAX_FILE_BYTES) {
|
|
469
|
+
return { status: "refused", reason: "the Memory file is too large" };
|
|
470
|
+
}
|
|
471
|
+
const secret = refuseMemorySecretV1(request.text);
|
|
472
|
+
if (secret) return { status: "refused", reason: secret.reason };
|
|
473
|
+
if (!writerOwnsMemoryPathV1(request.path, request.writer)) {
|
|
474
|
+
return {
|
|
475
|
+
status: "refused",
|
|
476
|
+
reason: `this writer may not write "${request.path.path}" in this Memory root`,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const existing = await this.#files.stat(request.path);
|
|
480
|
+
if (existing.status !== "ok" && existing.status !== "not-found") {
|
|
481
|
+
return { status: existing.status, reason: existing.reason };
|
|
482
|
+
}
|
|
483
|
+
return this.commit(
|
|
484
|
+
request.path,
|
|
485
|
+
bytes,
|
|
486
|
+
request.writer,
|
|
487
|
+
existing.status === "ok" ? existing.entry.generation : undefined,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private refuseForeignShard(
|
|
492
|
+
path: WorkspacePathV1,
|
|
493
|
+
writer: WorkspaceWriterV1,
|
|
494
|
+
): MemoryWriteOutcomeV1 | undefined {
|
|
495
|
+
// The contract decides ownership; this Package never re-decides it. The
|
|
496
|
+
// store refuses the same write again, so this is an early, legible answer
|
|
497
|
+
// rather than the boundary itself.
|
|
498
|
+
if (writerOwnsMemoryPathV1(path, writer)) return undefined;
|
|
499
|
+
return {
|
|
500
|
+
status: "refused",
|
|
501
|
+
reason: `only the Bot that owns this Memory shard may write "${path.path}"`,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private async rewrite(
|
|
506
|
+
path: WorkspacePathV1,
|
|
507
|
+
writer: WorkspaceWriterV1,
|
|
508
|
+
change: (facts: MemoryFactV1[]) => MemoryFactV1[] | "unchanged",
|
|
509
|
+
): Promise<MemoryWriteOutcomeV1> {
|
|
510
|
+
const existing = await this.#files.read(path);
|
|
511
|
+
if (existing.status !== "ok" && existing.status !== "not-found") {
|
|
512
|
+
return { status: existing.status, reason: existing.reason };
|
|
513
|
+
}
|
|
514
|
+
const current: WorkspaceGenerationV1 | undefined =
|
|
515
|
+
existing.status === "ok" ? existing.file.generation : undefined;
|
|
516
|
+
const facts =
|
|
517
|
+
existing.status === "ok"
|
|
518
|
+
? parseMemoryFileV1(decoder.decode(existing.file.bytes))
|
|
519
|
+
: [];
|
|
520
|
+
const next = change(facts);
|
|
521
|
+
if (next === "unchanged") {
|
|
522
|
+
return {
|
|
523
|
+
status: "ok",
|
|
524
|
+
path: path.path,
|
|
525
|
+
generationId: current?.generationId ?? "",
|
|
526
|
+
contentHash: current?.contentHash ?? "",
|
|
527
|
+
duplicate: true,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
return this.commit(
|
|
531
|
+
path,
|
|
532
|
+
encoder.encode(renderMemoryFileV1(next)),
|
|
533
|
+
writer,
|
|
534
|
+
current,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private async commit(
|
|
539
|
+
path: WorkspacePathV1,
|
|
540
|
+
bytes: Uint8Array,
|
|
541
|
+
writer: WorkspaceWriterV1,
|
|
542
|
+
current: WorkspaceGenerationV1 | undefined,
|
|
543
|
+
): Promise<MemoryWriteOutcomeV1> {
|
|
544
|
+
const outcome = await this.#files.write({
|
|
545
|
+
path,
|
|
546
|
+
bytes,
|
|
547
|
+
writer,
|
|
548
|
+
expectedGenerationId: current?.generationId ?? null,
|
|
549
|
+
mediaType: "text/markdown; charset=utf-8",
|
|
550
|
+
});
|
|
551
|
+
if (outcome.status === "ok") {
|
|
552
|
+
return {
|
|
553
|
+
status: "ok",
|
|
554
|
+
path: path.path,
|
|
555
|
+
generationId: outcome.generation.generationId,
|
|
556
|
+
contentHash: outcome.generation.contentHash,
|
|
557
|
+
duplicate: false,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
if (outcome.status === "conflict") {
|
|
561
|
+
return { status: "conflict", reason: outcome.reason };
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
status: outcome.status === "refused" ? "refused" : "unavailable",
|
|
565
|
+
reason: outcome.reason,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Fixtures for the Memory Package's tests, and for anything that needs a
|
|
2
|
+
// Memory host without a Durable Object.
|
|
3
|
+
//
|
|
4
|
+
// The Workspace half is deliberately *not* faked here: the tests build a real
|
|
5
|
+
// `createObjectWorkspaceFilesV1` over the in-memory bucket and generation
|
|
6
|
+
// ledger from `@frockbot/workspace-store/testing`, so what they prove is the
|
|
7
|
+
// production store's behaviour and not a double's. What this module supplies
|
|
8
|
+
// is the two seams that genuinely have no implementation in this Package: the
|
|
9
|
+
// durable Project authority, and a deterministic clock.
|
|
10
|
+
import { createObjectWorkspaceFilesV1 } from "@frockbot/workspace-store";
|
|
11
|
+
import {
|
|
12
|
+
createInMemoryObjectBucketV1,
|
|
13
|
+
createInMemoryWorkspaceGenerationsV1,
|
|
14
|
+
} from "@frockbot/workspace-store/testing";
|
|
15
|
+
import type { WorkspaceFilesV1 } from "@frockbot/kernel-contracts";
|
|
16
|
+
import type { MemoryProjectsOutcomeV1, MemoryProjectsV1 } from "./projects.js";
|
|
17
|
+
import type { MemoryProjectV1 } from "./render.js";
|
|
18
|
+
import { MemoryStore } from "./store.js";
|
|
19
|
+
import type { MemoryOwnerV1 } from "./roots.js";
|
|
20
|
+
|
|
21
|
+
/** The Memory surface, over the same store production uses. */
|
|
22
|
+
export function createTestMemoryFilesV1(options: {
|
|
23
|
+
userId: string;
|
|
24
|
+
clock?: () => Date;
|
|
25
|
+
}): WorkspaceFilesV1 {
|
|
26
|
+
return createObjectWorkspaceFilesV1({
|
|
27
|
+
bucket: createInMemoryObjectBucketV1(options.clock),
|
|
28
|
+
generations: createInMemoryWorkspaceGenerationsV1(options.clock),
|
|
29
|
+
owner: { userId: options.userId },
|
|
30
|
+
surface: "memory",
|
|
31
|
+
...(options.clock ? { clock: options.clock } : {}),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A Project authority in memory. Production's lives in the User Durable
|
|
37
|
+
* Object; this one models the same contract, including create-is-join.
|
|
38
|
+
*/
|
|
39
|
+
export function createInMemoryMemoryProjectsV1(
|
|
40
|
+
seed: MemoryProjectV1[] = [],
|
|
41
|
+
): MemoryProjectsV1 & { known(): MemoryProjectV1[] } {
|
|
42
|
+
const known = new Map<string, MemoryProjectV1>(
|
|
43
|
+
seed.map((project) => [project.projectId, project]),
|
|
44
|
+
);
|
|
45
|
+
const joined = new Set<string>(seed.map((project) => project.projectId));
|
|
46
|
+
const list = (): MemoryProjectV1[] =>
|
|
47
|
+
[...joined]
|
|
48
|
+
.flatMap((projectId) => {
|
|
49
|
+
const project = known.get(projectId);
|
|
50
|
+
return project ? [project] : [];
|
|
51
|
+
})
|
|
52
|
+
.sort((left, right) => left.projectId.localeCompare(right.projectId));
|
|
53
|
+
const ok = (): MemoryProjectsOutcomeV1 => ({ status: "ok", joined: list() });
|
|
54
|
+
return {
|
|
55
|
+
known: () => [...known.values()],
|
|
56
|
+
joined: () => Promise.resolve(list()),
|
|
57
|
+
create: (project) => {
|
|
58
|
+
if (!known.has(project.projectId)) known.set(project.projectId, project);
|
|
59
|
+
joined.add(project.projectId);
|
|
60
|
+
return Promise.resolve(ok());
|
|
61
|
+
},
|
|
62
|
+
join: (projectId) => {
|
|
63
|
+
if (!known.has(projectId)) {
|
|
64
|
+
return Promise.resolve({
|
|
65
|
+
status: "refused",
|
|
66
|
+
reason: `no Project "${projectId}" exists`,
|
|
67
|
+
} satisfies MemoryProjectsOutcomeV1);
|
|
68
|
+
}
|
|
69
|
+
joined.add(projectId);
|
|
70
|
+
return Promise.resolve(ok());
|
|
71
|
+
},
|
|
72
|
+
leave: (projectId) => {
|
|
73
|
+
joined.delete(projectId);
|
|
74
|
+
return Promise.resolve(ok());
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A `MemoryStore` over the test Workspace surface, with a frozen clock. */
|
|
80
|
+
export function createTestMemoryStoreV1(options: {
|
|
81
|
+
owner: MemoryOwnerV1;
|
|
82
|
+
botNames?: Record<string, string>;
|
|
83
|
+
at?: Date;
|
|
84
|
+
files?: WorkspaceFilesV1;
|
|
85
|
+
}): MemoryStore {
|
|
86
|
+
const clock = options.at ? () => options.at as Date : undefined;
|
|
87
|
+
return new MemoryStore({
|
|
88
|
+
files:
|
|
89
|
+
options.files ??
|
|
90
|
+
createTestMemoryFilesV1({
|
|
91
|
+
userId: options.owner.userId,
|
|
92
|
+
...(clock ? { clock } : {}),
|
|
93
|
+
}),
|
|
94
|
+
owner: options.owner,
|
|
95
|
+
...(options.botNames ? { botNames: options.botNames } : {}),
|
|
96
|
+
...(clock ? { clock } : {}),
|
|
97
|
+
});
|
|
98
|
+
}
|