@frockbot/plugin-memory 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent.ts ADDED
@@ -0,0 +1,1135 @@
1
+ // The Memory runtime Contribution.
2
+ //
3
+ // Four responsibilities, and no authority of its own:
4
+ //
5
+ // 1. Render the Memory block into the system prompt once per admitted Turn,
6
+ // in GrokBot's shape and order (user → project → own).
7
+ // 2. Record what it injected. "the session event log records exactly what was
8
+ // injected, so an injection gap is visible in durable state rather than
9
+ // silently changing the Bot's behavior" — `memory/injected` names every
10
+ // Memory file generation the render read, every fact that reached the
11
+ // prompt, and every tier a cap or a failure cut short.
12
+ // 3. Offer the mutation surface GrokBot exposes as `update_state target
13
+ // memory`: `memory_write`, `memory_forget`, and the Project membership
14
+ // trio `project_create` / `project_join` / `project_leave`. Each records
15
+ // intent with an effect identifier *before* the effect runs.
16
+ // 4. Keep the derived index in step with the files, and offer
17
+ // `memory_rebuild_index` so the derived half can always be thrown away.
18
+ //
19
+ // It never calls the Computer interface and never wakes a Computer; the seam
20
+ // is documented on `MemoryStore`.
21
+ import type {
22
+ Session,
23
+ ToolDefinition,
24
+ ToolExecutionContext,
25
+ ToolExecutionResult,
26
+ WorkspaceMemoryRootV1,
27
+ WorkspaceWriterV1,
28
+ MemoryScopeNameV1,
29
+ } from "@frockbot/kernel-contracts";
30
+ // Merges the Agent loop's event declarations into the cordis Context type.
31
+ import type {} from "@frockbot/kernel-agent-loop/agent";
32
+ import type { Plugin } from "cordis";
33
+ import { createMemoryEmbedder } from "./embeddings.js";
34
+ import {
35
+ listAllMemoryDocumentsV1,
36
+ type MemoryDocumentV1,
37
+ } from "./documents.js";
38
+ import {
39
+ buildMemoryIndexV1,
40
+ emptyMemoryIndexV1,
41
+ embedMemoryIndexV1,
42
+ updateMemoryIndexV1,
43
+ type MemoryIndexV1,
44
+ } from "./indexer.js";
45
+ import {
46
+ parseProjectDocumentV1,
47
+ projectDocumentPathV1,
48
+ renderProjectDocumentV1,
49
+ type MemoryProjectsV1,
50
+ } from "./projects.js";
51
+ export type {
52
+ MemoryProjectsV1,
53
+ MemoryProjectsOutcomeV1,
54
+ MemoryProjectV1,
55
+ } from "./projects.js";
56
+ import { memoryDayV1, renderMemoryMarkerV1 } from "./facts.js";
57
+ import {
58
+ MEMORY_NOTE_TTL_DAYS,
59
+ renderMemoryInjectionV1,
60
+ type MemoryInjectionV1,
61
+ type MemoryProjectTierV1,
62
+ type MemoryProjectV1,
63
+ } from "./render.js";
64
+ import {
65
+ botMemoryRootV1,
66
+ isMemoryProjectIdV1,
67
+ memoryScopeRootV1,
68
+ projectMemoryRootV1,
69
+ userMemoryRootV1,
70
+ type MemoryOwnerV1,
71
+ type MemoryTierV1,
72
+ } from "./roots.js";
73
+ import { formatMemoryResultsV1, searchMemoryV1 } from "./searcher.js";
74
+ import { MemoryStore, MEMORY_MAX_FACT_LENGTH } from "./store.js";
75
+ import type {
76
+ EmbedMemory,
77
+ MemoryAiBinding,
78
+ MemoryVectorIndex,
79
+ } from "./types.js";
80
+
81
+ /** Bot write provenance: the Session and Turn that recorded a fact. */
82
+ export interface MemoryWriterIdentityV1 {
83
+ sessionId: string;
84
+ turnId: string;
85
+ runId: string;
86
+ }
87
+
88
+ /**
89
+ * The host seam this Package receives, supplied by the Durable Object for one
90
+ * admitted Turn. `files` and `writer` are present only when the Turn may
91
+ * write, so a Bot cannot change Memory outside a Turn whose Session and Turn
92
+ * its provenance can name.
93
+ */
94
+ export interface MemoryRuntimeHostV1 {
95
+ owner: MemoryOwnerV1;
96
+ store: MemoryStore;
97
+ writer?: MemoryWriterIdentityV1;
98
+ projects?: MemoryProjectsV1;
99
+ /** Optional derived-index bindings; Memory is complete without them. */
100
+ vectorize?: MemoryVectorIndex;
101
+ embed?: EmbedMemory;
102
+ ai?: MemoryAiBinding;
103
+ embeddingModel?: string;
104
+ /**
105
+ * The Turn's clock, for the note-fade cutoff only. Defaults to the wall
106
+ * clock; injected by tests so a fade can be driven without waiting a
107
+ * fortnight. Nothing else in this Package reads it — `MemoryStore` keeps its
108
+ * own, because a write's date is decided where the write happens.
109
+ */
110
+ clock?: () => Date;
111
+ }
112
+
113
+ export async function sha256HexV1(text: string): Promise<string> {
114
+ const digest = await crypto.subtle.digest(
115
+ "SHA-256",
116
+ new TextEncoder().encode(text),
117
+ );
118
+ return [...new Uint8Array(digest)]
119
+ .map((byte) => byte.toString(16).padStart(2, "0"))
120
+ .join("");
121
+ }
122
+
123
+ /** The turn and step a Memory effect is recorded under. */
124
+ export interface MemoryTurnPositionV1 {
125
+ turn: number;
126
+ step: number;
127
+ }
128
+
129
+ /**
130
+ * The open step a Memory event belongs to. The session log is the
131
+ * reconstruction surface, so an event without its turn and step would not
132
+ * replay in place.
133
+ */
134
+ export function openMemoryTurnPositionV1(
135
+ session: Session,
136
+ ): MemoryTurnPositionV1 {
137
+ const started = session.events.findLast(
138
+ (event) => event.type === "step/start",
139
+ );
140
+ const ended = session.events.findLast((event) => event.type === "step/end");
141
+ if (started?.type !== "step/start") {
142
+ throw new Error("a Memory effect has no open step to record against");
143
+ }
144
+ if (
145
+ ended?.type === "step/end" &&
146
+ ended.turn === started.turn &&
147
+ ended.step === started.step
148
+ ) {
149
+ throw new Error("a Memory effect has no open step to record against");
150
+ }
151
+ return { turn: started.turn, step: started.step };
152
+ }
153
+
154
+ /**
155
+ * The Turn-scoped Memory projection. Deep module, small surface: `refresh` is
156
+ * the only way it changes, and `current` is what the prompt and the search
157
+ * tool both read, so those two can never disagree about what this Turn saw.
158
+ */
159
+ export class MemoryProjection {
160
+ #host: MemoryRuntimeHostV1;
161
+ #injection: MemoryInjectionV1 = {
162
+ text: "",
163
+ facts: [],
164
+ omissions: [],
165
+ faded: [],
166
+ };
167
+ #index: MemoryIndexV1 = emptyMemoryIndexV1();
168
+ #turn: number | undefined;
169
+
170
+ constructor(host: MemoryRuntimeHostV1) {
171
+ this.#host = host;
172
+ }
173
+
174
+ current(): MemoryInjectionV1 {
175
+ return this.#injection;
176
+ }
177
+
178
+ index(): MemoryIndexV1 {
179
+ return this.#index;
180
+ }
181
+
182
+ loadedTurn(): number | undefined {
183
+ return this.#turn;
184
+ }
185
+
186
+ /**
187
+ * Every Memory root this Bot can see this Turn, in tier order.
188
+ *
189
+ * A Project authority that cannot be reached yields no Projects and says so
190
+ * — `unavailable` is an ordinary answer across a Durable Object seam, and a
191
+ * Turn must not fail because membership was briefly unreadable. The gap is
192
+ * carried into `memory/injected` as an omission rather than passing for "no
193
+ * Projects joined".
194
+ */
195
+ async roots(): Promise<{
196
+ own: WorkspaceMemoryRootV1;
197
+ user: WorkspaceMemoryRootV1;
198
+ projects: MemoryProjectV1[];
199
+ unavailable?: string;
200
+ }> {
201
+ const owner = this.#host.owner;
202
+ const roots = {
203
+ own: botMemoryRootV1(owner),
204
+ user: userMemoryRootV1(owner),
205
+ };
206
+ if (!this.#host.projects) return { ...roots, projects: [] };
207
+ try {
208
+ return { ...roots, projects: await this.#host.projects.joined() };
209
+ } catch (error) {
210
+ return {
211
+ ...roots,
212
+ projects: [],
213
+ unavailable: `Project membership could not be read: ${
214
+ error instanceof Error ? error.message : String(error)
215
+ }`,
216
+ };
217
+ }
218
+ }
219
+
220
+ /** Reads every tier, renders the block, and records the injection. */
221
+ async refresh(turn: number, session: Session): Promise<MemoryInjectionV1> {
222
+ const store = this.#host.store;
223
+ const owner = this.#host.owner;
224
+ const { own, user, projects, unavailable } = await this.roots();
225
+ const ownTier = await store.read(own);
226
+ const userTier = await store.read(user);
227
+ const projectTiers: MemoryProjectTierV1[] = [];
228
+ for (const project of projects) {
229
+ projectTiers.push({
230
+ project,
231
+ tier: await store.read(projectMemoryRootV1(owner, project.projectId)),
232
+ });
233
+ }
234
+ // The fade's cutoff is computed once, here, and recorded below. A render
235
+ // that decided "today" for itself would not replay: "The durable session
236
+ // event log reconstructs … every exact normalized model request, given the
237
+ // Composition generation and Memory generations it records."
238
+ const now = this.#host.clock?.() ?? new Date();
239
+ const noteCutoff = memoryDayV1(
240
+ new Date(now.getTime() - MEMORY_NOTE_TTL_DAYS * 24 * 60 * 60 * 1_000),
241
+ );
242
+ this.#injection = renderMemoryInjectionV1({
243
+ botId: owner.botId,
244
+ own: ownTier,
245
+ user: userTier,
246
+ projects: projectTiers,
247
+ joined: projects,
248
+ noteCutoff,
249
+ });
250
+ if (unavailable) {
251
+ this.#injection.omissions.push({ scope: "project", reason: unavailable });
252
+ }
253
+ this.#turn = turn;
254
+
255
+ const sources = [
256
+ ...ownTier.sources.map((source) => ({
257
+ source,
258
+ scope: "bot" as const,
259
+ projectId: "",
260
+ })),
261
+ ...userTier.sources.map((source) => ({
262
+ source,
263
+ scope: "user" as const,
264
+ projectId: "",
265
+ })),
266
+ ...projectTiers.flatMap((entry) =>
267
+ entry.tier.sources.map((source) => ({
268
+ source,
269
+ scope: "project" as const,
270
+ projectId: entry.project.projectId,
271
+ })),
272
+ ),
273
+ ];
274
+ session.append({
275
+ type: "memory/injected",
276
+ turn,
277
+ sources: sources.map(({ source, scope, projectId }) => ({
278
+ scope,
279
+ projectId,
280
+ path: source.path,
281
+ generationId: source.generationId,
282
+ contentHash: source.contentHash,
283
+ })),
284
+ facts: this.#injection.facts,
285
+ omissions: this.#injection.omissions,
286
+ faded: this.#injection.faded,
287
+ noteCutoff,
288
+ noteTtlDays: MEMORY_NOTE_TTL_DAYS,
289
+ });
290
+ await session.flush();
291
+
292
+ // The index is derived from the same documents the render just read, so it
293
+ // is refreshed on the same boundary and never outlives the Turn's view.
294
+ await this.reindex();
295
+ return this.#injection;
296
+ }
297
+
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);
302
+ this.#index = update.index;
303
+ await this.embed();
304
+ return {
305
+ documentsChanged: update.documentsChanged,
306
+ chunksTotal: update.chunksTotal,
307
+ };
308
+ }
309
+
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());
313
+ await this.embed();
314
+ return { chunksTotal: this.#index.chunks.length };
315
+ }
316
+
317
+ private async documents(): Promise<MemoryDocumentV1[]> {
318
+ const { own, user, projects } = await this.roots();
319
+ return listAllMemoryDocumentsV1(this.#host.store.reads, [
320
+ own,
321
+ user,
322
+ ...projects.map((project) =>
323
+ projectMemoryRootV1(this.#host.owner, project.projectId),
324
+ ),
325
+ ]);
326
+ }
327
+
328
+ private async embed(): Promise<void> {
329
+ const embed = memoryEmbedderV1(this.#host);
330
+ if (!embed || !this.#host.vectorize) return;
331
+ try {
332
+ await embedMemoryIndexV1(this.#index, embed, this.#host.vectorize);
333
+ } catch (error) {
334
+ // Embeddings are derived from the files and rebuildable; losing them
335
+ // costs recall quality, never a fact.
336
+ console.error("[memory] embedding the derived index failed", error);
337
+ }
338
+ }
339
+
340
+ /** Drops the projection, so the next Turn reloads it rather than reusing it. */
341
+ invalidate(): void {
342
+ this.#injection = { text: "", facts: [], omissions: [], faded: [] };
343
+ this.#index = emptyMemoryIndexV1();
344
+ this.#turn = undefined;
345
+ }
346
+ }
347
+
348
+ function memoryEmbedderV1(host: MemoryRuntimeHostV1): EmbedMemory | undefined {
349
+ if (host.embed) return host.embed;
350
+ if (host.ai) return createMemoryEmbedder(host.ai, host.embeddingModel);
351
+ return undefined;
352
+ }
353
+
354
+ const SCOPE_ENUM = ["bot", "user", "project"] as const;
355
+ const TIER_ENUM = ["profile", "log", "note"] as const;
356
+
357
+ const MEMORY_WRITE_SCHEMA = {
358
+ type: "object",
359
+ properties: {
360
+ scope: {
361
+ type: "string",
362
+ enum: [...SCOPE_ENUM],
363
+ description:
364
+ "bot = your own memory (the default and the most specific); user = shared with every Bot of this User; project = shared with the Bots in one Project you have joined.",
365
+ },
366
+ project: {
367
+ type: "string",
368
+ description: "The Project slug. Required when scope is project.",
369
+ },
370
+ tier: {
371
+ type: "string",
372
+ enum: [...TIER_ENUM],
373
+ description:
374
+ "profile = a foundational fact kept in mind every turn; log = dated history (the default); note = something that fades fast.",
375
+ },
376
+ fact: {
377
+ type: "string",
378
+ description: "One complete sentence, exactly as it should be recorded.",
379
+ },
380
+ },
381
+ required: ["fact"],
382
+ additionalProperties: false,
383
+ } as const;
384
+
385
+ const MEMORY_FORGET_SCHEMA = {
386
+ type: "object",
387
+ properties: {
388
+ scope: { type: "string", enum: [...SCOPE_ENUM] },
389
+ project: { type: "string" },
390
+ fact: {
391
+ type: "string",
392
+ description: "The exact recorded text of the fact to forget.",
393
+ },
394
+ },
395
+ required: ["fact"],
396
+ additionalProperties: false,
397
+ } as const;
398
+
399
+ interface MemoryToolInputV1 {
400
+ scope: MemoryScopeNameV1;
401
+ project?: string;
402
+ tier: MemoryTierV1;
403
+ fact: string;
404
+ }
405
+
406
+ function decodeMemoryToolInputV1(
407
+ input: unknown,
408
+ allowTier: boolean,
409
+ ): MemoryToolInputV1 {
410
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
411
+ throw new Error("input must be an object");
412
+ }
413
+ const value = input as Record<string, unknown>;
414
+ const allowed = allowTier
415
+ ? ["scope", "project", "tier", "fact"]
416
+ : ["scope", "project", "fact"];
417
+ if (!Object.keys(value).every((key) => allowed.includes(key))) {
418
+ throw new Error("input has unknown fields");
419
+ }
420
+ const fact = value.fact;
421
+ if (
422
+ typeof fact !== "string" ||
423
+ fact.trim().length === 0 ||
424
+ fact.length > MEMORY_MAX_FACT_LENGTH
425
+ ) {
426
+ throw new Error("fact must be a bounded non-empty string");
427
+ }
428
+ const scope = value.scope ?? "bot";
429
+ if (!SCOPE_ENUM.includes(scope as MemoryScopeNameV1)) {
430
+ throw new Error("scope is invalid");
431
+ }
432
+ const tier = allowTier ? (value.tier ?? "log") : "log";
433
+ if (!TIER_ENUM.includes(tier as MemoryTierV1)) {
434
+ throw new Error("tier is invalid");
435
+ }
436
+ const decoded: MemoryToolInputV1 = {
437
+ scope: scope as MemoryScopeNameV1,
438
+ tier: tier as MemoryTierV1,
439
+ fact: fact.trim(),
440
+ };
441
+ if (scope === "project") {
442
+ if (!isMemoryProjectIdV1(value.project)) {
443
+ throw new Error("the project scope requires a valid Project slug");
444
+ }
445
+ decoded.project = value.project;
446
+ } else if (value.project !== undefined) {
447
+ throw new Error("project is only valid with the project scope");
448
+ }
449
+ return decoded;
450
+ }
451
+
452
+ function refusal(reason: string): ToolExecutionResult {
453
+ return { content: reason, isError: true };
454
+ }
455
+
456
+ /**
457
+ * Refuses a `project`-scope change to a Project this Bot has not joined.
458
+ *
459
+ * "only the Projects a Bot has joined are injected into its prompts", and a
460
+ * Bot that may not read a Project's Memory may not write it either. Membership
461
+ * is durable User-scoped state, so the answer comes from the Project authority
462
+ * through the existing seam, never from anything this Package holds. A
463
+ * membership that cannot be read is a refusal, not an assumption: an
464
+ * unreachable authority must not become an open door.
465
+ */
466
+ async function refuseUnjoinedProjectV1(
467
+ host: MemoryRuntimeHostV1,
468
+ scope: MemoryScopeNameV1,
469
+ projectId: string | undefined,
470
+ ): Promise<string | undefined> {
471
+ if (scope !== "project" || projectId === undefined) return undefined;
472
+ if (!host.projects) {
473
+ return `Project membership is unavailable, so writing Project "${projectId}" memory cannot be authorised`;
474
+ }
475
+ let joined: MemoryProjectV1[];
476
+ try {
477
+ joined = await host.projects.joined();
478
+ } catch (error) {
479
+ return `Project membership could not be read: ${
480
+ error instanceof Error ? error.message : String(error)
481
+ }`;
482
+ }
483
+ if (joined.some((project) => project.projectId === projectId)) {
484
+ return undefined;
485
+ }
486
+ return `you have not joined Project "${projectId}"; join it before changing its memory`;
487
+ }
488
+
489
+ /**
490
+ * The provenance one Memory write records. A Bot writes its own shard as
491
+ * itself; the Project descriptor is a User-scoped file the Bot writes with its
492
+ * User's authority, which is the only writer `writerOwnsMemoryPathV1` allows
493
+ * outside a shard and the honest description of creating a Project.
494
+ */
495
+ function botWriterV1(
496
+ owner: MemoryOwnerV1,
497
+ writer: MemoryWriterIdentityV1,
498
+ ): WorkspaceWriterV1 {
499
+ return {
500
+ kind: "bot",
501
+ botId: owner.botId,
502
+ sessionId: writer.sessionId,
503
+ turnId: writer.turnId,
504
+ runId: writer.runId,
505
+ };
506
+ }
507
+
508
+ export function createMemoryWriteTool(
509
+ host: MemoryRuntimeHostV1 & { writer: MemoryWriterIdentityV1 },
510
+ sessions: { get(sessionId: string): Session | undefined },
511
+ projection: MemoryProjection,
512
+ ): ToolDefinition {
513
+ return {
514
+ name: "memory_write",
515
+ // A general work tool: the full toolset an `executor` subagent gets, and
516
+ // not part of the narrow reach of `browserUse`, `computerUse`, or the two
517
+ // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
518
+ admission: { subagentRoles: ["executor"] },
519
+ description:
520
+ "Record one fact in memory. Choose the scope deliberately: bot memory is yours, user memory is shared with every Bot of this User, project memory is shared inside one Project. You always write into your own shard; never try to edit another Bot's.",
521
+ inputSchema: MEMORY_WRITE_SCHEMA as unknown as Record<string, unknown>,
522
+ idempotent: false,
523
+ validate: (input) => {
524
+ try {
525
+ decodeMemoryToolInputV1(input, true);
526
+ return true;
527
+ } catch {
528
+ return false;
529
+ }
530
+ },
531
+ execute: async (input: unknown, context: ToolExecutionContext) => {
532
+ let decoded: MemoryToolInputV1;
533
+ try {
534
+ decoded = decodeMemoryToolInputV1(input, true);
535
+ } catch (error) {
536
+ return refusal(
537
+ `memory_write was refused: ${error instanceof Error ? error.message : String(error)}`,
538
+ );
539
+ }
540
+ const session = sessions.get(context.sessionId);
541
+ if (!session) {
542
+ return refusal(
543
+ `memory_write was refused: session "${context.sessionId}" is unavailable, so the intent cannot be recorded`,
544
+ );
545
+ }
546
+ let root: WorkspaceMemoryRootV1;
547
+ try {
548
+ root = memoryScopeRootV1(decoded.scope, host.owner, decoded.project);
549
+ } catch (error) {
550
+ return refusal(
551
+ `memory_write was refused: ${error instanceof Error ? error.message : String(error)}`,
552
+ );
553
+ }
554
+ const unjoined = await refuseUnjoinedProjectV1(
555
+ host,
556
+ decoded.scope,
557
+ decoded.project,
558
+ );
559
+ if (unjoined) return refusal(`memory_write was refused: ${unjoined}`);
560
+ // One vocabulary: the `note` tier writes the `[note] ` marker through
561
+ // the same renderer the parser is the inverse of, so the tier enum and
562
+ // the on-disk prefix can never drift apart.
563
+ const text = renderMemoryMarkerV1(
564
+ decoded.tier === "note" ? "note" : undefined,
565
+ decoded.fact,
566
+ );
567
+ const contentHash = await sha256HexV1(text);
568
+ const effectId = `memory:write:${decoded.scope}:${decoded.project ?? ""}:${decoded.tier}:${contentHash}`;
569
+ const position = openMemoryTurnPositionV1(session);
570
+ const path = `${decoded.scope}/${decoded.tier}`;
571
+ // Intent before effect.
572
+ session.append({
573
+ type: "memory/write-intent",
574
+ ...position,
575
+ effectId,
576
+ action: "write",
577
+ scope: decoded.scope,
578
+ projectId: decoded.project ?? "",
579
+ tier: decoded.tier,
580
+ path,
581
+ contentHash,
582
+ });
583
+ await session.flush();
584
+
585
+ const outcome = await host.store.write({
586
+ root,
587
+ tier: decoded.tier,
588
+ fact: text,
589
+ writer: botWriterV1(host.owner, host.writer),
590
+ });
591
+ if (outcome.status !== "ok") {
592
+ return refusal(`memory_write was ${outcome.status}: ${outcome.reason}`);
593
+ }
594
+ session.append({
595
+ type: "memory/written",
596
+ ...position,
597
+ effectId,
598
+ action: "write",
599
+ scope: decoded.scope,
600
+ projectId: decoded.project ?? "",
601
+ tier: decoded.tier,
602
+ path: outcome.path,
603
+ generationId: outcome.generationId || "duplicate",
604
+ contentHash,
605
+ });
606
+ // The model must not be told it succeeded before the record is durable.
607
+ await session.flush();
608
+ await projection.reindex();
609
+ return {
610
+ 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.`,
613
+ isError: false,
614
+ };
615
+ },
616
+ };
617
+ }
618
+
619
+ export function createMemoryForgetTool(
620
+ host: MemoryRuntimeHostV1 & { writer: MemoryWriterIdentityV1 },
621
+ sessions: { get(sessionId: string): Session | undefined },
622
+ projection: MemoryProjection,
623
+ ): ToolDefinition {
624
+ return {
625
+ name: "memory_forget",
626
+ // A general work tool: the full toolset an `executor` subagent gets, and
627
+ // not part of the narrow reach of `browserUse`, `computerUse`, or the two
628
+ // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
629
+ admission: { subagentRoles: ["executor"] },
630
+ description:
631
+ "Forget one fact by its exact recorded text. A fact you recorded is removed. A shared fact another Bot recorded is not edited — a retraction is written into your own shard instead, and newest wins.",
632
+ inputSchema: MEMORY_FORGET_SCHEMA as unknown as Record<string, unknown>,
633
+ idempotent: false,
634
+ validate: (input) => {
635
+ try {
636
+ decodeMemoryToolInputV1(input, false);
637
+ return true;
638
+ } catch {
639
+ return false;
640
+ }
641
+ },
642
+ execute: async (input: unknown, context: ToolExecutionContext) => {
643
+ let decoded: MemoryToolInputV1;
644
+ try {
645
+ decoded = decodeMemoryToolInputV1(input, false);
646
+ } catch (error) {
647
+ return refusal(
648
+ `memory_forget was refused: ${error instanceof Error ? error.message : String(error)}`,
649
+ );
650
+ }
651
+ const session = sessions.get(context.sessionId);
652
+ if (!session) {
653
+ return refusal(
654
+ `memory_forget was refused: session "${context.sessionId}" is unavailable, so the intent cannot be recorded`,
655
+ );
656
+ }
657
+ let root: WorkspaceMemoryRootV1;
658
+ try {
659
+ root = memoryScopeRootV1(decoded.scope, host.owner, decoded.project);
660
+ } catch (error) {
661
+ return refusal(
662
+ `memory_forget was refused: ${error instanceof Error ? error.message : String(error)}`,
663
+ );
664
+ }
665
+ const unjoined = await refuseUnjoinedProjectV1(
666
+ host,
667
+ decoded.scope,
668
+ decoded.project,
669
+ );
670
+ if (unjoined) return refusal(`memory_forget was refused: ${unjoined}`);
671
+ const contentHash = await sha256HexV1(decoded.fact);
672
+ const effectId = `memory:forget:${decoded.scope}:${decoded.project ?? ""}:${contentHash}`;
673
+ const position = openMemoryTurnPositionV1(session);
674
+ session.append({
675
+ type: "memory/write-intent",
676
+ ...position,
677
+ effectId,
678
+ action: "forget",
679
+ scope: decoded.scope,
680
+ projectId: decoded.project ?? "",
681
+ tier: "log",
682
+ path: `${decoded.scope}/forget`,
683
+ contentHash,
684
+ });
685
+ await session.flush();
686
+
687
+ const outcome = await host.store.forget({
688
+ root,
689
+ fact: decoded.fact,
690
+ writer: botWriterV1(host.owner, host.writer),
691
+ });
692
+ // A forget can span more than one of this Bot's files. Whatever it
693
+ // rewrote is durable whether or not the whole call succeeded, so the
694
+ // event log records each rewritten file before the outcome is reported;
695
+ // otherwise the log would claim nothing changed while the files disagree.
696
+ const changed =
697
+ outcome.written && outcome.written.length > 0
698
+ ? outcome.written
699
+ : outcome.status === "ok"
700
+ ? [
701
+ {
702
+ path: outcome.path,
703
+ generationId: outcome.generationId,
704
+ contentHash,
705
+ },
706
+ ]
707
+ : [];
708
+ for (const file of changed) {
709
+ session.append({
710
+ type: "memory/written",
711
+ ...position,
712
+ effectId,
713
+ action: "forget",
714
+ scope: decoded.scope,
715
+ projectId: decoded.project ?? "",
716
+ tier: "log",
717
+ path: file.path,
718
+ generationId: file.generationId || "unchanged",
719
+ contentHash,
720
+ });
721
+ }
722
+ if (changed.length > 0) await session.flush();
723
+ if (outcome.status !== "ok") {
724
+ return refusal(
725
+ changed.length > 0
726
+ ? `memory_forget was ${outcome.status} after changing ${changed.length} file(s) (${changed
727
+ .map((file) => file.path)
728
+ .join(", ")}): ${outcome.reason}`
729
+ : `memory_forget was ${outcome.status}: ${outcome.reason}`,
730
+ );
731
+ }
732
+ await projection.reindex();
733
+ return {
734
+ 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}.`,
737
+ isError: false,
738
+ };
739
+ },
740
+ };
741
+ }
742
+
743
+ const MEMORY_SEARCH_SCHEMA = {
744
+ type: "object",
745
+ properties: {
746
+ query: { type: "string", minLength: 1, maxLength: 500 },
747
+ scope: { type: "string", enum: [...SCOPE_ENUM] },
748
+ maxResults: { type: "integer", minimum: 1, maximum: 20 },
749
+ },
750
+ required: ["query"],
751
+ additionalProperties: false,
752
+ } as const;
753
+
754
+ interface MemorySearchInputV1 {
755
+ query: string;
756
+ scope?: MemoryScopeNameV1;
757
+ maxResults?: number;
758
+ }
759
+
760
+ /**
761
+ * Decodes `memory_search` input at the seam, exactly as the write tools do.
762
+ *
763
+ * "every inbound value is decoded at its seam" — a tool argument arrives from
764
+ * a model, so it is inbound, and being read-only buys it no exemption: an
765
+ * unknown key or an out-of-range `maxResults` is a refusal, never a value the
766
+ * searcher is handed unchecked.
767
+ */
768
+ function decodeMemorySearchInputV1(input: unknown): MemorySearchInputV1 {
769
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
770
+ throw new Error("input must be an object");
771
+ }
772
+ const value = input as Record<string, unknown>;
773
+ const allowed = ["query", "scope", "maxResults"];
774
+ if (!Object.keys(value).every((key) => allowed.includes(key))) {
775
+ throw new Error("input has unknown fields");
776
+ }
777
+ const query = value.query;
778
+ if (
779
+ typeof query !== "string" ||
780
+ query.trim().length === 0 ||
781
+ query.length > 500
782
+ ) {
783
+ throw new Error("query must be a bounded non-empty string");
784
+ }
785
+ const decoded: MemorySearchInputV1 = { query: query.trim() };
786
+ if (value.scope !== undefined) {
787
+ if (!SCOPE_ENUM.includes(value.scope as MemoryScopeNameV1)) {
788
+ throw new Error("scope is invalid");
789
+ }
790
+ decoded.scope = value.scope as MemoryScopeNameV1;
791
+ }
792
+ if (value.maxResults !== undefined) {
793
+ const maxResults = value.maxResults;
794
+ if (
795
+ !Number.isSafeInteger(maxResults) ||
796
+ (maxResults as number) < 1 ||
797
+ (maxResults as number) > 20
798
+ ) {
799
+ throw new Error("maxResults must be an integer between 1 and 20");
800
+ }
801
+ decoded.maxResults = maxResults as number;
802
+ }
803
+ return decoded;
804
+ }
805
+
806
+ export function createMemorySearchTool(
807
+ host: MemoryRuntimeHostV1,
808
+ projection: MemoryProjection,
809
+ ): ToolDefinition {
810
+ return {
811
+ name: "memory_search",
812
+ // A general work tool: the full toolset an `executor` subagent gets, and
813
+ // not part of the narrow reach of `browserUse`, `computerUse`, or the two
814
+ // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
815
+ admission: { subagentRoles: ["executor"] },
816
+ description:
817
+ "Search your memory files for anything the injected block did not carry. Your prompt holds only the most recent capped selection; the rest is on disk.",
818
+ inputSchema: MEMORY_SEARCH_SCHEMA as unknown as Record<string, unknown>,
819
+ idempotent: true,
820
+ validate: (input) => {
821
+ try {
822
+ decodeMemorySearchInputV1(input);
823
+ return true;
824
+ } catch {
825
+ return false;
826
+ }
827
+ },
828
+ execute: async (input: unknown) => {
829
+ let value: MemorySearchInputV1;
830
+ try {
831
+ value = decodeMemorySearchInputV1(input);
832
+ } catch (error) {
833
+ return refusal(
834
+ `memory_search was refused: ${error instanceof Error ? error.message : String(error)}`,
835
+ );
836
+ }
837
+ const embed = memoryEmbedderV1(host);
838
+ const results = await searchMemoryV1({
839
+ index: projection.index(),
840
+ query: value.query,
841
+ maxResults: value.maxResults ?? 5,
842
+ ...(value.scope ? { scope: value.scope } : {}),
843
+ ...(embed ? { embed } : {}),
844
+ ...(host.vectorize ? { vectorize: host.vectorize } : {}),
845
+ });
846
+ return {
847
+ content: formatMemoryResultsV1(results),
848
+ isError: false,
849
+ };
850
+ },
851
+ };
852
+ }
853
+
854
+ export function createMemoryRebuildIndexTool(
855
+ projection: MemoryProjection,
856
+ ): ToolDefinition {
857
+ return {
858
+ name: "memory_rebuild_index",
859
+ // A general work tool: the full toolset an `executor` subagent gets, and
860
+ // not part of the narrow reach of `browserUse`, `computerUse`, or the two
861
+ // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
862
+ admission: { subagentRoles: ["executor"] },
863
+ description:
864
+ "Throw away the derived memory index and build it again from the memory files. Safe at any time: the index holds no facts, only a way of finding them.",
865
+ inputSchema: {
866
+ type: "object",
867
+ properties: {},
868
+ additionalProperties: false,
869
+ } as unknown as Record<string, unknown>,
870
+ idempotent: true,
871
+ validate: () => true,
872
+ execute: async () => {
873
+ const rebuilt = await projection.rebuild();
874
+ return {
875
+ content: `Rebuilt the memory index from the files: ${rebuilt.chunksTotal} chunk(s).`,
876
+ isError: false,
877
+ };
878
+ },
879
+ };
880
+ }
881
+
882
+ const PROJECT_SCHEMA = {
883
+ type: "object",
884
+ properties: {
885
+ project: {
886
+ type: "string",
887
+ description: "The Project slug: lowercase letters, digits and hyphens.",
888
+ },
889
+ name: { type: "string", description: "The Project's display name." },
890
+ description: { type: "string" },
891
+ },
892
+ required: ["project"],
893
+ additionalProperties: false,
894
+ } as const;
895
+
896
+ function decodeProjectInputV1(input: unknown): {
897
+ project: string;
898
+ name?: string;
899
+ description?: string;
900
+ } {
901
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
902
+ throw new Error("input must be an object");
903
+ }
904
+ const value = input as Record<string, unknown>;
905
+ if (
906
+ !Object.keys(value).every((key) =>
907
+ ["project", "name", "description"].includes(key),
908
+ )
909
+ ) {
910
+ throw new Error("input has unknown fields");
911
+ }
912
+ if (!isMemoryProjectIdV1(value.project)) {
913
+ throw new Error("project must be a valid slug");
914
+ }
915
+ const decoded: { project: string; name?: string; description?: string } = {
916
+ project: value.project,
917
+ };
918
+ for (const key of ["name", "description"] as const) {
919
+ const candidate = value[key];
920
+ if (candidate === undefined) continue;
921
+ if (typeof candidate !== "string" || candidate.length > 512) {
922
+ throw new Error(`${key} must be a bounded string`);
923
+ }
924
+ decoded[key] = candidate.trim();
925
+ }
926
+ return decoded;
927
+ }
928
+
929
+ export function createProjectTools(
930
+ host: MemoryRuntimeHostV1 & {
931
+ writer: MemoryWriterIdentityV1;
932
+ projects: MemoryProjectsV1;
933
+ },
934
+ sessions: { get(sessionId: string): Session | undefined },
935
+ projection: MemoryProjection,
936
+ ): ToolDefinition[] {
937
+ const act = (
938
+ action: "create" | "join" | "leave",
939
+ name: string,
940
+ description: string,
941
+ ): ToolDefinition => ({
942
+ name,
943
+ description,
944
+ inputSchema: PROJECT_SCHEMA as unknown as Record<string, unknown>,
945
+ idempotent: false,
946
+ validate: (input) => {
947
+ try {
948
+ decodeProjectInputV1(input);
949
+ return true;
950
+ } catch {
951
+ return false;
952
+ }
953
+ },
954
+ execute: async (input: unknown, context: ToolExecutionContext) => {
955
+ let decoded: ReturnType<typeof decodeProjectInputV1>;
956
+ try {
957
+ decoded = decodeProjectInputV1(input);
958
+ } catch (error) {
959
+ return refusal(
960
+ `${name} was refused: ${error instanceof Error ? error.message : String(error)}`,
961
+ );
962
+ }
963
+ const session = sessions.get(context.sessionId);
964
+ if (!session) {
965
+ return refusal(
966
+ `${name} was refused: session "${context.sessionId}" is unavailable, so the intent cannot be recorded`,
967
+ );
968
+ }
969
+ const effectId = `memory:project:${action}:${decoded.project}`;
970
+ const position = openMemoryTurnPositionV1(session);
971
+ session.append({
972
+ type: "memory/project-intent",
973
+ ...position,
974
+ effectId,
975
+ action,
976
+ projectId: decoded.project,
977
+ });
978
+ await session.flush();
979
+
980
+ if (action === "create") {
981
+ // The descriptor is a Memory file like any other, so it goes through
982
+ // the same store and the same conditional write. It sits outside any
983
+ // shard, so its writer is the User whose Project it is.
984
+ const project: MemoryProjectV1 = {
985
+ projectId: decoded.project,
986
+ name: decoded.name || decoded.project,
987
+ description: decoded.description ?? "",
988
+ };
989
+ const written = await host.store.writeFile({
990
+ path: {
991
+ root: projectMemoryRootV1(host.owner, decoded.project),
992
+ path: projectDocumentPathV1(decoded.project),
993
+ },
994
+ text: renderProjectDocumentV1(project),
995
+ writer: { kind: "user", userId: host.owner.userId },
996
+ });
997
+ if (written.status !== "ok") {
998
+ // A conflict is not a success. Another writer holds a generation this
999
+ // call never saw, so the descriptor on disk is not the one this Bot
1000
+ // asked for; membership is left unchanged and nothing is recorded as
1001
+ // changed, rather than logging a Project change that did not happen.
1002
+ return refusal(`${name} was ${written.status}: ${written.reason}`);
1003
+ }
1004
+ }
1005
+
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);
1016
+ if (outcome.status !== "ok") {
1017
+ return refusal(`${name} was refused: ${outcome.reason}`);
1018
+ }
1019
+ session.append({
1020
+ type: "memory/project-changed",
1021
+ ...position,
1022
+ effectId,
1023
+ action,
1024
+ projectId: decoded.project,
1025
+ projects: outcome.joined.map((project) => project.projectId),
1026
+ });
1027
+ await session.flush();
1028
+ projection.invalidate();
1029
+ return {
1030
+ content: `Projects you have joined: ${
1031
+ outcome.joined.map((project) => project.projectId).join(", ") ||
1032
+ "none"
1033
+ }. Project memory changes reach your prompt on your next Turn.`,
1034
+ isError: false,
1035
+ };
1036
+ },
1037
+ });
1038
+ return [
1039
+ act(
1040
+ "create",
1041
+ "project_create",
1042
+ "Create a Project and join it. If the slug already exists this joins it instead, exactly as create-is-join.",
1043
+ ),
1044
+ act("join", "project_join", "Join an existing Project."),
1045
+ act(
1046
+ "leave",
1047
+ "project_leave",
1048
+ "Leave a Project. Its shared memory stays on disk; it simply stops loading into your prompt.",
1049
+ ),
1050
+ ];
1051
+ }
1052
+
1053
+ /** Reads a Project descriptor back out of its Memory root, when one exists. */
1054
+ export async function readProjectDocumentV1(
1055
+ store: MemoryStore,
1056
+ owner: MemoryOwnerV1,
1057
+ projectId: string,
1058
+ ): Promise<MemoryProjectV1 | undefined> {
1059
+ const outcome = await store.reads.read({
1060
+ root: projectMemoryRootV1(owner, projectId),
1061
+ path: projectDocumentPathV1(projectId),
1062
+ });
1063
+ if (outcome.status !== "ok") return undefined;
1064
+ return parseProjectDocumentV1(
1065
+ projectId,
1066
+ new TextDecoder().decode(outcome.file.bytes),
1067
+ );
1068
+ }
1069
+
1070
+ /**
1071
+ * The runtime Contribution. Registers the Memory prompt section, the read
1072
+ * tools, and — only when the host supplies Bot provenance — the write tools.
1073
+ */
1074
+ export function createMemoryRuntimePlugin(
1075
+ host: MemoryRuntimeHostV1,
1076
+ ): Plugin.Function {
1077
+ const plugin: Plugin.Function = (ctx) => {
1078
+ const projection = new MemoryProjection(host);
1079
+ const disposers: Array<() => void> = [];
1080
+ disposers.push(
1081
+ ctx.systemPrompt.register({
1082
+ id: "memory",
1083
+ order: 100,
1084
+ render: () => projection.current().text,
1085
+ }),
1086
+ );
1087
+ disposers.push(
1088
+ ctx.tools.register(createMemorySearchTool(host, projection)),
1089
+ );
1090
+ disposers.push(
1091
+ ctx.tools.register(createMemoryRebuildIndexTool(projection)),
1092
+ );
1093
+ if (host.writer) {
1094
+ const writing = { ...host, writer: host.writer };
1095
+ disposers.push(
1096
+ ctx.tools.register(
1097
+ createMemoryWriteTool(writing, ctx.sessions, projection),
1098
+ ),
1099
+ );
1100
+ disposers.push(
1101
+ ctx.tools.register(
1102
+ createMemoryForgetTool(writing, ctx.sessions, projection),
1103
+ ),
1104
+ );
1105
+ if (host.projects) {
1106
+ for (const tool of createProjectTools(
1107
+ { ...writing, projects: host.projects },
1108
+ ctx.sessions,
1109
+ projection,
1110
+ )) {
1111
+ disposers.push(ctx.tools.register(tool));
1112
+ }
1113
+ }
1114
+ }
1115
+ disposers.push(
1116
+ ctx.on("agent/pre-step", async (agent, _inputs, turn, step, next) => {
1117
+ // Once per Turn, at its first step. Memory a Turn writes reaches its
1118
+ // own prompt on the next Turn, which is what makes the injected block
1119
+ // and the `memory/injected` record describe the same thing.
1120
+ if (step === 1 || projection.loadedTurn() !== turn) {
1121
+ await projection.refresh(turn, agent.session);
1122
+ }
1123
+ return next();
1124
+ }),
1125
+ );
1126
+ return () => {
1127
+ for (const dispose of disposers.toReversed()) dispose();
1128
+ projection.invalidate();
1129
+ };
1130
+ };
1131
+ plugin.inject = ["tools", "systemPrompt", "sessions"];
1132
+ return plugin;
1133
+ }
1134
+
1135
+ export default createMemoryRuntimePlugin;