@hicaru/pi-rlm 0.3.6 → 0.3.8

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.
@@ -0,0 +1,577 @@
1
+ /**
2
+ * Durable memory (port of rlm_test v5 `memory/store.py`).
3
+ *
4
+ * L1 episodes: content-addressed replay — a recorded child/root answer replays for ZERO
5
+ * API calls while every file it touched still hashes to the recorded sha256.
6
+ * L2 notes: A-MEM-lite BM25 notes over {content, context, keywords, tags, paths, symbols},
7
+ * batched consolidation (no per-write evolve), link-on-write to top-4 neighbors.
8
+ *
9
+ * Layout (v5-identical): `<root>/.rlm/memory/{episodes.jsonl, notes.json}`.
10
+ * All I/O is fail-soft: writers return booleans and never throw — a corrupt or missing
11
+ * store degrades to a no-op, it never takes a run down.
12
+ */
13
+
14
+ import { createHash } from "node:crypto";
15
+ import { closeSync, openSync, readSync } from "node:fs";
16
+ import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
17
+ import { dirname, join, resolve, sep } from "node:path";
18
+
19
+ const TOK = /[a-z0-9]{2,}/g;
20
+ const EPISODE_CAP = 4_000;
21
+ const INJECT_HEADER = "[memory] retrieved notes (do not restudy these paths unless hashes went stale):";
22
+ const NOTE_CONTENT_CHARS = 280;
23
+ const LINK_NEIGHBORS = 4;
24
+ const KEYWORDS_MAX = 12;
25
+
26
+ export interface Episode {
27
+ readonly key: string;
28
+ readonly kind: "rlm" | "root";
29
+ readonly model: string;
30
+ readonly prompt: string;
31
+ readonly paths: readonly string[];
32
+ readonly pathHashes: Readonly<Record<string, string>>;
33
+ readonly result: string;
34
+ readonly tokensIn: number;
35
+ readonly tokensOut: number;
36
+ readonly ts: number;
37
+ }
38
+
39
+ export interface Note {
40
+ readonly id: string;
41
+ readonly content: string;
42
+ readonly timestamp: number;
43
+ readonly keywords: readonly string[];
44
+ readonly tags: readonly string[];
45
+ readonly context: string;
46
+ readonly paths: readonly string[];
47
+ readonly symbols: readonly string[];
48
+ readonly links: readonly string[];
49
+ readonly sourceKeys: readonly string[];
50
+ }
51
+
52
+ export interface MemoryStats {
53
+ readonly episodes: number;
54
+ readonly notes: number;
55
+ readonly hits: number;
56
+ readonly misses: number;
57
+ readonly notesInjected: number;
58
+ }
59
+
60
+ /** Single completion seam for consolidation (wired to bridge/model at the composition root). */
61
+ export type MemoryLlm = (prompt: string) => Promise<string>;
62
+
63
+ export interface MemoryOptions {
64
+ /** Override directory; default resolves to `<root>/.rlm/memory`. */
65
+ readonly dir?: string;
66
+ readonly injectNoteTokens?: number;
67
+ readonly evolveEvery?: number;
68
+ readonly llm?: MemoryLlm;
69
+ }
70
+
71
+ function tokenize(text: string): readonly string[] {
72
+ return (text.toLowerCase().replace(/_/g, " ").replace(/-/g, " ").match(TOK) ?? []) as readonly string[];
73
+ }
74
+
75
+ function noteBlob(n: Note): string {
76
+ return [n.content, n.context, n.keywords.join(" "), n.tags.join(" "), n.paths.join(" "), n.symbols.join(" ")].join(" ");
77
+ }
78
+
79
+ /** Sync streamed sha256 (64KiB chunks via readSync — audit H7): a huge path must never
80
+ * buffer whole in memory, and no async contagion into recordEpisode/replay. */
81
+ export function fileSha256(path: string): string | undefined {
82
+ let fd: number | undefined;
83
+ try {
84
+ fd = openSync(path, "r");
85
+ const h = createHash("sha256");
86
+ const buf = Buffer.allocUnsafe(1 << 16);
87
+ for (;;) {
88
+ const n = readSync(fd, buf, 0, buf.length, null);
89
+ if (n <= 0) break;
90
+ h.update(n === buf.length ? buf : buf.subarray(0, n));
91
+ }
92
+ return h.digest("hex");
93
+ } catch {
94
+ return undefined;
95
+ } finally {
96
+ if (fd !== undefined) {
97
+ try {
98
+ closeSync(fd);
99
+ } catch {
100
+ // already closed — nothing to do
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ function isRecord(value: unknown): value is Record<string, unknown> {
107
+ return typeof value === "object" && value !== null;
108
+ }
109
+
110
+ /** H6 (audit): the real-file slice of a root context — cwd-seeded entries (un-prefixed
111
+ * paths with string content), bounded, `ctx/<id>/…` virtual sources excluded (they have no
112
+ * disk file to hash). These are what a root episode snapshots for replay invalidation. */
113
+ export function rootContextPaths(context: unknown, max: number): readonly string[] {
114
+ if (!Array.isArray(context)) return Object.freeze([]);
115
+ const out: string[] = [];
116
+ for (const item of context) {
117
+ if (out.length >= max) break;
118
+ if (isRecord(item) && typeof item.path === "string" && typeof item.content === "string") {
119
+ const p = item.path;
120
+ if (p !== "" && !p.startsWith("ctx/") && !p.includes("/ctx/") && !p.startsWith("/")) out.push(p);
121
+ }
122
+ }
123
+ return Object.freeze(out);
124
+ }
125
+
126
+ export class MemoryStore {
127
+ readonly enabled: boolean;
128
+ private dir: string | undefined;
129
+ private readonly pinnedDir: boolean;
130
+ private readonly injectNoteTokens: number;
131
+ private readonly evolveEvery: number;
132
+ private llm: MemoryLlm | undefined;
133
+ private root: string;
134
+ private episodes = new Map<string, Episode>();
135
+ private notes = new Map<string, Note>();
136
+ private pending: readonly string[] = [];
137
+ private hits = 0;
138
+ private misses = 0;
139
+ private notesInjected = 0;
140
+ private loaded = false;
141
+
142
+ constructor(root: string, opts: MemoryOptions = {}, enabled = true) {
143
+ this.root = root;
144
+ this.enabled = enabled && opts.dir !== null;
145
+ this.pinnedDir = opts.dir !== undefined;
146
+ this.dir = this.enabled ? (opts.dir ?? join(root, ".rlm", "memory")) : undefined;
147
+ this.injectNoteTokens = opts.injectNoteTokens ?? 2_000;
148
+ this.evolveEvery = opts.evolveEvery ?? 8;
149
+ this.llm = opts.llm;
150
+ }
151
+
152
+ /** Session hooks: the consolidation model + real workspace root arrive after construction. */
153
+ setLlm(llm: MemoryLlm): void {
154
+ this.llm = llm;
155
+ }
156
+
157
+ setRoot(root: string): void {
158
+ if (root === this.root) return;
159
+ this.root = root;
160
+ if (!this.pinnedDir && this.enabled) this.dir = join(root, ".rlm", "memory");
161
+ this.loaded = false;
162
+ this.episodes = new Map();
163
+ this.notes = new Map();
164
+ this.pending = [];
165
+ }
166
+
167
+ // ── L1: episodes ────────────────────────────────────────────────────────────
168
+
169
+ /** H7 (audit): resolve a rel-path INSIDE the root only — `../` traversal gets no digest. */
170
+ private safeAbs(rel: string): string | undefined {
171
+ const rootAbs = resolve(this.root);
172
+ const abs = resolve(rootAbs, rel);
173
+ return abs === rootAbs || abs.startsWith(rootAbs + sep) ? abs : undefined;
174
+ }
175
+
176
+ /** Record a completed run. Snapshots path hashes; triggers consolidation on threshold. */
177
+ recordEpisode(req: {
178
+ readonly key: string;
179
+ readonly kind: "rlm" | "root";
180
+ readonly model: string;
181
+ readonly prompt: string;
182
+ readonly paths: readonly string[];
183
+ readonly result: string;
184
+ readonly tokensIn?: number;
185
+ readonly tokensOut?: number;
186
+ }): boolean {
187
+ if (!this.enabled || this.dir === undefined || req.result === "") return false;
188
+ this.load();
189
+ const pathHashes: Record<string, string> = {};
190
+ for (const rel of req.paths) {
191
+ const abs = this.safeAbs(rel);
192
+ const digest = abs === undefined ? undefined : fileSha256(abs);
193
+ if (digest !== undefined) pathHashes[rel.replace(/\\/g, "/")] = digest;
194
+ }
195
+ const ep: Episode = {
196
+ key: req.key,
197
+ kind: req.kind,
198
+ model: req.model,
199
+ prompt: req.prompt,
200
+ paths: Object.freeze([...req.paths]),
201
+ pathHashes: Object.freeze(pathHashes),
202
+ result: req.result,
203
+ tokensIn: req.tokensIn ?? 0,
204
+ tokensOut: req.tokensOut ?? 0,
205
+ ts: Date.now(),
206
+ };
207
+ this.episodes.set(ep.key, ep);
208
+ this.pending = [...this.pending, ep.key];
209
+ const appended = this.appendEpisode(ep);
210
+ if (this.pending.length >= this.evolveEvery) void this.consolidate();
211
+ return appended;
212
+ }
213
+
214
+ /** Zero-API-call replay — only while every recorded hash still matches. */
215
+ replay(key: string): Episode | undefined {
216
+ if (!this.enabled) return undefined;
217
+ this.load();
218
+ const ep = this.episodes.get(key);
219
+ if (ep === undefined) {
220
+ this.misses++;
221
+ return undefined;
222
+ }
223
+ if (!this.hashesFresh(ep.pathHashes)) {
224
+ this.misses++;
225
+ return undefined;
226
+ }
227
+ this.hits++;
228
+ return ep;
229
+ }
230
+
231
+ private hashesFresh(pathHashes: Readonly<Record<string, string>>): boolean {
232
+ for (const [rel, digest] of Object.entries(pathHashes)) {
233
+ const abs = this.safeAbs(rel);
234
+ if (abs === undefined) return false; // path escaped the root — treat as drifted
235
+ if (fileSha256(abs) !== digest) return false;
236
+ }
237
+ return true;
238
+ }
239
+
240
+ private appendEpisode(ep: Episode): boolean {
241
+ if (this.dir === undefined) return false;
242
+ try {
243
+ mkdirSync(dirname(join(this.dir, "episodes.jsonl")), { recursive: true });
244
+ writeFileSync(join(this.dir, "episodes.jsonl"), `${JSON.stringify(ep)}\n`, { flag: "a" });
245
+ if (this.episodes.size > EPISODE_CAP) this.rewriteEpisodes();
246
+ return true;
247
+ } catch {
248
+ return false; // fail-soft: warn-free degradation
249
+ }
250
+ }
251
+
252
+ private rewriteEpisodes(): void {
253
+ if (this.dir === undefined) return;
254
+ const keep = [...this.episodes.values()].sort((a, b) => b.ts - a.ts).slice(0, EPISODE_CAP);
255
+ this.episodes = new Map(keep.map((e) => [e.key, e]));
256
+ try {
257
+ writeFileSync(
258
+ join(this.dir, "episodes.jsonl"),
259
+ keep.map((e) => JSON.stringify(e)).join("\n") + (keep.length > 0 ? "\n" : ""),
260
+ );
261
+ } catch {
262
+ // fail-soft: the append already succeeded; the trim retries on the next overflow
263
+ }
264
+ }
265
+
266
+ // ── L2: notes ───────────────────────────────────────────────────────────────
267
+
268
+ addNote(req: {
269
+ readonly content: string;
270
+ readonly paths?: readonly string[];
271
+ readonly tags?: readonly string[];
272
+ readonly context?: string;
273
+ readonly symbols?: readonly string[];
274
+ readonly sourceKeys?: readonly string[];
275
+ readonly noteId?: string;
276
+ }): Note | undefined {
277
+ if (!this.enabled || this.dir === undefined || req.content.trim() === "") return undefined;
278
+ this.load();
279
+ const paths = req.paths ?? [];
280
+ const id =
281
+ req.noteId ?? createHash("sha256").update(`${req.content}|${paths.join(",")}`).digest("hex").slice(0, 16);
282
+ const existing = this.notes.get(id);
283
+ const toks = tokenize(req.content);
284
+ const note: Note = {
285
+ id,
286
+ content: req.content.trim(),
287
+ timestamp: Date.now(),
288
+ keywords: Object.freeze(existing?.keywords ?? toks.slice(0, KEYWORDS_MAX)),
289
+ tags: Object.freeze([...(req.tags ?? [])]),
290
+ context: req.context ?? "",
291
+ paths: Object.freeze([...paths]),
292
+ symbols: Object.freeze([...(req.symbols ?? [])]),
293
+ links: Object.freeze([...(existing?.links ?? [])]),
294
+ sourceKeys: Object.freeze([...new Set([...(existing?.sourceKeys ?? []), ...(req.sourceKeys ?? [])])]),
295
+ };
296
+ // link-on-write: top-4 BM25 neighbors, bidirectional (merge for re-writes)
297
+ const neighbors = this.bm25(note.content, [...this.notes.values()].filter((n) => n.id !== id), LINK_NEIGHBORS);
298
+ const links = [...new Set([...note.links, ...neighbors.map(([n]) => n.id)])];
299
+ this.notes.set(id, { ...note, links: Object.freeze(links) });
300
+ for (const [n] of neighbors) {
301
+ const back = this.notes.get(n.id);
302
+ if (back !== undefined) {
303
+ this.notes.set(n.id, { ...back, links: Object.freeze([...new Set([...back.links, id])]) });
304
+ }
305
+ }
306
+ this.saveNotes();
307
+ return this.notes.get(id);
308
+ }
309
+
310
+ /** BM25 retrieval over note blobs. */
311
+ query(text: string, k = 8): readonly Note[] {
312
+ if (!this.enabled) return [];
313
+ this.load();
314
+ return Object.freeze(this.bm25(text, [...this.notes.values()], k).map(([n]) => n));
315
+ }
316
+
317
+ private bm25(query: string, notes: readonly Note[], k: number): readonly [Note, number][] {
318
+ if (notes.length === 0 || query.trim() === "") return [];
319
+ const qTokens = new Set(tokenize(query));
320
+ if (qTokens.size === 0) return [];
321
+ const docs = notes.map((n) => tokenize(noteBlob(n)));
322
+ const df = new Map<string, number>();
323
+ for (const toks of docs) {
324
+ for (const t of new Set(toks)) df.set(t, (df.get(t) ?? 0) + 1);
325
+ }
326
+ const n = docs.length;
327
+ const avgdl = docs.reduce((s, t) => s + t.length, 0) / Math.max(1, n);
328
+ const k1 = 1.5;
329
+ const b = 0.75;
330
+ const scored = new Array<[Note, number]>(n);
331
+ for (let i = 0; i < n; i++) {
332
+ const toks = docs[i];
333
+ const tf = new Map<string, number>();
334
+ for (const t of toks) tf.set(t, (tf.get(t) ?? 0) + 1);
335
+ const dl = toks.length || 1;
336
+ let score = 0;
337
+ for (const q of qTokens) {
338
+ const f = tf.get(q);
339
+ if (f === undefined) continue;
340
+ const idf = Math.log(1 + (n - (df.get(q) ?? 0) + 0.5) / ((df.get(q) ?? 0) + 0.5));
341
+ score += idf * ((f * (k1 + 1)) / (f + k1 * (1 - b + (b * dl) / avgdl)));
342
+ }
343
+ scored[i] = [notes[i], score];
344
+ }
345
+ scored.sort((a, c) => c[1] - a[1] || (a[0].id < c[0].id ? -1 : 1));
346
+ return scored.slice(0, k).filter(([, s]) => s > 0);
347
+ }
348
+
349
+ /** Batched A-MEM-lite: one prompt over every pending episode → note list; verbatim fallback.
350
+ * Single-flight (audit M2): overlapping recordEpisode bursts share ONE consolidation. */
351
+ async consolidate(): Promise<number> {
352
+ if (this.consolidating !== undefined) return this.consolidating;
353
+ const run = this.doConsolidate().finally(() => {
354
+ this.consolidating = undefined;
355
+ });
356
+ this.consolidating = run;
357
+ return run;
358
+ }
359
+
360
+ private consolidating: Promise<number> | undefined;
361
+
362
+ private async doConsolidate(): Promise<number> {
363
+ if (!this.enabled || this.dir === undefined) return 0;
364
+ this.load();
365
+ // Snapshot the batch we are about to distill. Mid-flight recordEpisode
366
+ // keys must survive — wiping `this.pending = []` at the end dropped them (audit R5).
367
+ const batch = this.pending;
368
+ const pendingEps = batch
369
+ .map((k) => this.episodes.get(k))
370
+ .filter((e): e is Episode => e !== undefined);
371
+ if (pendingEps.length === 0) return 0;
372
+ let made = 0;
373
+ if (this.llm !== undefined) {
374
+ try {
375
+ const payload = JSON.stringify(
376
+ pendingEps.map((e) => ({ prompt: e.prompt.slice(0, 200), answer: e.result.slice(0, 600), paths: e.paths })),
377
+ );
378
+ const raw = await this.llm(
379
+ "Distill the following completed research episodes into durable notes. " +
380
+ "Return STRICT JSON: an array of {content, tags, paths} objects (max 12, one line each, " +
381
+ "no preamble). Episodes:\n" + payload,
382
+ );
383
+ // M2 (audit): try the whole reply as JSON first; only then fall back to slicing the
384
+ // first bracket span out of prose. Item-level validation below rejects junk either way.
385
+ const parsed: unknown = await (async (): Promise<unknown> => {
386
+ try {
387
+ return JSON.parse(raw.trim());
388
+ } catch {
389
+ const slice = raw.slice(raw.indexOf("["), raw.lastIndexOf("]") + 1);
390
+ return slice === "" ? null : JSON.parse(slice);
391
+ }
392
+ })();
393
+ if (Array.isArray(parsed)) {
394
+ for (const item of parsed) {
395
+ if (typeof item === "object" && item !== null) {
396
+ const r = item as Record<string, unknown>;
397
+ if (typeof r.content === "string") {
398
+ this.addNote({
399
+ content: r.content,
400
+ tags: Array.isArray(r.tags) ? r.tags.filter((t): t is string => typeof t === "string") : [],
401
+ paths: Array.isArray(r.paths) ? r.paths.filter((p): p is string => typeof p === "string") : [],
402
+ sourceKeys: pendingEps.map((e) => e.key),
403
+ });
404
+ made++;
405
+ }
406
+ }
407
+ }
408
+ }
409
+ } catch {
410
+ made = 0; // fall through to the verbatim fallback below
411
+ }
412
+ }
413
+ if (made === 0) {
414
+ for (const e of pendingEps) {
415
+ this.addNote({
416
+ content: `${e.prompt.slice(0, 200)} → ${e.result.slice(0, 400)}`,
417
+ paths: e.paths,
418
+ tags: ["episode"],
419
+ sourceKeys: [e.key],
420
+ });
421
+ made++;
422
+ }
423
+ }
424
+ this.pending = this.pending.filter((k) => !batch.includes(k));
425
+ return made;
426
+ }
427
+
428
+ /** v2 rule: silent when empty (v1 burned ~90 chars/turn teaching an empty store). */
429
+ injectBlock(query: string, k = 6): string {
430
+ const notes = this.query(query, k);
431
+ if (notes.length === 0) {
432
+ this.notesInjected = 0;
433
+ return "";
434
+ }
435
+ const budget = this.injectNoteTokens * 4; // chars
436
+ const lines: string[] = [INJECT_HEADER];
437
+ let used = INJECT_HEADER.length;
438
+ let kept = 0;
439
+ for (const n of notes) {
440
+ const chunk = `- ${n.id} tags=${n.tags.join(",") || "-"} paths=${n.paths.join(",") || "-"}: ${n.content.slice(0, NOTE_CONTENT_CHARS)}`;
441
+ if (used + chunk.length > budget) break;
442
+ lines.push(chunk);
443
+ used += chunk.length;
444
+ kept++;
445
+ }
446
+ this.notesInjected = kept;
447
+ return kept > 0 ? lines.join("\n") : "";
448
+ }
449
+
450
+ stats(): MemoryStats {
451
+ return Object.freeze({
452
+ episodes: this.episodes.size,
453
+ notes: this.notes.size,
454
+ hits: this.hits,
455
+ misses: this.misses,
456
+ notesInjected: this.notesInjected,
457
+ });
458
+ }
459
+
460
+ /** The ONE implementation of the sandbox `memory.query/add/stats` surface — the engine
461
+ * and the native repl tool both route their `memoryOp` interrupt here. */
462
+ serviceOp(
463
+ op: "query" | "add" | "stats",
464
+ args: { readonly query?: string; readonly k?: number; readonly content?: string; readonly paths?: readonly string[]; readonly tags?: readonly string[] },
465
+ ): string {
466
+ if (!this.enabled) return "memory disabled";
467
+ if (op === "stats") return JSON.stringify(this.stats());
468
+ if (op === "add") {
469
+ const n = this.addNote({ content: args.content ?? "", paths: args.paths ?? [], tags: args.tags ?? [] });
470
+ return n === undefined ? "add skipped (empty content)" : `ok note ${n.id}`;
471
+ }
472
+ const notes = this.query(args.query ?? "", args.k ?? 8);
473
+ if (notes.length === 0) return "no notes match";
474
+ const lines = new Array<string>(notes.length);
475
+ for (let i = 0; i < notes.length; i++) {
476
+ const n = notes[i];
477
+ lines[i] = `- ${n.id} tags=${n.tags.join(",") || "-"} paths=${n.paths.join(",") || "-"}: ${n.content.slice(0, NOTE_CONTENT_CHARS)}`;
478
+ }
479
+ return lines.join("\n");
480
+ }
481
+
482
+ // ── persistence (fail-soft) ─────────────────────────────────────────────────
483
+
484
+ private saveNotes(): void {
485
+ if (this.dir === undefined) return;
486
+ try {
487
+ mkdirSync(this.dir, { recursive: true });
488
+ const obj: Record<string, Note> = {};
489
+ for (const [id, n] of this.notes) obj[id] = n;
490
+ writeFileSync(join(this.dir, "notes.json"), JSON.stringify(obj));
491
+ } catch {
492
+ // fail-soft
493
+ }
494
+ }
495
+
496
+ private load(): void {
497
+ if (this.loaded || this.dir === undefined) return;
498
+ this.loaded = true;
499
+ try {
500
+ const raw = readFileSync(join(this.dir, "episodes.jsonl"), "utf8");
501
+ for (const line of raw.split("\n")) {
502
+ if (line.trim() === "") continue;
503
+ try {
504
+ const ep = parseEpisode(JSON.parse(line) as unknown);
505
+ if (ep !== undefined) this.episodes.set(ep.key, ep);
506
+ } catch {
507
+ // skip corrupt line — one bad append must not lose the store
508
+ }
509
+ }
510
+ } catch {
511
+ // no episodes yet
512
+ }
513
+ try {
514
+ const rawNotes = JSON.parse(readFileSync(join(this.dir, "notes.json"), "utf8")) as unknown;
515
+ if (typeof rawNotes === "object" && rawNotes !== null) {
516
+ for (const v of Object.values(rawNotes as Record<string, unknown>)) {
517
+ const n = parseNote(v);
518
+ if (n !== undefined) this.notes.set(n.id, n);
519
+ }
520
+ }
521
+ } catch {
522
+ // no notes yet
523
+ }
524
+ }
525
+ }
526
+
527
+ function str(v: unknown, fallback = ""): string {
528
+ return typeof v === "string" ? v : fallback;
529
+ }
530
+ function strArr(v: unknown): readonly string[] {
531
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
532
+ }
533
+ function num(v: unknown): number {
534
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
535
+ }
536
+
537
+ function parseEpisode(v: unknown): Episode | undefined {
538
+ if (typeof v !== "object" || v === null) return undefined;
539
+ const r = v as Record<string, unknown>;
540
+ if (typeof r.key !== "string" || typeof r.result !== "string") return undefined;
541
+ const hashes: Record<string, string> = {};
542
+ if (typeof r.pathHashes === "object" && r.pathHashes !== null) {
543
+ for (const [k, h] of Object.entries(r.pathHashes as Record<string, unknown>)) {
544
+ if (typeof h === "string") hashes[k] = h;
545
+ }
546
+ }
547
+ return {
548
+ key: r.key,
549
+ kind: r.kind === "root" ? "root" : "rlm",
550
+ model: str(r.model),
551
+ prompt: str(r.prompt),
552
+ paths: strArr(r.paths),
553
+ pathHashes: hashes,
554
+ result: r.result,
555
+ tokensIn: num(r.tokensIn),
556
+ tokensOut: num(r.tokensOut),
557
+ ts: num(r.ts),
558
+ };
559
+ }
560
+
561
+ function parseNote(v: unknown): Note | undefined {
562
+ if (typeof v !== "object" || v === null) return undefined;
563
+ const r = v as Record<string, unknown>;
564
+ if (typeof r.id !== "string" || typeof r.content !== "string") return undefined;
565
+ return {
566
+ id: r.id,
567
+ content: r.content,
568
+ timestamp: num(r.timestamp),
569
+ keywords: strArr(r.keywords),
570
+ tags: strArr(r.tags),
571
+ context: str(r.context),
572
+ paths: strArr(r.paths),
573
+ symbols: strArr(r.symbols),
574
+ links: strArr(r.links),
575
+ sourceKeys: strArr(r.sourceKeys),
576
+ };
577
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Model context-window registry (port of rlm_test v4/v5 `models.py`).
3
+ *
4
+ * The plugin already knows context windows from model metadata (`Model.contextWindow`); this
5
+ * registry is the offline fallback for models whose metadata carries none: a conservative
6
+ * static table plus an optional disk cache at `<root>/.rlm/models_cache.json` (24h TTL).
7
+ * All I/O is fail-soft — a missing/corrupt cache degrades to the table, never throws.
8
+ */
9
+
10
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { dirname } from "node:path";
12
+
13
+ /** Conservative offline table (v5 verbatim). Unknown models get UNKNOWN_CONTEXT. */
14
+ const FALLBACK_CONTEXT: Readonly<Record<string, number>> = Object.freeze({
15
+ "openai/gpt-5": 400_000,
16
+ "openai/gpt-5-mini": 400_000,
17
+ "anthropic/claude-sonnet-4.5": 200_000,
18
+ "google/gemini-2.5-pro": 1_000_000,
19
+ "qwen/qwen3-coder": 262_000,
20
+ "deepseek/deepseek-chat": 128_000,
21
+ });
22
+
23
+ export const UNKNOWN_CONTEXT = 32_000;
24
+ const CACHE_TTL_MS = 86_400_000; // 24h
25
+ const CACHE_MAX_BYTES = 1 << 20; // refuse absurd caches rather than parse them
26
+
27
+ interface CacheEntry {
28
+ readonly ctx: number;
29
+ readonly ts: number;
30
+ }
31
+ type CacheFile = Readonly<Record<string, CacheEntry>>;
32
+
33
+ /** `<root>/.rlm/models_cache.json` — the single cache path helper (also used by memory). */
34
+ export function modelsCachePath(root: string): string {
35
+ return `${root.replace(/\/+$/, "")}/.rlm/models_cache.json`;
36
+ }
37
+
38
+ export class ModelContextRegistry {
39
+ private cache: CacheFile | undefined;
40
+ private cacheLoaded = false;
41
+
42
+ constructor(private readonly cachePath: string | undefined) {}
43
+
44
+ /** Context window for "provider/id", falling back through cache → table → 32k. */
45
+ limitFor(modelId: string): number {
46
+ const hit = this.readCache()[modelId];
47
+ if (hit !== undefined && Date.now() - hit.ts < CACHE_TTL_MS && hit.ctx > 0) return hit.ctx;
48
+ return FALLBACK_CONTEXT[modelId] ?? UNKNOWN_CONTEXT;
49
+ }
50
+
51
+ /** Record a freshly observed window (fail-soft: a failed write only skips the cache). */
52
+ observe(modelId: string, ctx: number): boolean {
53
+ if (!(ctx > 0)) return false;
54
+ const next: Record<string, CacheEntry> = { ...this.readCache(), [modelId]: { ctx, ts: Date.now() } };
55
+ this.cache = next;
56
+ if (this.cachePath === undefined) return false;
57
+ try {
58
+ mkdirSync(dirname(this.cachePath), { recursive: true });
59
+ writeFileSync(this.cachePath, JSON.stringify(next));
60
+ return true;
61
+ } catch {
62
+ return false; // fail-soft: warn-free degradation to the static table
63
+ }
64
+ }
65
+
66
+ private readCache(): CacheFile {
67
+ if (this.cacheLoaded) return this.cache ?? {};
68
+ this.cacheLoaded = true;
69
+ if (this.cachePath === undefined) return {};
70
+ try {
71
+ const raw = readFileSync(this.cachePath, "utf8");
72
+ if (raw.length > CACHE_MAX_BYTES) return {};
73
+ const parsed: unknown = JSON.parse(raw);
74
+ if (typeof parsed !== "object" || parsed === null) return {};
75
+ const out: Record<string, CacheEntry> = {};
76
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
77
+ if (typeof v === "object" && v !== null) {
78
+ const e = v as Record<string, unknown>;
79
+ if (typeof e.ctx === "number" && typeof e.ts === "number") out[k] = { ctx: e.ctx, ts: e.ts };
80
+ }
81
+ }
82
+ this.cache = out;
83
+ return out;
84
+ } catch {
85
+ return {}; // missing or corrupt → static table
86
+ }
87
+ }
88
+ }