@hicaru/pi-rlm 0.3.16 → 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 (86) hide show
  1. package/README.md +0 -4
  2. package/README.ru.md +56 -66
  3. package/README.zh-CN.md +61 -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 +6 -3
  14. package/src/commands/rlm-llm.ts +1 -10
  15. package/src/commands/rlm-rlm.ts +1 -8
  16. package/src/config/defaults.ts +28 -12
  17. package/src/config/settings.ts +41 -31
  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 +141 -84
  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 +51 -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/sandbox/context-file.ts +1 -1
  50. package/src/sandbox/interrupts.ts +25 -31
  51. package/src/sandbox/protocol.ts +14 -20
  52. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  53. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  54. package/src/sandbox/py/__pycache__/worker.cpython-314.pyc +0 -0
  55. package/src/sandbox/py/guards.py +1 -1
  56. package/src/sandbox/py/scaffold.py +24 -31
  57. package/src/sandbox/py/worker.py +3 -1
  58. package/src/sandbox/sandbox-manager.ts +2 -2
  59. package/src/sandbox/sandbox.ts +21 -4
  60. package/src/text/agent-text.ts +58 -0
  61. package/src/text/parsing.ts +35 -3
  62. package/src/text/preview.ts +3 -0
  63. package/src/text/repl-output.ts +1 -1
  64. package/src/tool/background-tasks.ts +1 -1
  65. package/src/tool/repl-render.ts +1 -1
  66. package/src/tool/repl-result.ts +1 -1
  67. package/src/tool/repl-tool.ts +50 -26
  68. package/src/tool/rlm-tool.ts +4 -5
  69. package/src/tool/subcall-render.ts +1 -1
  70. package/src/tool/subcall-store.ts +2 -2
  71. package/src/tool/tool-utils.ts +5 -5
  72. package/src/ui/intro.ts +1 -1
  73. package/src/ui/modal/timeline-store.ts +1 -1
  74. package/src/ui/model-picker/drilldown.ts +1 -1
  75. package/src/ui/model-picker/levels.ts +1 -1
  76. package/src/ui/panel/run-registry.ts +1 -1
  77. package/src/ui/tree/tree-rows.ts +1 -1
  78. package/src/ui/tree/tree-widget.ts +1 -1
  79. package/src/util/bm25.ts +97 -0
  80. package/src/util/concurrency.ts +1 -1
  81. package/src/util/errors.ts +1 -1
  82. package/src/util/retry.ts +22 -7
  83. package/src/util/state-merge.ts +34 -0
  84. package/src/util/throttle.ts +1 -1
  85. package/src/util/type-guards.ts +6 -0
  86. package/src/core/memory.ts +0 -589
package/src/index.ts CHANGED
@@ -21,15 +21,18 @@ import { SANDBOX_WATCHDOG_HEARTBEAT_MS } from "./sandbox/sandbox.ts";
21
21
  import { SandboxManager } from "./sandbox/sandbox-manager.ts";
22
22
  import { buildSessionGates, type SubcallGates } from "./util/concurrency.ts";
23
23
  import { BackgroundTasks } from "./tool/background-tasks.ts";
24
- import { MemoryStore } from "./core/memory.ts";
25
- import { modelComplete } from "./bridge/model.ts";
26
- import { retryPolicy } from "./util/retry.ts";
27
24
  import { resolve } from "node:path";
28
25
  import { resolveSource } from "./context/resolve.ts";
29
26
  import { formatContextListing } from "./context/listing.ts";
30
27
  import { extractEditPaths, readDiskFile } from "./context/refresh.ts";
31
28
  import type { AddContextHandlerBundle } from "./bridge/add-context.ts";
32
29
  import { buildNativeSystemPrompt } from "./prompts/native.ts";
