@hicaru/pi-rlm 0.3.15 → 0.3.17

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.
Files changed (90) hide show
  1. package/README.md +96 -70
  2. package/README.ru.md +86 -59
  3. package/README.zh-CN.md +95 -65
  4. package/package.json +5 -5
  5. package/src/bridge/add-context.ts +1 -1
  6. package/src/bridge/handlers/await.ts +13 -22
  7. package/src/bridge/handlers/completion.ts +27 -5
  8. package/src/bridge/handlers/emitting.ts +2 -2
  9. package/src/bridge/handlers/llm-query.ts +46 -68
  10. package/src/bridge/handlers/rlm-query.ts +14 -84
  11. package/src/bridge/handlers/task-registry.ts +22 -17
  12. package/src/bridge/handlers/types.ts +8 -6
  13. package/src/bridge/model.ts +21 -4
  14. package/src/commands/rlm-llm.ts +1 -10
  15. package/src/commands/rlm-rlm.ts +1 -8
  16. package/src/config/defaults.ts +31 -12
  17. package/src/config/settings.ts +47 -33
  18. package/src/config/skillstate.ts +465 -0
  19. package/src/context/md-cache.ts +1 -1
  20. package/src/context/merge.ts +1 -1
  21. package/src/context/namespace.ts +2 -2
  22. package/src/context/refresh.ts +1 -1
  23. package/src/context/source-dir.ts +21 -11
  24. package/src/context/source-doc.ts +1 -1
  25. package/src/context/source-git.ts +3 -15
  26. package/src/context/source-text.ts +1 -1
  27. package/src/context/walk.ts +6 -14
  28. package/src/core/budget.ts +107 -21
  29. package/src/core/compaction.ts +44 -1
  30. package/src/core/engine.ts +192 -94
  31. package/src/core/iteration.ts +1 -1
  32. package/src/core/ledger.ts +10 -13
  33. package/src/core/limits.ts +1 -1
  34. package/src/core/model-registry.ts +1 -1
  35. package/src/core/resource-limits.ts +1 -1
  36. package/src/core/root-context.ts +126 -0
  37. package/src/core/root-digest.ts +213 -0
  38. package/src/core/root-state.ts +240 -0
  39. package/src/core/run-state.ts +577 -0
  40. package/src/core/types.ts +56 -12
  41. package/src/index.ts +167 -36
  42. package/src/mode/llm-model.ts +13 -1
  43. package/src/mode/native-guards.ts +0 -6
  44. package/src/mode/rlm-mode.ts +34 -11
  45. package/src/mode/subagent.ts +5 -5
  46. package/src/prompts/glossary.ts +41 -25
  47. package/src/prompts/native.ts +1 -3
  48. package/src/prompts/system.ts +12 -4
  49. package/src/prompts/user.ts +17 -0
  50. package/src/sandbox/context-file.ts +1 -1
  51. package/src/sandbox/interrupts.ts +25 -31
  52. package/src/sandbox/protocol.ts +14 -20
  53. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  54. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  55. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  56. package/src/sandbox/py/__pycache__/worker.cpython-314.pyc +0 -0
  57. package/src/sandbox/py/guards.py +1 -1
  58. package/src/sandbox/py/retrieval.py +4 -1
  59. package/src/sandbox/py/scaffold.py +24 -31
  60. package/src/sandbox/py/worker.py +3 -1
  61. package/src/sandbox/sandbox-manager.ts +2 -2
  62. package/src/sandbox/sandbox.ts +35 -5
  63. package/src/text/agent-text.ts +58 -0
  64. package/src/text/parsing.ts +35 -3
  65. package/src/text/preview.ts +3 -0
  66. package/src/text/repl-output.ts +1 -1
  67. package/src/tool/background-tasks.ts +1 -1
  68. package/src/tool/repl-render.ts +1 -1
  69. package/src/tool/repl-result.ts +1 -1
  70. package/src/tool/repl-tool.ts +50 -26
  71. package/src/tool/rlm-tool.ts +4 -5
  72. package/src/tool/subcall-render.ts +1 -1
  73. package/src/tool/subcall-store.ts +2 -2
  74. package/src/tool/tool-utils.ts +5 -5
  75. package/src/ui/config-panel.ts +39 -0
  76. package/src/ui/intro.ts +1 -1
  77. package/src/ui/modal/timeline-store.ts +1 -1
  78. package/src/ui/model-picker/drilldown.ts +1 -1
  79. package/src/ui/model-picker/levels.ts +1 -1
  80. package/src/ui/panel/run-registry.ts +1 -1
  81. package/src/ui/tree/tree-rows.ts +1 -1
  82. package/src/ui/tree/tree-widget.ts +1 -1
  83. package/src/util/bm25.ts +97 -0
  84. package/src/util/concurrency.ts +1 -1
  85. package/src/util/errors.ts +1 -1
  86. package/src/util/retry.ts +22 -7
  87. package/src/util/state-merge.ts +34 -0
  88. package/src/util/throttle.ts +1 -1
  89. package/src/util/type-guards.ts +6 -0
  90. package/src/core/memory.ts +0 -589
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Workstream B — SkillState: the persistent cross-session distilled-knowledge store.
3
+ *
4
+ * One file per agent (`rlm-skillstate.json`), one section per project (cwd fingerprint). Notes
5
+ * are A-Mem-shaped (minus embeddings — BM25 lexical scoring only): write-time keywords/tags/
6
+ * contextual description, reinforcement by re-encounter (`hits`), LRU eviction with the
7
+ * top-hits quartile pinned.
8
+ *
9
+ * Persistence mirrors `config/settings.ts` exactly — the documented template, not copied
10
+ * logic: fail-soft reader narrowing `JSON.parse(…) as unknown` through a type guard, fail-soft
11
+ * boolean writer (`mkdir` → `writeFile` → `true | false`), frozen empty value on miss/corrupt.
12
+ * The three-state pin semantics of settings (`undefined` = merge-from-disk, `null` = explicit
13
+ * clear) do not apply here: the session-scoped SkillStore is the single writer — it hydrates
14
+ * the whole file, mutates its own project section, and flushes the whole file back, so other
15
+ * projects' sections ride along untouched.
16
+ */
17
+
18
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
21
+ import { bm25Rank } from "../util/bm25.ts";
22
+ import { deepMergeWithNullDeletion } from "../util/state-merge.ts";
23
+ import { formatError } from "../util/errors.ts";
24
+ import { isRecord } from "../util/type-guards.ts";
25
+ import type { RunState } from "../core/run-state.ts";
26
+ import type { RlmConfig } from "../core/types.ts";
27
+ import { skillStateLines } from "../prompts/glossary.ts";
28
+
29
+ export const SKILL_STATE_FILE = "rlm-skillstate.json";
30
+
31
+ /** A-Mem-derived note (no embedding): write-time annotation + reinforcement counter. */
32
+ export interface SkillNote {
33
+ /** Claim key — hash(project + normalized text); ledger-style dedup id. */
34
+ readonly id: string;
35
+ /** ≤240 chars, factual, path-anchored. */
36
+ readonly text: string;
37
+ /** A-Mem K_i. */
38
+ readonly keywords: readonly string[];
39
+ /** A-Mem G_i — "config" | "gotcha" | "symbol" | "recipe". */
40
+ readonly tags: readonly string[];
41
+ /** A-Mem X_i — why it matters (≤120 chars). */
42
+ readonly context: string;
43
+ /** Reinforcement count: a duplicate write bumps this instead of duplicating. */
44
+ readonly hits: number;
45
+ readonly ts: number;
46
+ }
47
+
48
+ export interface SkillStateFile {
49
+ readonly version: 1;
50
+ /** key: cwd fingerprint (projectFingerprint of the absolute cwd). */
51
+ readonly projects: Readonly<Record<string, readonly SkillNote[]>>;
52
+ }
53
+
54
+ export const EMPTY_SKILL_STATE: SkillStateFile = Object.freeze({
55
+ version: 1,
56
+ projects: Object.freeze({}),
57
+ });
58
+
59
+ const TAGS: ReadonlySet<string> = new Set(["config", "gotcha", "symbol", "recipe"]);
60
+ const NOTE_MAX_CHARS = 240;
61
+ const CONTEXT_MAX_CHARS = 120;
62
+ const KEYWORDS_MAX = 6;
63
+
64
+ export function skillStatePath(dir?: string): string {
65
+ return join(dir ?? getAgentDir(), SKILL_STATE_FILE);
66
+ }
67
+
68
+ /** Deterministic project key — stable across sessions on the same machine. */
69
+ export function projectFingerprint(cwd: string): string {
70
+ return fnv1aHex(resolve(cwd), 0x811c9dc5).slice(0, 12);
71
+ }
72
+
73
+ /**
74
+ * Root Σ WS-1: the BM25 query for a root Ξ composition — the LIVE user prompt wins so
75
+ * mid-session harvested notes rank against what the user is actually asking; the static
76
+ * system-prompt slice is only the empty-prompt fallback (prior behavior).
77
+ */
78
+ export function xiQuery(prompt: string, fallback: string): string {
79
+ const trimmed = prompt.trim();
80
+ return trimmed !== "" ? trimmed : fallback;
81
+ }
82
+
83
+ /** FNV-1a hex; two passes for long ids. Never cryptographic — dedup keys only. */
84
+ function fnv1aHex(text: string, seed: number): string {
85
+ let hash = seed | 0;
86
+ for (let i = 0; i < text.length; i++) {
87
+ hash ^= text.charCodeAt(i);
88
+ hash = Math.imul(hash, 0x01000193);
89
+ }
90
+ return (hash >>> 0).toString(16).padStart(8, "0");
91
+ }
92
+
93
+ function noteId(project: string, text: string): string {
94
+ const normalized = text.trim().toLowerCase().replace(/\s+/g, " ");
95
+ return `${fnv1aHex(`${project}\u0000${normalized}`, 0x811c9dc5)}${fnv1aHex(normalized, 0x01000193)}`;
96
+ }
97
+
98
+ function normalizeTags(tags: readonly string[] | undefined): readonly string[] {
99
+ if (tags === undefined) return ["symbol"];
100
+ const out: string[] = [];
101
+ for (const tag of tags) {
102
+ if (TAGS.has(tag) && !out.includes(tag)) out.push(tag);
103
+ }
104
+ return out.length > 0 ? out : ["symbol"];
105
+ }
106
+
107
+ export function isSkillNote(value: unknown): value is SkillNote {
108
+ if (!isRecord(value)) return false;
109
+ return (
110
+ typeof value.id === "string" &&
111
+ typeof value.text === "string" &&
112
+ isStringArray(value.keywords) &&
113
+ isStringArray(value.tags) &&
114
+ typeof value.context === "string" &&
115
+ typeof value.hits === "number" &&
116
+ typeof value.ts === "number"
117
+ );
118
+ }
119
+
120
+ export function isSkillStateFile(value: unknown): value is SkillStateFile {
121
+ if (!isRecord(value) || value.version !== 1 || !isRecord(value.projects)) return false;
122
+ for (const notes of Object.values(value.projects)) {
123
+ if (!Array.isArray(notes)) return false;
124
+ // Corrupt notes are dropped, not fatal (fail-soft read discipline).
125
+ if (!notes.every((n) => isSkillNote(n))) return false;
126
+ }
127
+ return true;
128
+ }
129
+
130
+ function isStringArray(value: unknown): value is readonly string[] {
131
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
132
+ }
133
+
134
+ /** Fail-soft reader — mirrors settings.ts:loadSettings. Corrupt/missing ⇒ frozen empty. */
135
+ export async function loadSkillState(dir?: string): Promise<SkillStateFile> {
136
+ try {
137
+ const raw = JSON.parse(await readFile(skillStatePath(dir), "utf8")) as unknown;
138
+ if (isSkillStateFile(raw)) return raw;
139
+ return EMPTY_SKILL_STATE;
140
+ } catch {
141
+ return EMPTY_SKILL_STATE;
142
+ }
143
+ }
144
+
145
+ /** Fail-soft boolean writer — mirrors settings.ts:saveSettings. */
146
+ export async function saveSkillState(s: SkillStateFile, dir?: string): Promise<boolean> {
147
+ try {
148
+ const p = skillStatePath(dir);
149
+ await mkdir(dirname(p), { recursive: true });
150
+ await writeFile(p, JSON.stringify(s));
151
+ return true;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ // ── Distillation: Σ → notes (the construction half of the one interface) ────────────────
158
+
159
+ /** Input note shape accepted by SkillStore.merge — ids are computed, never supplied. */
160
+ export interface SkillNoteInput {
161
+ readonly text: string;
162
+ readonly keywords?: readonly string[];
163
+ readonly tags?: readonly string[];
164
+ readonly context?: string;
165
+ }
166
+
167
+ const PATH_TOKEN = /(?:[\w@.-]+\/)+[\w@.-]+/g;
168
+ const CAMEL_SPLIT = /(?<=[a-z0-9])(?=[A-Z])/;
169
+
170
+ /** Deterministic keywords: path tokens + camelCase parts + long words, deduped, ≤6. */
171
+ function keywordsOf(text: string): readonly string[] {
172
+ const out: string[] = [];
173
+ const push = (word: string): void => {
174
+ const w = word.trim();
175
+ if (w.length >= 3 && !out.includes(w) && out.length < KEYWORDS_MAX) out.push(w);
176
+ };
177
+ for (const match of text.matchAll(PATH_TOKEN)) push(match[0]);
178
+ for (const raw of text.split(/[^0-9A-Za-z]+/)) {
179
+ if (raw.length > 3) {
180
+ const parts = raw.split(CAMEL_SPLIT);
181
+ if (parts.length > 1) for (const part of parts) push(part.toLowerCase());
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * Workstream B Hook 1 (deterministic half): harvest Σ into note inputs — zero extra tokens.
189
+ * verifiedFacts → "symbol" notes; successful approaches → "recipe" notes.
190
+ */
191
+ export function notesFromRunState(state: RunState): readonly SkillNoteInput[] {
192
+ const notes: SkillNoteInput[] = [];
193
+ for (const fact of state.verifiedFacts) {
194
+ if (fact.trim().length < 8) continue;
195
+ notes.push({
196
+ text: fact.trim().slice(0, NOTE_MAX_CHARS),
197
+ keywords: keywordsOf(fact),
198
+ tags: ["symbol"],
199
+ context: state.task.slice(0, CONTEXT_MAX_CHARS),
200
+ });
201
+ }
202
+ for (const [key, outcome] of Object.entries(state.testedApproaches)) {
203
+ if (outcome.status !== "succeeded") continue;
204
+ const text = `${key}: ${outcome.evidence}`.slice(0, NOTE_MAX_CHARS);
205
+ notes.push({
206
+ text,
207
+ keywords: keywordsOf(text),
208
+ tags: ["recipe"],
209
+ context: state.task.slice(0, CONTEXT_MAX_CHARS),
210
+ });
211
+ }
212
+ return notes;
213
+ }
214
+
215
+ /** A-Mem phrasing prompt (Hook 1, LLM half — enableSkillStateDistill, default ON). */
216
+ export function distillPromptFor(state: RunState): string {
217
+ return [
218
+ "Distill AT MOST 6 durable, reusable project facts from this run. One per line, exactly:",
219
+ "text | kw1, kw2 | tag",
220
+ 'tag ∈ {config, gotcha, symbol, recipe}. text ≤240 chars, factual, path-anchored. No preamble.',
221
+ "",
222
+ `Run task: ${state.task}`,
223
+ "Verified facts:",
224
+ ...state.verifiedFacts.slice(-12).map((f) => `- ${f}`),
225
+ ].join("\n");
226
+ }
227
+
228
+ /** Defensive parser for the distill leaf's output — anything malformed is dropped. */
229
+ export function parseDistilledNotes(raw: string): readonly SkillNoteInput[] {
230
+ const out: SkillNoteInput[] = [];
231
+ for (const line of raw.split("\n")) {
232
+ const trimmed = line.trim().replace(/^-\s*/, "");
233
+ if (trimmed === "") continue;
234
+ const parts = trimmed.split("|");
235
+ if (parts.length < 2) continue;
236
+ const text = parts[0].trim();
237
+ if (text.length < 8) continue;
238
+ const keywords = (parts[1] ?? "")
239
+ .split(",")
240
+ .map((k) => k.trim())
241
+ .filter((k) => k.length >= 3)
242
+ .slice(0, KEYWORDS_MAX);
243
+ const tags = (parts[2] ?? "")
244
+ .split(/[\s,]+/)
245
+ .map((t) => t.trim())
246
+ .filter((t) => TAGS.has(t));
247
+ out.push({ text: text.slice(0, NOTE_MAX_CHARS), keywords, tags: normalizeTags(tags) });
248
+ if (out.length >= 6) break;
249
+ }
250
+ return out;
251
+ }
252
+
253
+ // ── Read path: BM25 selection for Ξ, leaf grounding, and skill_search ───────────────────
254
+
255
+ function noteCorpus(note: SkillNote): string {
256
+ return `${note.text} ${note.keywords.join(" ")} ${note.tags.join(" ")} ${note.context}`;
257
+ }
258
+
259
+ export interface SkillSearchHit {
260
+ readonly id: string;
261
+ readonly text: string;
262
+ readonly tags: readonly string[];
263
+ readonly score: number;
264
+ }
265
+
266
+ /**
267
+ * The session-scoped store — hydrated once at session_start, flushed at session_shutdown.
268
+ * All recall (Ξ block, leaf grounding, skill_search) reads through the same BM25 rank.
269
+ */
270
+ export class SkillStore {
271
+ private constructor(
272
+ private file: SkillStateFile,
273
+ private readonly project: string,
274
+ private readonly notesPerProject: number,
275
+ private readonly dir: string | undefined,
276
+ private dirty = false,
277
+ ) {}
278
+
279
+ static async hydrate(notesPerProject: number, dir?: string): Promise<SkillStore> {
280
+ return new SkillStore(
281
+ await loadSkillState(dir),
282
+ projectFingerprint(process.cwd()),
283
+ Math.max(1, Math.floor(notesPerProject)),
284
+ dir,
285
+ );
286
+ }
287
+
288
+ get projectKey(): string {
289
+ return this.project;
290
+ }
291
+
292
+ get noteCount(): number {
293
+ return this.file.projects[this.project]?.length ?? 0;
294
+ }
295
+
296
+ /** Test/telemetry seam — the exact on-disk shape a flush would write. */
297
+ snapshot(): SkillStateFile {
298
+ return this.file;
299
+ }
300
+
301
+ /** Workstream E: the sandbox skill_search surface. Score > 0 hits only, best first. */
302
+ search(query: string, k = 8): readonly SkillSearchHit[] {
303
+ const notes = this.file.projects[this.project] ?? [];
304
+ if (notes.length === 0 || query.trim() === "") return [];
305
+ const ranked = bm25Rank(
306
+ query,
307
+ notes.map((note) => ({ item: note, text: noteCorpus(note) })),
308
+ Math.max(1, Math.min(32, k)),
309
+ );
310
+ return ranked.map(({ item, score }) => ({
311
+ id: item.id,
312
+ text: item.text,
313
+ tags: item.tags,
314
+ score: Math.round(score * 100) / 100,
315
+ }));
316
+ }
317
+
318
+ /** Ξ body lines shared by blockFor/sliceForPrompt — packed greedily under the char budget. */
319
+ private pack(
320
+ query: string,
321
+ k: number,
322
+ budgetChars: number,
323
+ minScore: number,
324
+ ): readonly string[] {
325
+ const notes = this.file.projects[this.project] ?? [];
326
+ if (notes.length === 0) return [];
327
+ const ranked = bm25Rank(
328
+ query,
329
+ notes.map((note) => ({ item: note, text: noteCorpus(note) })),
330
+ Math.min(k, notes.length),
331
+ );
332
+ const lines: string[] = [];
333
+ let used = 0;
334
+ for (const { item, score } of ranked) {
335
+ if (score < minScore) break; // ranked desc — the first miss ends the window
336
+ const line = `- (${item.tags[0] ?? "symbol"}) ${item.text}`;
337
+ if (used + line.length + 1 > budgetChars) continue; // too fat — try the next, smaller
338
+ lines.push(line);
339
+ used += line.length + 1;
340
+ }
341
+ return lines;
342
+ }
343
+
344
+ /**
345
+ * Workstream C: the full Ξ block for a root prompt (header via skillStateLines — the single
346
+ * wording source). "" when nothing is relevant or the store is empty.
347
+ */
348
+ blockFor(query: string, budgetTokens: number): string {
349
+ const lines = this.pack(query, 24, Math.max(0, budgetTokens) * 4, Number.MIN_VALUE);
350
+ if (lines.length === 0) return "";
351
+ return skillStateLines(lines.length, lines.join("\n"));
352
+ }
353
+
354
+ /**
355
+ * Workstream D: grounded leaf lines. "" unless the top BM25 score clears `minScore`
356
+ * (below-threshold prompts stay byte-identical — grounding must never add noise).
357
+ */
358
+ sliceForPrompt(query: string, budgetTokens: number, minScore: number): string {
359
+ const lines = this.pack(query, 8, Math.max(0, budgetTokens) * 4, minScore);
360
+ return lines.join("\n");
361
+ }
362
+
363
+ /**
364
+ * Dedup write: duplicate (by normalized text) bumps `hits` and refreshes `ts`; new notes
365
+ * insert. Per-project cap with LRU-by-ts eviction, top-hits quartile pinned.
366
+ */
367
+ merge(inputs: readonly SkillNoteInput[]): void {
368
+ if (inputs.length === 0) return;
369
+ const existing = this.file.projects[this.project] ?? [];
370
+ const byId = new Map<string, SkillNote>(existing.map((note) => [note.id, note]));
371
+ const now = Date.now();
372
+ for (const input of inputs) {
373
+ const text = input.text.trim();
374
+ if (text === "") continue;
375
+ const id = noteId(this.project, text);
376
+ const prev = byId.get(id);
377
+ if (prev !== undefined) {
378
+ // Reinforcement, not duplication. The one merge (state-merge.ts) composes the update;
379
+ // the result is re-narrowed — a malformed merge outcome keeps the previous note.
380
+ const mergedKeywords = [...prev.keywords];
381
+ for (const keyword of input.keywords ?? []) {
382
+ if (!mergedKeywords.includes(keyword) && mergedKeywords.length < KEYWORDS_MAX) {
383
+ mergedKeywords.push(keyword);
384
+ }
385
+ }
386
+ const merged = deepMergeWithNullDeletion(prev, {
387
+ hits: prev.hits + 1,
388
+ ts: now,
389
+ keywords: mergedKeywords,
390
+ });
391
+ byId.set(id, isSkillNote(merged) ? merged : prev);
392
+ } else {
393
+ const note: SkillNote = {
394
+ id,
395
+ text: text.slice(0, NOTE_MAX_CHARS),
396
+ keywords: (input.keywords ?? []).slice(0, KEYWORDS_MAX),
397
+ tags: normalizeTags(input.tags),
398
+ context: (input.context ?? "").slice(0, CONTEXT_MAX_CHARS),
399
+ hits: 1,
400
+ ts: now,
401
+ };
402
+ byId.set(id, note);
403
+ }
404
+ }
405
+ let notes = [...byId.values()];
406
+ if (notes.length > this.notesPerProject) {
407
+ // Pinned: top quartile by hits (ceil) — frequently-reinforced facts survive eviction.
408
+ const byHits = [...notes].sort((a, b) => b.hits - a.hits || b.ts - a.ts);
409
+ const pinned = new Set(
410
+ byHits.slice(0, Math.max(1, Math.ceil(byHits.length / 4))).map((note) => note.id),
411
+ );
412
+ const evictable = notes
413
+ .filter((note) => !pinned.has(note.id))
414
+ .sort((a, b) => a.ts - b.ts);
415
+ const evict = new Set(evictable.slice(0, notes.length - this.notesPerProject).map((note) => note.id));
416
+ notes = notes.filter((note) => !evict.has(note.id));
417
+ }
418
+ this.file = { version: 1, projects: { ...this.file.projects, [this.project]: notes } };
419
+ this.dirty = true;
420
+ }
421
+
422
+ /** Flush to disk (fail-soft). Other projects' sections ride along from the hydrated file. */
423
+ async flush(): Promise<boolean> {
424
+ if (!this.dirty) return true;
425
+ const written = await saveSkillState(this.file, this.dir);
426
+ if (written) this.dirty = false;
427
+ return written;
428
+ }
429
+ }
430
+
431
+ // ── Wiring helpers (one implementation each — both composition roots reuse these) ────────
432
+
433
+ /**
434
+ * Workstream D leaf grounding — THE implementation complete1 delegates to via
435
+ * `SubcallHandlerDeps.groundLeaf`. Below-threshold ⇒ byte-identical prompt.
436
+ */
437
+ export function groundLeafPrompt(
438
+ store: SkillStore,
439
+ config: Pick<RlmConfig, "skillStateLeafTokens" | "skillStateMinScore">,
440
+ prompt: string,
441
+ ): string {
442
+ const slice = store.sliceForPrompt(prompt, config.skillStateLeafTokens, config.skillStateMinScore);
443
+ return slice === "" ? prompt : `[Project facts]\n${slice}\n\n${prompt}`;
444
+ }
445
+
446
+ /**
447
+ * Workstream E host handler — the ONE skill_search implementation, served to both sandboxes
448
+ * (engine + repl tool). Unwired/disabled stores reply with an error string, never a throw.
449
+ */
450
+ export function skillSearchHandler(
451
+ getStore: () => SkillStore | undefined,
452
+ ): (query: string, k: number, depth: number) => Promise<string> {
453
+ return async (query, k) => {
454
+ const store = getStore();
455
+ if (store === undefined) return formatError("skill state store not configured");
456
+ try {
457
+ return JSON.stringify(store.search(query, k));
458
+ } catch (err: unknown) {
459
+ return formatError(err instanceof Error ? err.message : String(err));
460
+ }
461
+ };
462
+ }
463
+
464
+ /** The config slice groundLeafPrompt needs — callers pass their live RlmConfig. */
465
+ export type GroundLeafConfig = Pick<RlmConfig, "skillStateLeafTokens" | "skillStateMinScore">;
@@ -19,7 +19,7 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
19
19
  import { basename, join } from "node:path";
20
20
  import { homedir } from "node:os";
21
21
 
22
- export interface FileStamp {
22
+ interface FileStamp {
23
23
  readonly size: number;
24
24
  readonly mtimeMs: number;
25
25
  }
@@ -14,7 +14,7 @@ import {
14
14
  } from "./namespace.ts";
15
15
  import type { ContextFile } from "./types.ts";
16
16
 
17
- export interface FilteredContext {
17
+ interface FilteredContext {
18
18
  readonly files: readonly ContextFile[];
19
19
  /** Prefixes that selected zero files — the caller decides whether that is fatal. */
20
20
  readonly unmatched: readonly string[];
@@ -10,7 +10,7 @@ import { basename, isAbsolute, resolve } from "node:path";
10
10
  import { estimateTokens } from "../text/tokens.ts";
11
11
  import { LEGACY_UNKNOWN_PREFIX, type ContextFile } from "./types.ts";
12
12
 
13
- export interface ContextNamespace {
13
+ interface ContextNamespace {
14
14
  readonly sourceId: string;
15
15
  readonly pathPrefix: string;
16
16
  }
@@ -105,7 +105,7 @@ export function namespaceContextFilesWithChars(
105
105
  for (const item of payload) {
106
106
  if (item === null || typeof item !== "object") continue;
107
107
  const rec = item as Record<string, unknown>;
108
- const content = typeof rec.content === "string" ? rec.content : String(rec.content ?? "");
108
+ const content = typeof rec.content === "string" ? rec.content : "";
109
109
  let path = typeof rec.path === "string" ? rec.path : "unknown";
110
110
  path = applyPathPrefix(path, prefix);
111
111
  const tokens = typeof rec.tokens === "number" && Number.isFinite(rec.tokens)
@@ -69,7 +69,7 @@ export function upsertContextFile(
69
69
  item !== null &&
70
70
  typeof item === "object" &&
71
71
  "path" in item &&
72
- typeof (item as { path: unknown }).path === "string" &&
72
+ typeof (item).path === "string" &&
73
73
  pathMatches((item as { path: string }).path, path, cwd)
74
74
  ) {
75
75
  next[n++] = entry;
@@ -29,7 +29,7 @@ import { applyPathPrefix, contextSourceId, pathPrefixFor } from "./namespace.ts"
29
29
  /** Conversions are libuv-threadpool bound (default pool 4); 8 keeps the pool busy without thrash. */
30
30
  const CONVERT_CONCURRENCY = 8;
31
31
 
32
- export interface PackDirResult {
32
+ interface PackDirResult {
33
33
  readonly files: readonly ContextFile[];
34
34
  readonly chars: number;
35
35
  readonly documents: number;
@@ -37,6 +37,25 @@ export interface PackDirResult {
37
37
  readonly skipped: readonly SkippedFile[];
38
38
  }
39
39
 
40
+ /** DRY: the ONE PackDirResult → SourceResult flattening — shared by the local-dir source and
41
+ * the git-clone source (which wraps it in Result). Never inline a second copy. */
42
+ export function packToSourceResult(
43
+ packed: PackDirResult,
44
+ sourceId: string,
45
+ pathPrefix: string,
46
+ ): SourceResult {
47
+ return Object.freeze({
48
+ payload: packed.files,
49
+ files: packed.files.length,
50
+ chars: packed.chars,
51
+ sourceId,
52
+ pathPrefix,
53
+ documents: packed.documents,
54
+ converted: packed.converted,
55
+ skipped: packed.skipped,
56
+ });
57
+ }
58
+
40
59
  /**
41
60
  * Walk `dir` and produce ContextFiles. Resolves the anydoc handle once per source
42
61
  * (not per file) and threads it into the per-file router.
@@ -153,14 +172,5 @@ export async function sourceDir(
153
172
  // Explicit pathPrefix (including "") wins; otherwise derive ctx/<id>/.
154
173
  const pathPrefix = opts.pathPrefix !== undefined ? opts.pathPrefix : pathPrefixFor(sourceId);
155
174
  const packed = await packDirectory(dir, pathPrefix, opts.signal);
156
- return Object.freeze({
157
- payload: packed.files,
158
- files: packed.files.length,
159
- chars: packed.chars,
160
- sourceId,
161
- pathPrefix,
162
- documents: packed.documents,
163
- converted: packed.converted,
164
- skipped: packed.skipped,
165
- });
175
+ return packToSourceResult(packed, sourceId, pathPrefix);
166
176
  }
@@ -16,7 +16,7 @@ import {
16
16
  type SkippedFile,
17
17
  } from "./types.ts";
18
18
 
19
- export type DocFileResult =
19
+ type DocFileResult =
20
20
  | { readonly ok: true; readonly value: ContextFile; /** true only for a real anydoc conversion */ readonly converted: boolean }
21
21
  | { readonly ok: false; readonly skipped: SkippedFile };
22
22
 
@@ -2,18 +2,15 @@
2
2
  * Shallow-clone a git URL, then pack with source-dir.
3
3
  */
4
4
 
5
- import { execFile } from "node:child_process";
6
5
  import { mkdtemp, rm } from "node:fs/promises";
7
6
  import { tmpdir } from "node:os";
8
7
  import { join } from "node:path";
9
- import { promisify } from "node:util";
10
8
  import { errorMessage, type Result } from "../util/errors.ts";
9
+ import { execFileP } from "./walk.ts";
11
10
  import { contextSourceId, pathPrefixFor } from "./namespace.ts";
12
- import { packDirectory } from "./source-dir.ts";
11
+ import { packDirectory, packToSourceResult } from "./source-dir.ts";
13
12
  import type { ResolveOpts, SourceResult } from "./types.ts";
14
13
 
15
- const execFileP = promisify(execFile);
16
-
17
14
  /** Shallow-clone `url` into a temp dir, pack, then remove the clone. */
18
15
  export async function sourceGit(
19
16
  url: string,
@@ -32,16 +29,7 @@ export async function sourceGit(
32
29
  const packed = await packDirectory(dir, pathPrefix, opts.signal);
33
30
  return {
34
31
  ok: true,
35
- value: Object.freeze({
36
- payload: packed.files,
37
- files: packed.files.length,
38
- chars: packed.chars,
39
- sourceId,
40
- pathPrefix,
41
- documents: packed.documents,
42
- converted: packed.converted,
43
- skipped: packed.skipped,
44
- }),
32
+ value: packToSourceResult(packed, sourceId, pathPrefix),
45
33
  };
46
34
  } catch (err: unknown) {
47
35
  return { ok: false, error: `git clone failed for ${url} — ${errorMessage(err)}` };
@@ -9,7 +9,7 @@ import { applyPathPrefix } from "./namespace.ts";
9
9
  import { isBinary } from "./walk.ts";
10
10
  import { MAX_WALK_FILE_BYTES, type ContextFile, type SkippedFile } from "./types.ts";
11
11
 
12
- export type TextFileResult =
12
+ type TextFileResult =
13
13
  | { readonly ok: true; readonly value: ContextFile }
14
14
  | { readonly ok: false; readonly skipped: SkippedFile };
15
15
 
@@ -13,12 +13,13 @@
13
13
 
14
14
  import { execFile } from "node:child_process";
15
15
  import type { Dirent } from "node:fs";
16
- import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
16
+ import { lstat, open, readdir, realpath } from "node:fs/promises";
17
17
  import { basename, join, relative, resolve, sep } from "node:path";
18
18
  import { promisify } from "node:util";
19
19
  import type { SkipReason } from "./types.ts";
20
20
 
21
- const execFileP = promisify(execFile);
21
+ /** Shared promisified execFile (git listing) — source-git.ts reuses it (DRY). */
22
+ export const execFileP = promisify(execFile);
22
23
 
23
24
  /** 8KB probe window for the NUL-byte binary check. */
24
25
  const BINARY_PROBE_BYTES = 8 * 1024;
@@ -94,7 +95,7 @@ function absKey(path: string): string {
94
95
  }
95
96
 
96
97
  /** True when `fileAbs` is `rootAbs` or a descendant (prefix + separator). */
97
- export function isInsideRoot(fileAbs: string, rootAbs: string): boolean {
98
+ function isInsideRoot(fileAbs: string, rootAbs: string): boolean {
98
99
  const root = absKey(rootAbs);
99
100
  const file = absKey(fileAbs);
100
101
  if (file === root) return true;
@@ -102,7 +103,7 @@ export function isInsideRoot(fileAbs: string, rootAbs: string): boolean {
102
103
  return file.startsWith(prefix);
103
104
  }
104
105
 
105
- export type PathSafety =
106
+ type PathSafety =
106
107
  | { readonly ok: true; readonly realAbs: string }
107
108
  | { readonly ok: false; readonly reason: Extract<SkipReason, "sensitive" | "symlink-escape" | "unreadable"> };
108
109
 
@@ -147,7 +148,7 @@ export async function checkPathSafety(absPath: string, packRoot: string): Promis
147
148
  * Paths are returned raw from git (forward-slashed). Sensitive paths are NOT filtered here —
148
149
  * packDirectory reports them as skipped: "sensitive" so the drop is visible.
149
150
  */
150
- export async function gitFiles(cwd: string, signal?: AbortSignal): Promise<readonly string[] | undefined> {
151
+ async function gitFiles(cwd: string, signal?: AbortSignal): Promise<readonly string[] | undefined> {
151
152
  try {
152
153
  const { stdout } = await execFileP(
153
154
  "git",
@@ -239,12 +240,3 @@ export async function enumerateFiles(root: string, signal?: AbortSignal): Promis
239
240
  return await walkFs(root, signal);
240
241
  }
241
242
 
242
- /** True when a path exists and is a regular file (or symlink to one). */
243
- export async function isRegularFile(absPath: string): Promise<boolean> {
244
- try {
245
- const s = await stat(absPath);
246
- return s.isFile();
247
- } catch {
248
- return false;
249
- }
250
- }