@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/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;
|