30
+ import { SkillStore, notesFromRunState, xiQuery } from "./config/skillstate.ts";
31
+ import { buildRootDigestCompaction } from "./core/root-digest.ts";
32
+ import { RootStateTracker } from "./core/root-state.ts";
33
+ import { elideStalePayloads, spliceSigmaSnapshot } from "./core/root-context.ts";
34
+ import { agentMessageText, firstLine, textContentOf } from "./text/agent-text.ts";
35
+ import { findStatePatches } from "./text/parsing.ts";
33
36
  import { capToolResultText } from "./mode/native-guards.ts";
34
37
  import {
35
38
  isSubagentChildBypass,
@@ -69,21 +72,10 @@ export default function rlmExtension(pi: ExtensionAPI): void {
69
72
 
70
73
  // Init synchronously with defaults — ensures commands/tools/handlers register before session_start
71
74
  const config = mergeConfig({});
72
- // v5 durable memory: one store per session under <cwd>/.rlm/memory (L1 replay + L2 notes).
73
- // NOTE (audit M4): this store IS shared by both composition roots, but the TaskLedger is
74
- // NOT the native repl() session and each headless rlm run each keep their own blackboard
75
- // (v5 parity: per-run ledger). Claims/coalescing reset at that boundary, by design.
76
- // The consolidation LLM + real workspace root are attached in session_start (setLlm/setRoot).
77
- const memory = new MemoryStore(
78
- process.cwd(),
79
- {
80
- dir: config.memoryDir ?? undefined,
81
- injectNoteTokens: config.injectNoteTokens,
82
- evolveEvery: config.evolveEvery,
83
- },
84
- config.enableMemory,
85
- );
86
- const controller = new RlmController(config, memory);
75
+ const controller = new RlmController(config);
76
+ // Root Σ WS-4: engine-finalize Σ flows into the root tracker (assigned once; buildEngine
77
+ // reads it per run, so /rlm-config and session resets never stale this wire).
78
+ controller.onRunState = (state) => { rootTracker?.absorbEngineState(state); };
87
79
  let onSandboxDiscardExtra: (() => void) | undefined;
88
80
  const sandboxManager = new SandboxManager({
89
81
  execTimeoutS: config.execTimeoutS,
@@ -116,6 +108,16 @@ export default function rlmExtension(pi: ExtensionAPI): void {
116
108
  hideWhenEmpty: true,
117
109
  });
118
110
  let treePanelInstalled = false;
111
+ /** SKILL.state (Workstream B): session store — hydrated at session_start, flushed at shutdown. */
112
+ let skillStore: SkillStore | undefined;
113
+ /** Root Σ (WS-3/4): the native session's digest-level Σ_t — runtime-derived (tool outcomes,
114
+ * engine mirrors, prompts); lazily born on the first prompt, harvested + dropped at shutdown. */
115
+ let rootTracker: RootStateTracker | undefined;
116
+ // Root Σ WS-5.1 telemetry — journal counters (trace lines only; no TUI surface by design).
117
+ let xiCompositions = 0;
118
+ let rootDigests = 0;
119
+ let elidedMessages = 0;
120
+ let sigmaSplices = 0;
119
121
  // A detached child works in its OWN sandbox, so this one sees no frames and its request
120
122
  // watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
121
123
  // with it. Keep it alive while detached work is genuinely in flight.
@@ -157,17 +159,25 @@ export default function rlmExtension(pi: ExtensionAPI): void {
157
159
  await seedPromise;
158
160
  };
159
161
 
162
+ /** Ξ (Workstream C): BM25 slice of the SkillState store for a query; undefined when off. */
163
+ const composeSkillBlock = (query: string): string | undefined => {
164
+ const cfg = controller.config;
165
+ if (!cfg.enableSkillState || skillStore === undefined) return undefined;
166
+ const block = skillStore.blockFor(query, cfg.skillStateMaxTokens);
167
+ return block === "" ? undefined : block;
168
+ };
169
+
160
170
  // ── Message renderers ──
161
171
  // Markdown themes are derived from the injected `theme`, never pi's module-global
162
172
  // `getMarkdownTheme()` — under jiti that global can be undefined inside a plugin.
163
173
  pi.registerMessageRenderer("rlm-answer", (message, _options, theme) =>
164
- new Markdown(String(message.content ?? ""), 1, 0, markdownTheme(theme)),
174
+ new Markdown(textContentOf(message.content), 1, 0, markdownTheme(theme)),
165
175
  );
166
176
  pi.registerMessageRenderer("rlm-question", (message, _options, theme) =>
167
- new Markdown(`**RLM question**\n\n${String(message.content ?? "")}`, 1, 0, markdownTheme(theme)),
177
+ new Markdown(`**RLM question**\n\n${textContentOf(message.content)}`, 1, 0, markdownTheme(theme)),
168
178
  );
169
179
  pi.registerMessageRenderer("rlm-intro", (message, _options, theme) =>
170
- new Markdown(String(message.content ?? ""), 1, 0, markdownTheme(theme)),
180
+ new Markdown(textContentOf(message.content), 1, 0, markdownTheme(theme)),
171
181
  );
172
182
 
173
183
  // ── CLI flag: `pi --rlm` / `pi --rlm=false` overrides the persisted mode for this run ──
@@ -194,11 +204,17 @@ export default function rlmExtension(pi: ExtensionAPI): void {
194
204
  controller.savedLlmRef = persisted.llm ?? undefined;
195
205
  controller.savedRlmRef = persisted.rlm ?? undefined;
196
206
 
207
+ // SKILL.state (Workstream B): hydrate the cross-session note store (fail-soft).
208
+ skillStore = controller.config.enableSkillState
209
+ ? await SkillStore.hydrate(controller.config.skillStateNotesPerProject)
210
+ : undefined;
211
+ controller.skillStore = skillStore;
212
+
197
213
  // An explicit --rlm flag wins over the persisted setting for this session.
198
214
  const flag = pi.getFlag("rlm");
199
215
  if (typeof flag === "boolean") controller.setConfig(Object.freeze({ ...controller.config, enabled: flag }));
200
216
 
201
- // Reload the catalog before the worker-model pick below reads it. Newer pi builds make
217
+ // Reload the catalog before the worker pick below reads it. Newer pi builds make
202
218
  // `getAvailable()` an async-populated snapshot that starts empty, and picking from an empty
203
219
  // catalog silently falls back to the root model. Called with no arguments and awaited so it
204
220
  // is valid whether `refresh` returns void (current) or a promise (newer); fail-soft, because
@@ -253,17 +269,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
253
269
  const llmModel = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
254
270
  const model = ctx.model;
255
271
  if (llmModel && model) {
256
- // Consolidation runs on the cheap worker model through the single completion entry point;
257
- // the workspace root is only known once the session starts.
258
- const consolidateModel = llmModel;
259
- memory.setLlm((prompt) =>
260
- modelComplete([{ role: "user", content: prompt }], {
261
- model: consolidateModel,
262
- registry: ctx.modelRegistry,
263
- retry: retryPolicy(controller.config),
264
- })
265
- .then((r) => r.text));
266
- memory.setRoot(ctx.cwd ?? process.cwd());
267
272
  // v5 provider caps (audit C1/C6): ONE resolver shared by both composition roots — the
268
273
  // repl() tool and RlmController.start admit through the same pool, each gate capped
269
274
  // against the model that actually runs on it (leaves = worker, children = smart).
@@ -294,7 +299,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
294
299
  try {
295
300
  pi.registerTool(createReplTool({
296
301
  sandboxManager,
302
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pi-ai registry types Model<any>; runtime models conform to Model<Api>
297
303
  model,
304
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- see above
298
305
  llmModel,
299
306
  getModel: () => controller.resolveModels(ctx)?.model,
300
307
  getLlmModel: () => controller.resolveModels(ctx)?.llm,
@@ -304,7 +311,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
304
311
  resolveGates: resolveSessionGates,
305
312
  background,
306
313
  runRegistry,
307
- memory,
314
+ skillStore,
315
+ onRunState: (state) => { rootTracker?.absorbEngineState(state); },
316
+ getSkillBlock: composeSkillBlock,
308
317
  registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
309
318
  registerContextBundle: (bundle) => {
310
319
  contextBundleRef = bundle;
@@ -350,10 +359,83 @@ export default function rlmExtension(pi: ExtensionAPI): void {
350
359
  activeToolNames: typeof pi.getActiveTools === "function" ? pi.getActiveTools() : undefined,
351
360
  });
352
361
 
362
+ /** Root Σ WS-3 gate: config flag AND the bench A/B toggle (RLM_BENCH_NO_ROOTCONTEXT=1 skips). */
363
+ const rootContextActive = (): boolean =>
364
+ controller.config.enableRootContextTransform && process.env.RLM_BENCH_NO_ROOTCONTEXT !== "1";
365
+
353
366
  // ── System prompt: native RLM mode addendum (only when the trade holds) ──
354
367
  pi.on("before_agent_start", async (event) => {
355
368
  if (!nativeTradeHolds()) return;
356
- return { systemPrompt: event.systemPrompt + "\n\n" + buildNativeSystemPrompt() };
369
+ // Root Σ: lazy tracker birth on the session's first prompt (the task IS that prompt).
370
+ rootTracker ??= RootStateTracker.fresh(event.prompt, controller.config.runStateRetryMax);
371
+ // Ξ (Workstream C / Root Σ WS-1): the block is PER-PROMPT text — BM25 ranks the store
372
+ // against the live user prompt (mid-session harvests surface immediately), falling back
373
+ // to the static slice when the prompt is empty. Concatenated here, so NATIVE_PROMPT_STATIC
374
+ // (the module-load snapshot) and its budget math stay untouched.
375
+ const xi = composeSkillBlock(xiQuery(event.prompt, event.systemPrompt.slice(0, 2_000)));
376
+ if (xi !== undefined) {
377
+ xiCompositions += 1;
378
+ if (traceEnabled) trace("root-xi.compose", { total: xiCompositions, chars: xi.length });
379
+ }
380
+ const xiPart = xi === undefined ? "" : `${xi}\n\n`;
381
+ // Root Σ WS-4: the user's latest ask IS the next step by definition; feed the tracker
382
+ // before the turn so the Σ snapshot in this turn's context carries it.
383
+ rootTracker?.setNextStep(event.prompt);
384
+ // Root Σ WS-4.2 (enableRootStateFences): a rejected ΔΣ_t from the previous turn surfaces
385
+ // as an observation message — the same error-as-observation retry ladder engine runs use.
386
+ const observation = rootTracker?.takePendingObservation();
387
+ const message = observation === undefined
388
+ ? undefined
389
+ : { customType: "rlm-sigma-observation", content: observation, display: false, details: undefined };
390
+ return {
391
+ ...(message === undefined ? {} : { message }),
392
+ systemPrompt: event.systemPrompt + "\n\n" + xiPart + buildNativeSystemPrompt(),
393
+ };
394
+ });
395
+
396
+ // Root Σ WS-4.2 (default OFF): capture model-proposed ΔΣ_t fences from finalized assistant
397
+ // replies and run them through the ONE patch validator (run-state.ts applyPatch).
398
+ pi.on("message_end", async (event) => {
399
+ const tracker = rootTracker;
400
+ if (tracker === undefined || !controller.config.enableRootStateFences) return;
401
+ if (event.message.role !== "assistant") return;
402
+ const text = agentMessageText(event.message);
403
+ if (text === "") return;
404
+ tracker.applyFences(findStatePatches(text));
405
+ });
406
+
407
+ // ── Root Σ WS-2: deterministic root compaction (no summary LLM call) ──
408
+ pi.on("session_before_compact", async (event) => {
409
+ if (!controller.config.enableRootDigestCompaction) return undefined;
410
+ if (!nativeTradeHolds() && !controller.enabled) return undefined;
411
+ try {
412
+ const result = buildRootDigestCompaction({
413
+ preparation: event.preparation,
414
+ branchEntries: event.branchEntries,
415
+ config: controller.config,
416
+ store: skillStore,
417
+ });
418
+ if (result !== undefined) {
419
+ rootDigests += 1;
420
+ if (traceEnabled) {
421
+ // V1 soak probe: host-consumed tokensBefore vs our recomputation over the same span.
422
+ trace("root-digest.built", {
423
+ count: rootDigests,
424
+ chars: result.compaction.summary.length,
425
+ tokensBefore: result.compaction.tokensBefore,
426
+ tokensBeforeRecomputed: result.tokensBeforeRecomputed,
427
+ });
428
+ }
429
+ }
430
+ // SessionBeforeCompactResult SHAPE: the host consumes `result.compaction` — return the
431
+ // WRAPPER, never the bare CompactionResult (a bare one reads as `compaction: undefined`
432
+ // host-side and silently falls back to the LLM summarizer).
433
+ return result === undefined ? undefined : { compaction: result.compaction };
434
+ } catch (err) {
435
+ // Fail-soft by contract (packages/agent/src/types.ts:191): Pi falls back to its summarizer.
436
+ if (traceEnabled) trace("root-digest.fail", { error: errorMessage(err) });
437
+ return undefined;
438
+ }
357
439
  });
358
440
 
359
441
  // ── Context injection: listing of whatever is currently loaded ──
@@ -367,6 +449,29 @@ export default function rlmExtension(pi: ExtensionAPI): void {
367
449
  );
368
450
  if (!nativeTradeHolds()) return { messages: filtered };
369
451
 
452
+ // Root Σ WS-3: per-call A_t on the clone Pi hands us (runner.ts structuredClone — the last
453
+ // returned array wins; the disk transcript is untouched). Elide stale payloads (§5.3),
454
+ // then splice exactly one fresh Σ snapshot. Fail-soft: a throw here must never break a turn.
455
+ if (rootContextActive()) {
456
+ try {
457
+ const elided = elideStalePayloads(filtered, {
458
+ keepTurns: controller.config.rootContextKeepTurns,
459
+ elideChars: controller.config.rootContextElideChars,
460
+ });
461
+ elidedMessages += elided;
462
+ const tracker = rootTracker;
463
+ if (controller.config.rootContextSnapshot && tracker !== undefined && !tracker.isEmpty) {
464
+ spliceSigmaSnapshot(filtered, tracker.snapshot(), tracker.rectifyHint());
465
+ sigmaSplices += 1;
466
+ }
467
+ if (traceEnabled && (elided > 0 || sigmaSplices > 0)) {
468
+ trace("root-context.transform", { elided, total: elidedMessages, splices: sigmaSplices });
469
+ }
470
+ } catch (err) {
471
+ if (traceEnabled) trace("root-context.fail", { error: errorMessage(err) });
472
+ }
473
+ }
474
+
370
475
  type PiMessage = (typeof filtered)[number];
371
476
 
372
477
  const payload = sandboxManager.contextPayload;
@@ -398,17 +503,29 @@ export default function rlmExtension(pi: ExtensionAPI): void {
398
503
  const MUTATING_FILE_TOOLS = Object.freeze(new Set(["edit", "write"]));
399
504
 
400
505
  pi.on("tool_result", async (event, ctx) => {
506
+ // Root Σ WS-4: the tracker learns the session's shape from outcomes — deterministic, zero
507
+ // model cooperation; fresh results always outrank Σ (paper §5.3 observation override).
508
+ const tracker = rootTracker;
509
+ if (tracker !== undefined) {
510
+ tracker.observeToolResult(
511
+ event.toolName,
512
+ event.isError,
513
+ event.isError ? firstLine(textContentOf(event.content)) : "",
514
+ );
515
+ }
516
+
401
517
  // ── Keep RLM context fresh after native file mutations ──
402
518
  if (
403
519
  nativeTradeHolds()
404
520
  && MUTATING_FILE_TOOLS.has(event.toolName)
405
- && event.isError !== true
521
+ && !event.isError
406
522
  ) {
407
523
  const cwd = resolve(ctx?.cwd ?? process.cwd());
408
524
  const paths = extractEditPaths(event.input);
409
525
  for (const p of paths) {
410
526
  const body = await readDiskFile(p, cwd);
411
527
  if (body === null) continue;
528
+ rootTracker?.noteFact(`edited ${p}`); // WS-4: the tracker learns the session's file shape
412
529
  try {
413
530
  await sandboxManager.refreshFileFromDisk(p, body, cwd);
414
531
  // Listing must re-inject if we rewrote payload identity
@@ -435,6 +552,18 @@ export default function rlmExtension(pi: ExtensionAPI): void {
435
552
 
436
553
  // ── Session shutdown: cleanup ──
437
554
  pi.on("session_shutdown", async () => {
555
+ // Root Σ WS-4 harvest symmetry: the root tracker teaches the store exactly like engine
556
+ // runs — the ONE notesFromRunState path — then the existing flush persists everything.
557
+ const tracker = rootTracker;
558
+ if (skillStore !== undefined && tracker !== undefined && tracker.dirty && controller.config.enableSkillState) {
559
+ try {
560
+ skillStore.merge(notesFromRunState(tracker.snapshot()));
561
+ if (traceEnabled) trace("root-harvest.merged", { notes: tracker.snapshot().verifiedFacts.length });
562
+ } catch (err) {
563
+ if (traceEnabled) trace("root-harvest.fail", { error: errorMessage(err) });
564
+ }
565
+ }
566
+ await skillStore?.flush(); // SKILL.state (Workstream B): persist distilled notes
438
567
  controller.abort();
439
568
  clearInterval(watchdogHeartbeat);
440
569
  background.dispose();
@@ -444,6 +573,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
444
573
  contextBundleRef = undefined;
445
574
  listingPayloadRef = undefined;
446
575
  listingInjected = false;
576
+ skillStore = undefined;
577
+ rootTracker = undefined;
447
578
  sandboxManager.contextPayload = [];
448
579
  });
449
580
  }
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { Api, Model } from "@earendil-works/pi-ai";
12
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
+ import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
13
13
 
14
14
  /** $/Mtok, input-weighted 3:1 — a sub-call sends a file body and gets back a sentence. */
15
15
  function priceOf(model: Model<Api>): number {
@@ -48,7 +48,19 @@ export function cheapestModel(registry: ModelRegistry): Model<Api> | undefined {
48
48
  for (let i = 0; i < models.length; i++) {
49
49
  const model = models[i];
50
50
  if (model === undefined) continue;
51
+ // Sentinel negative cost (openrouter/auto reports −1e6) = unpriceable router. priceOf()
52
+ // would crown it "cheapest" (−4M) — skip; fail closed on unknown prices.
53
+ if ((model.cost?.input ?? 0) < 0 || (model.cost?.output ?? 0) < 0) continue;
51
54
  if (best === undefined || compareLlm(model, best) < 0) best = model;
52
55
  }
53
56
  return best;
54
57
  }
58
+
59
+ /** Newer Pi hosts expose session-scoped models; 0.79 peers do not — duck-type safely.
60
+ * DRY: the ONE reflection probe — shared by /rlm-llm and /rlm-rlm. */
61
+ export function sessionScopedModels(
62
+ ctx: ExtensionContext,
63
+ ): readonly { readonly model: Model<Api> }[] | undefined {
64
+ const scoped: unknown = Reflect.get(ctx, "scopedModels");
65
+ return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
66
+ }
@@ -40,12 +40,6 @@ export function isFileReadingCommand(command: string): boolean {
40
40
  return statementHeads(command).some((h) => READER_COMMANDS.has(h));
41
41
  }
42
42
 
43
- export const BASH_BLOCK_REASON =
44
- "RLM mode: reading files via bash is blocked — that dumps file content into the root model's " +
45
- "context. All files are pre-loaded in the REPL `context` variable: use repl({code}) with Python " +
46
- "string/regex search, and delegate bulk analysis to llm_query / llm_batch / " +
47
- "llm_query_chunked. bash is for RUNNING things (tests, builds, git).";
48
-
49
43
  /** Max chars of tool output forwarded to the root model (≈1K tokens). */
50
44
  export const TOOL_RESULT_CAP = 4_000;
51
45
 
@@ -16,11 +16,12 @@ import { resolveSource } from "../context/resolve.ts";
16
16
  import { RlmEmitter } from "../tool/rlm-events.ts";
17
17
  import { formatError } from "../util/errors.ts";
18
18
  import { cheapestModel } from "./llm-model.ts";
19
- import type { MemoryStore } from "../core/memory.ts";
19
+ import type { SkillStore } from "../config/skillstate.ts";
20
+ import type { RunState } from "../core/run-state.ts";
20
21
  import type { RunRlm } from "../core/types.ts";
21
22
  import type { SubcallGates } from "../util/concurrency.ts";
22
23
 
23
- export interface RunHandle {
24
+ interface RunHandle {
24
25
  readonly abort: () => void;
25
26
  readonly done: Promise<RlmResult>;
26
27
  }
@@ -28,16 +29,23 @@ export interface RunHandle {
28
29
  export interface StartInput {
29
30
  readonly rootPrompt: string;
30
31
  readonly context: unknown;
32
+ /** History-as-deliverable opt-out (§12.1): RunState stays off — the archive is the product. */
33
+ readonly narrative?: boolean;
31
34
  }
32
35
 
33
36
  export class RlmController {
34
37
  llmModel: Model<Api> | undefined;
35
- savedLlmRef: string | undefined;
38
+ savedLlmRef?: string;
36
39
  /** Set by applyLlmSelection when the user explicitly picks "cheapest (auto)". */
37
40
  explicitClearPin = false;
38
41
  /** Pinned rlm root/worker model — when unset, child engines follow pi's session model. */
39
42
  rlmModel: Model<Api> | undefined;
40
- savedRlmRef: string | undefined;
43
+ savedRlmRef?: string;
44
+ /** SKILL.state (Workstream B): the session store — hydrated by index.ts at session_start.
45
+ * Undefined ⇒ no Ξ composition, no harvest: the headless path runs exactly as built. */
46
+ skillStore: SkillStore | undefined;
47
+ /** Root Σ (WS-4): set by the extension — engine-finalize Σ flows to the root tracker. */
48
+ onRunState: ((state: RunState) => void) | undefined;
41
49
  /** Set by applyRlmSelection when the user explicitly picks "(follow session model)". */
42
50
  explicitClearRlmPin = false;
43
51
  private active: AbortController | null = null;
@@ -45,11 +53,7 @@ export class RlmController {
45
53
  * session_start so BOTH composition roots admit through one pool (audit C1). */
46
54
  private sessionGates: (() => SubcallGates) | undefined;
47
55
 
48
- constructor(
49
- public config: RlmConfig,
50
- /** v5 durable memory — shared with the repl tool so child runs replay/persist too. */
51
- public readonly memory?: MemoryStore,
52
- ) {}
56
+ constructor(public config: RlmConfig) {}
53
57
 
54
58
  setSessionGates(getGates: () => SubcallGates): void {
55
59
  this.sessionGates = getGates;
@@ -101,9 +105,12 @@ export class RlmController {
101
105
  if (!this.llmModel && this.savedLlmRef) this.llmModel = resolveModelId(ctx.modelRegistry, this.savedLlmRef);
102
106
  if (!this.rlmModel && this.savedRlmRef) this.rlmModel = resolveModelId(ctx.modelRegistry, this.savedRlmRef);
103
107
  // The rlm pin wins over the session model; unset → follow pi's active model.
108
+ // pi's ctx.model is typed Model<any>; runtime models conform to Model<Api> — the two
109
+ // directives below mark exactly where that external-boundary any enters and leaves.
104
110
  const model = this.rlmModel ?? ctx.model ?? cheapestModel(ctx.modelRegistry);
105
111
  if (!model) return undefined;
106
112
  const llm = this.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
113
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
107
114
  return { model, llm };
108
115
  }
109
116
 
@@ -112,6 +119,14 @@ export class RlmController {
112
119
  return createEngine(deps);
113
120
  }
114
121
 
122
+ /** Ξ (Workstream C): BM25 slice of the session SkillState for a root prompt; undefined when
123
+ * the store is absent/disabled or nothing is relevant. */
124
+ private skillBlockFor(query: string): string | undefined {
125
+ if (this.skillStore === undefined || !this.config.enableSkillState) return undefined;
126
+ const block = this.skillStore.blockFor(query, this.config.skillStateMaxTokens);
127
+ return block === "" ? undefined : block;
128
+ }
129
+
115
130
  /** The ONE engine construction path for this controller (DRY #6 — a second path that
116
131
  * forgets to grow is exactly how issue #4 and audit C1 happened). Protected so tests can
117
132
  * subclass and assert the wiring without touching the network. */
@@ -129,8 +144,9 @@ export class RlmController {
129
144
  signal: args.signal,
130
145
  emitter: args.emitter,
131
146
  limits: limitsFromConfig(this.config),
132
- memory: this.memory,
133
147
  gates: this.sessionGates?.(),
148
+ skillStore: this.skillStore,
149
+ onRunState: this.onRunState,
134
150
  });
135
151
  }
136
152
 
@@ -159,7 +175,14 @@ export class RlmController {
159
175
  signal: abortController.signal,
160
176
  emitter: emitter ?? new RlmEmitter(),
161
177
  });
162
- return await engine({ rootPrompt: input.rootPrompt, context: contextValue, depth: 0 });
178
+ const skillBlock = this.skillBlockFor(input.rootPrompt);
179
+ return await engine({
180
+ rootPrompt: input.rootPrompt,
181
+ context: contextValue,
182
+ depth: 0,
183
+ ...(skillBlock === undefined ? {} : { skillBlock }), // Ξ (Workstream C)
184
+ ...(input.narrative === undefined ? {} : { narrative: input.narrative }),
185
+ });
163
186
  })().finally(() => {
164
187
  if (this.active === abortController) this.active = null;
165
188
  });
@@ -11,9 +11,9 @@
11
11
  */
12
12
  import { DEFAULT_CONFIG } from "../config/defaults.ts";
13
13
 
14
- export const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
15
- export const RLM_FORCE_IN_SUBAGENT_ENV = "PI_RLM_FORCE_IN_SUBAGENT";
16
- export const RLM_DEPTH_ENV = "PI_RLM_DEPTH";
14
+ const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
15
+ const RLM_FORCE_IN_SUBAGENT_ENV = "PI_RLM_FORCE_IN_SUBAGENT";
16
+ const RLM_DEPTH_ENV = "PI_RLM_DEPTH";
17
17
 
18
18
  /** Cross-process depth from env. Missing / invalid → 0. */
19
19
  export function processRlmDepth(): number {
@@ -48,7 +48,7 @@ export function commitSubagentForceActivation(maxDepth: number = DEFAULT_CONFIG.
48
48
  if (process.env[RLM_FORCE_IN_SUBAGENT_ENV] !== "1") return;
49
49
  if (processRlmDepth() >= maxDepth) return;
50
50
  const next = processRlmDepth() + 1;
51
- delete process.env[RLM_FORCE_IN_SUBAGENT_ENV];
51
+ Reflect.deleteProperty(process.env, RLM_FORCE_IN_SUBAGENT_ENV);
52
52
  process.env[RLM_DEPTH_ENV] = String(next);
53
53
  }
54
54
 
@@ -59,7 +59,7 @@ export function commitSubagentForceActivation(maxDepth: number = DEFAULT_CONFIG.
59
59
  */
60
60
  export function shouldEnforceNativeReaderBlock(opts: {
61
61
  readonly enabled: boolean;
62
- readonly activeToolNames: readonly string[] | undefined;
62
+ readonly activeToolNames?: readonly string[];
63
63
  }): boolean {
64
64
  if (!opts.enabled) return false;
65
65
  const names = opts.activeToolNames;