@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.
- package/package.json +1 -1
- package/src/bridge/handlers/llm-query.ts +73 -17
- package/src/bridge/handlers/rlm-query.ts +130 -8
- package/src/bridge/handlers/types.ts +12 -0
- package/src/config/defaults.ts +17 -0
- package/src/config/settings.ts +45 -2
- package/src/core/answer.ts +7 -10
- package/src/core/budget.ts +182 -0
- package/src/core/compaction.ts +46 -0
- package/src/core/engine.ts +171 -5
- package/src/core/ledger.ts +451 -0
- package/src/core/memory.ts +577 -0
- package/src/core/model-registry.ts +88 -0
- package/src/core/types.ts +44 -3
- package/src/index.ts +60 -9
- package/src/mode/rlm-mode.ts +47 -9
- package/src/prompts/glossary.ts +139 -57
- package/src/prompts/native.ts +12 -7
- package/src/prompts/system.ts +22 -7
- package/src/prompts/user.ts +6 -3
- package/src/sandbox/interrupts.ts +24 -0
- package/src/sandbox/protocol.ts +69 -5
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +11 -6
- package/src/sandbox/py/scaffold.py +615 -0
- package/src/sandbox/py/worker.py +53 -506
- package/src/sandbox/sandbox.ts +21 -3
- package/src/text/repl-output.ts +15 -0
- package/src/tool/repl-result.ts +54 -10
- package/src/tool/repl-tool.ts +43 -9
- package/src/ui/status.ts +4 -1
- package/src/util/concurrency.ts +47 -0
|
@@ -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
|
+
}
|