@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/src/render.ts ADDED
@@ -0,0 +1,478 @@
1
+ // The injected Memory block: GrokBot's shape, order, labels and caps.
2
+ //
3
+ // Parity target, `docs/research/grokbot-computer.md` §4.1a–b. Three scopes,
4
+ // injected **user → project → own**, as *labelled paragraphs* rather than
5
+ // headings, blank-line separated. Precedence runs the other way — own >
6
+ // project > user, "the most specific wins" — so a fact a Bot holds itself is
7
+ // not repeated in a shared block below it.
8
+ //
9
+ // The caps are GrokBot's constants, not ours:
10
+ //
11
+ // own 30 recent facts, 4000-char recent budget, 500-char clamp per fact
12
+ // user 50 profile / 15 recent, 4000 / 2000 char budgets
13
+ // project at most 3 Projects; 25 profile / 10 recent, 2500 / 1500 budgets
14
+ //
15
+ // DELIBERATELY NOT IMPLEMENTED: `resolveFrozenMemoryPrompt`. GrokBot freezes
16
+ // the rendered block per compaction epoch and reuses it, and that freeze is
17
+ // the best explanation for the divergence we observed — own profile facts
18
+ // sitting on disk while the injected block said "No facts recorded yet"
19
+ // (§3.6). Rendering fresh every Turn costs one listing and gives an injection
20
+ // that matches the files; the constitution's requirement is that what was
21
+ // injected is *recorded*, which `memory/injected` does, not that it is cached.
22
+ import type { MemoryScopeNameV1 } from "@frockbot/kernel-contracts";
23
+ import {
24
+ memoryFactKeyV1,
25
+ parseMemoryMarkerV1,
26
+ renderInjectedFactLineV1,
27
+ type SourcedMemoryFactV1,
28
+ } from "./facts.js";
29
+ import { memoryShardOfV1 } from "./roots.js";
30
+ import type { MemoryTierReadV1 } from "./store.js";
31
+
32
+ /** One tier's render bounds, in GrokBot's own units. */
33
+ export interface MemoryRenderCapsV1 {
34
+ profileLimit: number;
35
+ recentLimit: number;
36
+ profileBudget: number;
37
+ recentBudget: number;
38
+ factClamp: number;
39
+ }
40
+
41
+ /** `recall(30)`, a 4000-char recent budget, a 500-char clamp per fact. */
42
+ export const MEMORY_OWN_CAPS_V1: MemoryRenderCapsV1 = {
43
+ profileLimit: 200,
44
+ recentLimit: 30,
45
+ profileBudget: 4_000,
46
+ recentBudget: 4_000,
47
+ factClamp: 500,
48
+ };
49
+
50
+ /** profileLimit 50 / recentLimit 15, char budgets 4000 / 2000. */
51
+ export const MEMORY_USER_CAPS_V1: MemoryRenderCapsV1 = {
52
+ profileLimit: 50,
53
+ recentLimit: 15,
54
+ profileBudget: 4_000,
55
+ recentBudget: 2_000,
56
+ factClamp: 500,
57
+ };
58
+
59
+ /** profileLimit 25 / recentLimit 10, char budgets 2500 / 1500. */
60
+ export const MEMORY_PROJECT_CAPS_V1: MemoryRenderCapsV1 = {
61
+ profileLimit: 25,
62
+ recentLimit: 10,
63
+ profileBudget: 2_500,
64
+ recentBudget: 1_500,
65
+ factClamp: 500,
66
+ };
67
+
68
+ /**
69
+ * How many days a `[note] ` (or `[episode] `) fact keeps being injected.
70
+ *
71
+ * OURS, NOT GROKBOT'S. The register records only that the note tier "fades
72
+ * fast" (§2.2); no TTL, no observed expiry, no host source anywhere in the
73
+ * research. A fortnight is long enough that a note survives a week's gap in
74
+ * conversation and short enough to mean "fast", and it is one constant, here,
75
+ * beside the caps — not an env var and not a User setting, because a per-User
76
+ * knob would have to be recorded on every Turn for the model request to stay
77
+ * reconstructable.
78
+ *
79
+ * The fade is READ-TIME AND PURE. A faded note is still on disk, still
80
+ * greppable by the Bot, still returned by `memory_search`, still forgettable —
81
+ * it has stopped being *injected*, which is the strongest claim "fades fast"
82
+ * supports without deleting a User's data. There is no sweep, no alarm, and no
83
+ * write, so there is nothing to reconcile after an eviction.
84
+ */
85
+ export const MEMORY_NOTE_TTL_DAYS = 14;
86
+
87
+ /** `MEMORY_PROJECT_INJECTED_CAP`: at most three joined Projects are injected. */
88
+ export const MEMORY_PROJECT_INJECTED_CAP = 3;
89
+
90
+ /** One Project the Bot has joined, as the render needs it. */
91
+ export interface MemoryProjectV1 {
92
+ projectId: string;
93
+ name: string;
94
+ description: string;
95
+ }
96
+
97
+ /** A Project's tier read together with the Project it belongs to. */
98
+ export interface MemoryProjectTierV1 {
99
+ project: MemoryProjectV1;
100
+ tier: MemoryTierReadV1;
101
+ }
102
+
103
+ /** Everything one Turn renders from. */
104
+ export interface MemoryInjectionInputV1 {
105
+ botId: string;
106
+ user: MemoryTierReadV1;
107
+ projects: MemoryProjectTierV1[];
108
+ /** Every joined Project, including the ones the cap left out. */
109
+ joined: MemoryProjectV1[];
110
+ own: MemoryTierReadV1;
111
+ /**
112
+ * `YYYY-MM-DD`: the oldest day a marked fact is still injected on.
113
+ *
114
+ * Required, and passed in rather than read from a clock here, because "the
115
+ * durable session event log reconstructs … every exact normalized model
116
+ * request". A filter on "today" computed inside this function would make the
117
+ * render unreproducible; a filter on a day the caller records in
118
+ * `memory/injected` replays identically a year later.
119
+ */
120
+ noteCutoff: string;
121
+ }
122
+
123
+ /** One fact that reached the prompt, as `memory/injected` records it. */
124
+ export interface InjectedMemoryFactV1 {
125
+ scope: MemoryScopeNameV1;
126
+ projectId: string;
127
+ tier: "profile" | "log";
128
+ via: string;
129
+ learnedAt: string;
130
+ text: string;
131
+ }
132
+
133
+ /**
134
+ * Facts one scope faded out of this injection, as `memory/injected` records it.
135
+ *
136
+ * Kept apart from `omissions` deliberately: an omission means a cap or a
137
+ * failure cut a tier short and is a gap to repair, so a reader may alarm on
138
+ * it; a fade is the note tier working as designed. Folding the two together
139
+ * would make every Turn carrying an old note look degraded.
140
+ */
141
+ export interface MemoryFadedV1 {
142
+ scope: MemoryScopeNameV1;
143
+ projectId: string;
144
+ count: number;
145
+ }
146
+
147
+ /** A tier a cap or a failure cut short, as `memory/injected` records it. */
148
+ export interface MemoryOmissionV1 {
149
+ scope: MemoryScopeNameV1;
150
+ reason: string;
151
+ }
152
+
153
+ export interface MemoryInjectionV1 {
154
+ /** The rendered block, or `""` when there is nothing at all to inject. */
155
+ text: string;
156
+ facts: InjectedMemoryFactV1[];
157
+ omissions: MemoryOmissionV1[];
158
+ /** Marked facts dropped by the fade, per scope. Never an omission. */
159
+ faded: MemoryFadedV1[];
160
+ }
161
+
162
+ const USER_PARAGRAPH =
163
+ "User memory: facts shared by every Bot of this User. It is split into one shard folder per Bot so every file has a single writer. Never edit another Bot's shard — correct a shared fact by writing the correction into your own shard with memory_write, and newest wins.";
164
+ const OWN_PARAGRAPH =
165
+ "Memory: your own memory. On conflict prefer your OWN memory first, then project memory, then user memory — the most specific wins; within a shared tier, newest wins.";
166
+
167
+ interface TakenFacts {
168
+ lines: string[];
169
+ taken: SourcedMemoryFactV1[];
170
+ dropped: number;
171
+ }
172
+
173
+ /** Applies one tier's count limit, char budget and per-fact clamp, in order. */
174
+ function take(
175
+ facts: SourcedMemoryFactV1[],
176
+ limit: number,
177
+ budget: number,
178
+ clamp: number,
179
+ withVia: boolean,
180
+ ): TakenFacts {
181
+ const lines: string[] = [];
182
+ const taken: SourcedMemoryFactV1[] = [];
183
+ let used = 0;
184
+ for (const fact of facts) {
185
+ if (taken.length >= limit) break;
186
+ const line = renderInjectedFactLineV1(
187
+ withVia ? fact : { date: fact.date, text: fact.text },
188
+ clamp,
189
+ );
190
+ if (used + line.length > budget && taken.length > 0) break;
191
+ used += line.length + 1;
192
+ lines.push(line);
193
+ taken.push(fact);
194
+ }
195
+ return { lines, taken, dropped: facts.length - taken.length };
196
+ }
197
+
198
+ function injected(
199
+ facts: SourcedMemoryFactV1[],
200
+ scope: MemoryScopeNameV1,
201
+ projectId: string,
202
+ tier: "profile" | "log",
203
+ withVia: boolean,
204
+ ): InjectedMemoryFactV1[] {
205
+ return facts.map((fact) => ({
206
+ scope,
207
+ projectId,
208
+ tier,
209
+ via: withVia ? fact.via : "",
210
+ learnedAt: fact.date,
211
+ text: fact.text,
212
+ }));
213
+ }
214
+
215
+ /**
216
+ * Drops the marked facts that have faded, before any cap is applied.
217
+ *
218
+ * Before, not after, so a faded note never occupies a slot a live fact could
219
+ * have used — the whole point of a tier that fades is that it stops competing
220
+ * for the budget.
221
+ */
222
+ function live(
223
+ facts: SourcedMemoryFactV1[],
224
+ cutoff: string,
225
+ ): { kept: SourcedMemoryFactV1[]; faded: number } {
226
+ const kept = facts.filter((fact) => {
227
+ const { marker } = parseMemoryMarkerV1(fact.text);
228
+ return !marker || fact.date >= cutoff;
229
+ });
230
+ return { kept, faded: facts.length - kept.length };
231
+ }
232
+
233
+ function without(
234
+ facts: SourcedMemoryFactV1[],
235
+ seen: Set<string>,
236
+ ): SourcedMemoryFactV1[] {
237
+ return facts.filter((fact) => !seen.has(memoryFactKeyV1(fact.text)));
238
+ }
239
+
240
+ function remember(facts: SourcedMemoryFactV1[], seen: Set<string>): void {
241
+ for (const fact of facts) seen.add(memoryFactKeyV1(fact.text));
242
+ }
243
+
244
+ /**
245
+ * Renders the whole Memory block and reports exactly what reached the prompt.
246
+ *
247
+ * Pure: it reads nothing and writes nothing. That is what makes "the session
248
+ * event log records exactly what was injected" checkable — the caller records
249
+ * the `facts` this function returns, so the record cannot drift from the text.
250
+ */
251
+ export function renderMemoryInjectionV1(
252
+ input: MemoryInjectionInputV1,
253
+ ): MemoryInjectionV1 {
254
+ const facts: InjectedMemoryFactV1[] = [];
255
+ const omissions: MemoryOmissionV1[] = [];
256
+ const blocks: string[] = [];
257
+
258
+ // Precedence is applied before rendering: the own tier claims a fact text,
259
+ // then the Projects, and only the remainder reaches the User block. The
260
+ // ordering of the *paragraphs* is the opposite — user, project, own — which
261
+ // is GrokBot's injected order exactly.
262
+ //
263
+ // The fade runs first of all, so precedence, caps and budgets all see only
264
+ // the surviving set.
265
+ const faded: MemoryFadedV1[] = [];
266
+ const fade = (
267
+ tier: MemoryTierReadV1,
268
+ scope: MemoryScopeNameV1,
269
+ projectId: string,
270
+ ) => {
271
+ const profile = live(tier.profile, input.noteCutoff);
272
+ const recent = live(tier.recent, input.noteCutoff);
273
+ const count = profile.faded + recent.faded;
274
+ if (count > 0) faded.push({ scope, projectId, count });
275
+ return {
276
+ profile: profile.kept,
277
+ recent: recent.kept,
278
+ recentFaded: recent.faded,
279
+ };
280
+ };
281
+ const ownLive = fade(input.own, "bot", "");
282
+ const userLive = fade(input.user, "user", "");
283
+
284
+ const claimed = new Set<string>();
285
+ remember([...ownLive.profile, ...ownLive.recent], claimed);
286
+
287
+ const shown = input.projects.slice(0, MEMORY_PROJECT_INJECTED_CAP);
288
+ const projectFacts = shown.map((entry) => {
289
+ const tier = fade(entry.tier, "project", entry.project.projectId);
290
+ const profile = without(tier.profile, claimed);
291
+ const recent = without(tier.recent, claimed);
292
+ return { entry, profile, recent };
293
+ });
294
+ for (const entry of projectFacts) {
295
+ remember([...entry.profile, ...entry.recent], claimed);
296
+ }
297
+
298
+ // User memory.
299
+ const userProfile = take(
300
+ without(userLive.profile, claimed),
301
+ MEMORY_USER_CAPS_V1.profileLimit,
302
+ MEMORY_USER_CAPS_V1.profileBudget,
303
+ MEMORY_USER_CAPS_V1.factClamp,
304
+ true,
305
+ );
306
+ const userRecent = take(
307
+ without(userLive.recent, claimed),
308
+ MEMORY_USER_CAPS_V1.recentLimit,
309
+ MEMORY_USER_CAPS_V1.recentBudget,
310
+ MEMORY_USER_CAPS_V1.factClamp,
311
+ true,
312
+ );
313
+ const userLines = [USER_PARAGRAPH];
314
+ if (userProfile.lines.length > 0) {
315
+ userLines.push("About the user (shared):", ...userProfile.lines);
316
+ }
317
+ if (userRecent.lines.length > 0) {
318
+ userLines.push("Recently (shared):", ...userRecent.lines);
319
+ }
320
+ if (userProfile.lines.length === 0 && userRecent.lines.length === 0) {
321
+ userLines.push("No shared facts recorded yet.");
322
+ }
323
+ blocks.push(userLines.join("\n"));
324
+ facts.push(
325
+ ...injected(userProfile.taken, "user", "", "profile", true),
326
+ ...injected(userRecent.taken, "user", "", "log", true),
327
+ );
328
+ if (input.user.unavailable) {
329
+ omissions.push({ scope: "user", reason: input.user.unavailable });
330
+ }
331
+ if (input.user.omitted) {
332
+ omissions.push({ scope: "user", reason: input.user.omitted });
333
+ }
334
+ const userDropped = userProfile.dropped + userRecent.dropped;
335
+ if (userDropped > 0) {
336
+ omissions.push({
337
+ scope: "user",
338
+ reason: `${userDropped} shared fact(s) beyond the injection cap were not injected`,
339
+ });
340
+ }
341
+
342
+ // Project memory, at most three.
343
+ for (const { entry, profile, recent } of projectFacts) {
344
+ const shard = memoryShardOfV1(entry.tier.root, input.botId);
345
+ const lines = [
346
+ `Project "${entry.project.name}" (${entry.project.projectId}) — your shard: ${shard}:`,
347
+ ];
348
+ if (entry.project.description) {
349
+ lines.push(entry.project.description);
350
+ }
351
+ const profileTaken = take(
352
+ profile,
353
+ MEMORY_PROJECT_CAPS_V1.profileLimit,
354
+ MEMORY_PROJECT_CAPS_V1.profileBudget,
355
+ MEMORY_PROJECT_CAPS_V1.factClamp,
356
+ true,
357
+ );
358
+ const recentTaken = take(
359
+ recent,
360
+ MEMORY_PROJECT_CAPS_V1.recentLimit,
361
+ MEMORY_PROJECT_CAPS_V1.recentBudget,
362
+ MEMORY_PROJECT_CAPS_V1.factClamp,
363
+ true,
364
+ );
365
+ if (profileTaken.lines.length > 0) {
366
+ lines.push("About this project (shared):", ...profileTaken.lines);
367
+ }
368
+ if (recentTaken.lines.length > 0) {
369
+ lines.push("Recently (shared):", ...recentTaken.lines);
370
+ }
371
+ if (profileTaken.lines.length === 0 && recentTaken.lines.length === 0) {
372
+ lines.push("No shared facts recorded yet for this project.");
373
+ }
374
+ const others = input.joined
375
+ .filter((project) => project.projectId !== entry.project.projectId)
376
+ .map((project) => project.projectId);
377
+ if (others.length > 0) lines.push(`Also a member of: ${others.join(", ")}`);
378
+ blocks.push(lines.join("\n"));
379
+ facts.push(
380
+ ...injected(
381
+ profileTaken.taken,
382
+ "project",
383
+ entry.project.projectId,
384
+ "profile",
385
+ true,
386
+ ),
387
+ ...injected(
388
+ recentTaken.taken,
389
+ "project",
390
+ entry.project.projectId,
391
+ "log",
392
+ true,
393
+ ),
394
+ );
395
+ if (entry.tier.unavailable) {
396
+ omissions.push({
397
+ scope: "project",
398
+ reason: `${entry.project.projectId}: ${entry.tier.unavailable}`,
399
+ });
400
+ }
401
+ if (entry.tier.omitted) {
402
+ omissions.push({
403
+ scope: "project",
404
+ reason: `${entry.project.projectId}: ${entry.tier.omitted}`,
405
+ });
406
+ }
407
+ const dropped = profileTaken.dropped + recentTaken.dropped;
408
+ if (dropped > 0) {
409
+ omissions.push({
410
+ scope: "project",
411
+ reason: `${entry.project.projectId}: ${dropped} shared fact(s) beyond the injection cap were not injected`,
412
+ });
413
+ }
414
+ }
415
+ if (input.projects.length > MEMORY_PROJECT_INJECTED_CAP) {
416
+ omissions.push({
417
+ scope: "project",
418
+ reason: `at most ${MEMORY_PROJECT_INJECTED_CAP} joined Projects are injected; ${
419
+ input.projects.length - MEMORY_PROJECT_INJECTED_CAP
420
+ } were not`,
421
+ });
422
+ }
423
+
424
+ // Own memory, last and most specific.
425
+ const ownProfile = take(
426
+ ownLive.profile,
427
+ MEMORY_OWN_CAPS_V1.profileLimit,
428
+ MEMORY_OWN_CAPS_V1.profileBudget,
429
+ MEMORY_OWN_CAPS_V1.factClamp,
430
+ false,
431
+ );
432
+ const ownRecent = take(
433
+ ownLive.recent,
434
+ MEMORY_OWN_CAPS_V1.recentLimit,
435
+ MEMORY_OWN_CAPS_V1.recentBudget,
436
+ MEMORY_OWN_CAPS_V1.factClamp,
437
+ false,
438
+ );
439
+ const ownLines = [OWN_PARAGRAPH];
440
+ if (ownProfile.lines.length > 0) {
441
+ ownLines.push("About the user:", ...ownProfile.lines);
442
+ }
443
+ if (ownRecent.lines.length > 0) {
444
+ ownLines.push("Recently:", ...ownRecent.lines);
445
+ }
446
+ if (ownProfile.lines.length === 0 && ownRecent.lines.length === 0) {
447
+ ownLines.push("No facts recorded yet.");
448
+ }
449
+ // Faded notes count here: the line points at what is really on disk, and a
450
+ // faded note is still on disk for the Bot to grep. It is *not* an omission
451
+ // below, because nothing was cut short.
452
+ const ownMore = ownRecent.dropped + ownLive.recentFaded;
453
+ if (ownMore > 0) {
454
+ ownLines.push(
455
+ `(${ownMore} more log facts on disk — grep the log/ folder for them.)`,
456
+ );
457
+ }
458
+ blocks.push(ownLines.join("\n"));
459
+ facts.push(
460
+ ...injected(ownProfile.taken, "bot", "", "profile", false),
461
+ ...injected(ownRecent.taken, "bot", "", "log", false),
462
+ );
463
+ if (input.own.unavailable) {
464
+ omissions.push({ scope: "bot", reason: input.own.unavailable });
465
+ }
466
+ if (input.own.omitted) {
467
+ omissions.push({ scope: "bot", reason: input.own.omitted });
468
+ }
469
+ const ownDropped = ownProfile.dropped + ownRecent.dropped;
470
+ if (ownDropped > 0) {
471
+ omissions.push({
472
+ scope: "bot",
473
+ reason: `${ownDropped} own fact(s) beyond the injection cap were not injected`,
474
+ });
475
+ }
476
+
477
+ return { text: blocks.join("\n\n"), facts, omissions, faded };
478
+ }
package/src/roots.ts ADDED
@@ -0,0 +1,158 @@
1
+ // Where a Memory fact lives: which durable root, which shard, which file.
2
+ //
3
+ // "Memory is Markdown files under durable roots of the Workspace in three
4
+ // tiers: a Bot Memory root per Bot, a User Memory root shared by the User's
5
+ // Bots, and a Project Memory root per Project that a Bot has joined."
6
+ //
7
+ // Sharding is not decided here. `memoryShardPathV1` in
8
+ // `@frockbot/kernel-contracts` owns `by-agent/<botId>/`, and this module calls
9
+ // it; a second spelling of the shard prefix is exactly the bug the contract
10
+ // exists to prevent. What this module owns is GrokBot's file layout inside a
11
+ // shard — `profile.md` beside `log/YYYY-MM.md` — and the mapping from a
12
+ // scope name to a root.
13
+ import {
14
+ memoryShardPathV1,
15
+ memoryShardPrefixV1,
16
+ type MemoryScopeNameV1,
17
+ type WorkspaceMemoryRootV1,
18
+ type WorkspacePathV1,
19
+ } from "@frockbot/kernel-contracts";
20
+
21
+ /** The Bot whose Memory is being read or written, and its User. */
22
+ export interface MemoryOwnerV1 {
23
+ userId: string;
24
+ botId: string;
25
+ }
26
+
27
+ /**
28
+ * The three write tiers within a scope (`docs/research/grokbot-computer.md`
29
+ * §2.2): `profile` is foundational and kept in mind every Turn, `log` is dated
30
+ * history and the default, `note` "fades fast". A note is not a separate file:
31
+ * GrokBot stores it as a `[note] ` prefix on the fact text in the same monthly
32
+ * log, and so does this Package.
33
+ */
34
+ export type MemoryTierV1 = "profile" | "log" | "note";
35
+
36
+ /** The file a `profile`-tier fact is written to, inside a shard. */
37
+ export const MEMORY_PROFILE_FILE = "profile.md";
38
+ /** The directory dated facts live in, inside a shard. */
39
+ export const MEMORY_LOG_DIRECTORY = "log";
40
+
41
+ const PROJECT_ID = /^[a-z0-9][a-z0-9-]{0,127}$/;
42
+
43
+ /** True for a Project slug the `project-memory` root will accept. */
44
+ export function isMemoryProjectIdV1(value: unknown): value is string {
45
+ return typeof value === "string" && PROJECT_ID.test(value);
46
+ }
47
+
48
+ export function botMemoryRootV1(owner: MemoryOwnerV1): WorkspaceMemoryRootV1 {
49
+ return { kind: "bot-memory", userId: owner.userId, botId: owner.botId };
50
+ }
51
+
52
+ export function userMemoryRootV1(owner: MemoryOwnerV1): WorkspaceMemoryRootV1 {
53
+ return { kind: "user-memory", userId: owner.userId };
54
+ }
55
+
56
+ export function projectMemoryRootV1(
57
+ owner: MemoryOwnerV1,
58
+ projectId: string,
59
+ ): WorkspaceMemoryRootV1 {
60
+ if (!isMemoryProjectIdV1(projectId)) {
61
+ throw new Error(`Project slug "${projectId}" is invalid`);
62
+ }
63
+ return { kind: "project-memory", userId: owner.userId, projectId };
64
+ }
65
+
66
+ /** The root one scope names, with a Project slug required for `project`. */
67
+ export function memoryScopeRootV1(
68
+ scope: MemoryScopeNameV1,
69
+ owner: MemoryOwnerV1,
70
+ projectId?: string,
71
+ ): WorkspaceMemoryRootV1 {
72
+ if (scope === "bot") return botMemoryRootV1(owner);
73
+ if (scope === "user") return userMemoryRootV1(owner);
74
+ if (projectId === undefined) {
75
+ throw new Error("the project scope requires a Project slug");
76
+ }
77
+ return projectMemoryRootV1(owner, projectId);
78
+ }
79
+
80
+ /** The scope name a root belongs to. */
81
+ export function memoryScopeOfRootV1(
82
+ root: WorkspaceMemoryRootV1,
83
+ ): MemoryScopeNameV1 {
84
+ if (root.kind === "bot-memory") return "bot";
85
+ if (root.kind === "user-memory") return "user";
86
+ return "project";
87
+ }
88
+
89
+ /** The Project slug a root names, or `""` for the two unprojected tiers. */
90
+ export function memoryProjectIdOfRootV1(root: WorkspaceMemoryRootV1): string {
91
+ return root.kind === "project-memory" ? root.projectId : "";
92
+ }
93
+
94
+ /** `log/YYYY-MM.md`, the monthly file a dated fact is appended to. */
95
+ export function memoryLogRelativeV1(at: Date): string {
96
+ const year = at.getUTCFullYear().toString().padStart(4, "0");
97
+ const month = (at.getUTCMonth() + 1).toString().padStart(2, "0");
98
+ return `${MEMORY_LOG_DIRECTORY}/${year}-${month}.md`;
99
+ }
100
+
101
+ /** The relative path, inside a shard, one tier writes to. */
102
+ export function memoryTierRelativeV1(tier: MemoryTierV1, at: Date): string {
103
+ return tier === "profile" ? MEMORY_PROFILE_FILE : memoryLogRelativeV1(at);
104
+ }
105
+
106
+ /**
107
+ * The full path a Bot's fact is written to. Shared roots place it under the
108
+ * writing Bot's own shard; the Bot Memory root is already single-writer, so
109
+ * its shard is the root.
110
+ */
111
+ export function memoryFilePathV1(
112
+ root: WorkspaceMemoryRootV1,
113
+ botId: string,
114
+ tier: MemoryTierV1,
115
+ at: Date,
116
+ ): WorkspacePathV1 {
117
+ return memoryShardPathV1(root, botId, memoryTierRelativeV1(tier, at));
118
+ }
119
+
120
+ /** The prefix a Bot's own files sit under; `""` for the Bot Memory root. */
121
+ export function memoryShardOfV1(
122
+ root: WorkspaceMemoryRootV1,
123
+ botId: string,
124
+ ): string {
125
+ return memoryShardPrefixV1(root, botId);
126
+ }
127
+
128
+ /**
129
+ * Classifies one relative path inside a root as a Memory file, or not.
130
+ *
131
+ * A Memory root holds only `profile.md` and `log/*.md` per shard; anything
132
+ * else — a stray file, a derived index, a directory marker — is data the
133
+ * renderer ignores rather than parses. The shard prefix is stripped first, so
134
+ * the same predicate answers for all three tiers.
135
+ */
136
+ export function memoryFileKindV1(
137
+ root: WorkspaceMemoryRootV1,
138
+ relative: string,
139
+ ): { kind: "profile" | "log"; shard: string } | undefined {
140
+ let shard = "";
141
+ let tail = relative;
142
+ if (root.kind !== "bot-memory") {
143
+ const segments = relative.split("/");
144
+ if (segments.length < 3 || segments[0] !== "by-agent") return undefined;
145
+ try {
146
+ shard = decodeURIComponent(segments[1] ?? "");
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ if (!shard) return undefined;
151
+ tail = segments.slice(2).join("/");
152
+ } else {
153
+ shard = root.botId;
154
+ }
155
+ if (tail === MEMORY_PROFILE_FILE) return { kind: "profile", shard };
156
+ if (/^log\/\d{4}-\d{2}\.md$/.test(tail)) return { kind: "log", shard };
157
+ return undefined;
158
+ }