@hicaru/pi-rlm 0.3.16 → 0.3.18

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 (88) 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 +32 -12
  17. package/src/config/settings.ts +53 -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 +184 -0
  37. package/src/core/root-digest.ts +213 -0
  38. package/src/core/root-state.ts +310 -0
  39. package/src/core/run-state.ts +587 -0
  40. package/src/core/types.ts +51 -12
  41. package/src/index.ts +220 -39
  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 +46 -25
  47. package/src/prompts/native.ts +27 -5
  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/config-panel.ts +12 -0
  73. package/src/ui/intro.ts +1 -1
  74. package/src/ui/modal/timeline-store.ts +1 -1
  75. package/src/ui/model-picker/drilldown.ts +1 -1
  76. package/src/ui/model-picker/levels.ts +1 -1
  77. package/src/ui/panel/run-registry.ts +1 -1
  78. package/src/ui/status.ts +39 -4
  79. package/src/ui/tree/tree-rows.ts +1 -1
  80. package/src/ui/tree/tree-widget.ts +1 -1
  81. package/src/util/bm25.ts +97 -0
  82. package/src/util/concurrency.ts +1 -1
  83. package/src/util/errors.ts +1 -1
  84. package/src/util/retry.ts +22 -7
  85. package/src/util/state-merge.ts +34 -0
  86. package/src/util/throttle.ts +1 -1
  87. package/src/util/type-guards.ts +6 -0
  88. package/src/core/memory.ts +0 -589
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts"
13
13
  import { RlmController } from "./mode/rlm-mode.ts";
14
14
  import { cheapestModel } from "./mode/llm-model.ts";
15
15
  import { postRlmGuide } from "./ui/intro.ts";
16
- import { setRlmModeStatus } from "./ui/status.ts";
16
+ import { setRlmModeStatus, type RootSigmaTelemetry } from "./ui/status.ts";
17
17
  import { RunRegistry } from "./ui/panel/run-registry.ts";
18
18
  import { installTreePanel } from "./ui/panel/tree-panel.ts";
19
19
  import { markdownTheme } from "./ui/theme-adapter.ts";
@@ -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,25 @@ 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 + status widget when tracing).
117
+ let xiCompositions = 0;
118
+ let rootDigests = 0;
119
+ let elidedMessages = 0;
120
+ let sigmaSplices = 0;
121
+ let idleDegrades = 0;
122
+ /** R6: Σ counter snapshot for the status line — a fresh readonly object per render. */
123
+ const sigmaTelemetry = (): RootSigmaTelemetry => ({
124
+ xiCompositions,
125
+ rootDigests,
126
+ elidedMessages,
127
+ sigmaSplices,
128
+ idleDegrades,
129
+ });
119
130
  // A detached child works in its OWN sandbox, so this one sees no frames and its request
120
131
  // watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
121
132
  // with it. Keep it alive while detached work is genuinely in flight.
@@ -157,17 +168,26 @@ export default function rlmExtension(pi: ExtensionAPI): void {
157
168
  await seedPromise;
158
169
  };
159
170
 
171
+ /** Ξ (Workstream C): BM25 slice of the SkillState store for a query; undefined when off. */
172
+ const composeSkillBlock = (query: string): string | undefined => {
173
+ const cfg = controller.config;
174
+ // R0: enableSkillState is enforced (validateEnforcedOn) — no config check remains.
175
+ if (skillStore === undefined) return undefined;
176
+ const block = skillStore.blockFor(query, cfg.skillStateMaxTokens);
177
+ return block === "" ? undefined : block;
178
+ };
179
+
160
180
  // ── Message renderers ──
161
181
  // Markdown themes are derived from the injected `theme`, never pi's module-global
162
182
  // `getMarkdownTheme()` — under jiti that global can be undefined inside a plugin.
163
183
  pi.registerMessageRenderer("rlm-answer", (message, _options, theme) =>
164
- new Markdown(String(message.content ?? ""), 1, 0, markdownTheme(theme)),
184
+ new Markdown(textContentOf(message.content), 1, 0, markdownTheme(theme)),
165
185
  );
166
186
  pi.registerMessageRenderer("rlm-question", (message, _options, theme) =>
167
- new Markdown(`**RLM question**\n\n${String(message.content ?? "")}`, 1, 0, markdownTheme(theme)),
187
+ new Markdown(`**RLM question**\n\n${textContentOf(message.content)}`, 1, 0, markdownTheme(theme)),
168
188
  );
169
189
  pi.registerMessageRenderer("rlm-intro", (message, _options, theme) =>
170
- new Markdown(String(message.content ?? ""), 1, 0, markdownTheme(theme)),
190
+ new Markdown(textContentOf(message.content), 1, 0, markdownTheme(theme)),
171
191
  );
172
192
 
173
193
  // ── CLI flag: `pi --rlm` / `pi --rlm=false` overrides the persisted mode for this run ──
@@ -194,11 +214,17 @@ export default function rlmExtension(pi: ExtensionAPI): void {
194
214
  controller.savedLlmRef = persisted.llm ?? undefined;
195
215
  controller.savedRlmRef = persisted.rlm ?? undefined;
196
216
 
217
+ // SKILL.state (Workstream B / R0): hydrate the cross-session note store UNCONDITIONALLY —
218
+ // the SkillStore is operating law; no rlm.json, command, or UI path can prevent its birth
219
+ // (hostile configs are traced + ignored at the validateEnforcedOn seam; fail-soft).
220
+ skillStore = await SkillStore.hydrate(controller.config.skillStateNotesPerProject);
221
+ controller.skillStore = skillStore;
222
+
197
223
  // An explicit --rlm flag wins over the persisted setting for this session.
198
224
  const flag = pi.getFlag("rlm");
199
225
  if (typeof flag === "boolean") controller.setConfig(Object.freeze({ ...controller.config, enabled: flag }));
200
226
 
201
- // Reload the catalog before the worker-model pick below reads it. Newer pi builds make
227
+ // Reload the catalog before the worker pick below reads it. Newer pi builds make
202
228
  // `getAvailable()` an async-populated snapshot that starts empty, and picking from an empty
203
229
  // catalog silently falls back to the root model. Called with no arguments and awaited so it
204
230
  // is valid whether `refresh` returns void (current) or a promise (newer); fail-soft, because
@@ -253,17 +279,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
253
279
  const llmModel = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
254
280
  const model = ctx.model;
255
281
  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
282
  // v5 provider caps (audit C1/C6): ONE resolver shared by both composition roots — the
268
283
  // repl() tool and RlmController.start admit through the same pool, each gate capped
269
284
  // against the model that actually runs on it (leaves = worker, children = smart).
@@ -294,7 +309,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
294
309
  try {
295
310
  pi.registerTool(createReplTool({
296
311
  sandboxManager,
312
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pi-ai registry types Model<any>; runtime models conform to Model<Api>
297
313
  model,
314
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- see above
298
315
  llmModel,
299
316
  getModel: () => controller.resolveModels(ctx)?.model,
300
317
  getLlmModel: () => controller.resolveModels(ctx)?.llm,
@@ -304,7 +321,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
304
321
  resolveGates: resolveSessionGates,
305
322
  background,
306
323
  runRegistry,
307
- memory,
324
+ skillStore,
325
+ onRunState: (state) => { rootTracker?.absorbEngineState(state); },
326
+ getSkillBlock: composeSkillBlock,
308
327
  registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
309
328
  registerContextBundle: (bundle) => {
310
329
  contextBundleRef = bundle;
@@ -327,7 +346,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
327
346
  }
328
347
  }
329
348
 
330
- setRlmModeStatus(ctx, controller, ctx.getContextUsage());
349
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage(), sigmaTelemetry());
331
350
  if (!treePanelInstalled) {
332
351
  treePanelInstalled = true;
333
352
  installTreePanel(ctx, runRegistry);
@@ -340,7 +359,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
340
359
 
341
360
  // ── Keep the footer's context reading live (RLM exists to shrink this number) ──
342
361
  pi.on("turn_end", async (_event, ctx) => {
343
- setRlmModeStatus(ctx, controller, ctx.getContextUsage());
362
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage(), sigmaTelemetry());
344
363
  });
345
364
 
346
365
  /** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
@@ -350,10 +369,116 @@ export default function rlmExtension(pi: ExtensionAPI): void {
350
369
  activeToolNames: typeof pi.getActiveTools === "function" ? pi.getActiveTools() : undefined,
351
370
  });
352
371
 
372
+ /** Root Σ WS-3 gate: config flag AND the bench A/B toggle (RLM_BENCH_NO_ROOTCONTEXT=1 skips). */
373
+ const rootContextActive = (): boolean =>
374
+ controller.config.enableRootContextTransform && process.env.RLM_BENCH_NO_ROOTCONTEXT !== "1";
375
+
353
376
  // ── System prompt: native RLM mode addendum (only when the trade holds) ──
354
377
  pi.on("before_agent_start", async (event) => {
355
378
  if (!nativeTradeHolds()) return;
356
- return { systemPrompt: event.systemPrompt + "\n\n" + buildNativeSystemPrompt() };
379
+ // Root Σ: lazy tracker birth on the session's first prompt (the task IS that prompt).
380
+ rootTracker ??= RootStateTracker.fresh(event.prompt, controller.config.runStateRetryMax);
381
+ // Ξ (Workstream C / Root Σ WS-1): the block is PER-PROMPT text — BM25 ranks the store
382
+ // against the live user prompt (mid-session harvests surface immediately), falling back
383
+ // to the static slice when the prompt is empty. Concatenated here, so NATIVE_PROMPT_STATIC
384
+ // (the module-load snapshot) and its budget math stay untouched.
385
+ const xi = composeSkillBlock(xiQuery(event.prompt, event.systemPrompt.slice(0, 2_000)));
386
+ if (xi !== undefined) {
387
+ xiCompositions += 1;
388
+ if (traceEnabled) trace("root-xi.compose", { total: xiCompositions, chars: xi.length });
389
+ }
390
+ const xiPart = xi === undefined ? "" : `${xi}\n\n`;
391
+ // Root Σ WS-4: the user's latest ask IS the next step by definition; feed the tracker
392
+ // before the turn so the Σ snapshot in this turn's context carries it.
393
+ rootTracker?.setNextStep(event.prompt);
394
+ // Root Σ WS-4.2 (enableRootStateFences): a rejected ΔΣ_t from the previous turn surfaces
395
+ // as an observation message — the same error-as-observation retry ladder engine runs use.
396
+ const observation = rootTracker?.takePendingObservation();
397
+ const message = observation === undefined
398
+ ? undefined
399
+ : { customType: "rlm-sigma-observation", content: observation, display: false, details: undefined };
400
+ // R1 + R3 soak instrumentation: what the plugin RETURNS as the system prompt — the host
401
+ // applies result.systemPrompt verbatim (agent-session.js), so this line + the journal
402
+ // decide "wiring bug vs model non-compliance" for the fence contract.
403
+ const nativePrompt = buildNativeSystemPrompt({ stateFences: controller.config.enableRootStateFences });
404
+ if (traceEnabled) {
405
+ trace("root-prompt.composed", {
406
+ chars: nativePrompt.length,
407
+ fences: controller.config.enableRootStateFences,
408
+ contract: nativePrompt.includes("[state] Alongside"),
409
+ xi: xi !== undefined,
410
+ });
411
+ }
412
+ return {
413
+ ...(message === undefined ? {} : { message }),
414
+ systemPrompt: event.systemPrompt + "\n\n" + xiPart + nativePrompt,
415
+ };
416
+ });
417
+
418
+ // Root Σ WS-4.2 + v2 R4: capture model-proposed ΔΣ_t fences from finalized assistant
419
+ // replies and run them through the ONE patch validator (run-state.ts applyPatch). EVERY
420
+ // assistant turn feeds the ladder — a fence-free turn grows the idle streak, and
421
+ // RUN_STATE_IDLE_DEGRADE_TURNS consecutive idle turns degrade the tracker (G6 parity).
422
+ pi.on("message_end", async (event) => {
423
+ const tracker = rootTracker;
424
+ if (tracker === undefined || !controller.config.enableRootStateFences) return;
425
+ if (event.message.role !== "assistant") return;
426
+ const wasActive = tracker.isActive;
427
+ const outcome = tracker.applyFences(findStatePatches(agentMessageText(event.message)));
428
+ // R3 soak observability: per-turn fence outcomes — the soak-B bars (≥50% of turns commit
429
+ // ≥1 accepted delta, rejection storms <10%) are computed from these journal lines.
430
+ if (traceEnabled) {
431
+ trace("root-state.turn", {
432
+ ...outcome,
433
+ idle: tracker.idleTurns,
434
+ active: tracker.isActive,
435
+ degraded: wasActive && !tracker.isActive,
436
+ });
437
+ }
438
+ if (wasActive && !tracker.isActive) {
439
+ idleDegrades += 1;
440
+ if (traceEnabled) {
441
+ const reason = tracker.degradeReason ?? "unknown";
442
+ trace(reason.startsWith("idle") ? "root-state.idle-degrade" : "root-state.degrade", {
443
+ idleTurns: tracker.idleTurns,
444
+ reason,
445
+ });
446
+ }
447
+ }
448
+ });
449
+
450
+ // ── Root Σ WS-2: deterministic root compaction (no summary LLM call) ──
451
+ pi.on("session_before_compact", async (event) => {
452
+ if (!controller.config.enableRootDigestCompaction) return undefined;
453
+ if (!nativeTradeHolds() && !controller.enabled) return undefined;
454
+ try {
455
+ const result = buildRootDigestCompaction({
456
+ preparation: event.preparation,
457
+ branchEntries: event.branchEntries,
458
+ config: controller.config,
459
+ store: skillStore,
460
+ });
461
+ if (result !== undefined) {
462
+ rootDigests += 1;
463
+ if (traceEnabled) {
464
+ // V1 soak probe: host-consumed tokensBefore vs our recomputation over the same span.
465
+ trace("root-digest.built", {
466
+ count: rootDigests,
467
+ chars: result.compaction.summary.length,
468
+ tokensBefore: result.compaction.tokensBefore,
469
+ tokensBeforeRecomputed: result.tokensBeforeRecomputed,
470
+ });
471
+ }
472
+ }
473
+ // SessionBeforeCompactResult SHAPE: the host consumes `result.compaction` — return the
474
+ // WRAPPER, never the bare CompactionResult (a bare one reads as `compaction: undefined`
475
+ // host-side and silently falls back to the LLM summarizer).
476
+ return result === undefined ? undefined : { compaction: result.compaction };
477
+ } catch (err) {
478
+ // Fail-soft by contract (packages/agent/src/types.ts:191): Pi falls back to its summarizer.
479
+ if (traceEnabled) trace("root-digest.fail", { error: errorMessage(err) });
480
+ return undefined;
481
+ }
357
482
  });
358
483
 
359
484
  // ── Context injection: listing of whatever is currently loaded ──
@@ -367,6 +492,35 @@ export default function rlmExtension(pi: ExtensionAPI): void {
367
492
  );
368
493
  if (!nativeTradeHolds()) return { messages: filtered };
369
494
 
495
+ // Root Σ WS-3: per-call A_t on the clone Pi hands us (runner.ts structuredClone — the last
496
+ // returned array wins; the disk transcript is untouched). Elide stale payloads (§5.3),
497
+ // then splice exactly one fresh Σ snapshot. Fail-soft: a throw here must never break a turn.
498
+ if (rootContextActive()) {
499
+ try {
500
+ const elided = elideStalePayloads(filtered, {
501
+ keepTurns: controller.config.rootContextKeepTurns,
502
+ elideChars: controller.config.rootContextElideChars,
503
+ });
504
+ elidedMessages += elided;
505
+ const tracker = rootTracker;
506
+ // R4: a DEGRADED tracker stops splicing Σ (isActive gate) — pure input tax otherwise.
507
+ if (
508
+ controller.config.rootContextSnapshot && tracker !== undefined &&
509
+ tracker.isActive && !tracker.isEmpty
510
+ ) {
511
+ spliceSigmaSnapshot(filtered, tracker.snapshot(), tracker.rectifyHint(), {
512
+ withContract: controller.config.enableRootStateFences,
513
+ });
514
+ sigmaSplices += 1;
515
+ }
516
+ if (traceEnabled && (elided > 0 || sigmaSplices > 0)) {
517
+ trace("root-context.transform", { elided, total: elidedMessages, splices: sigmaSplices });
518
+ }
519
+ } catch (err) {
520
+ if (traceEnabled) trace("root-context.fail", { error: errorMessage(err) });
521
+ }
522
+ }
523
+
370
524
  type PiMessage = (typeof filtered)[number];
371
525
 
372
526
  const payload = sandboxManager.contextPayload;
@@ -398,17 +552,29 @@ export default function rlmExtension(pi: ExtensionAPI): void {
398
552
  const MUTATING_FILE_TOOLS = Object.freeze(new Set(["edit", "write"]));
399
553
 
400
554
  pi.on("tool_result", async (event, ctx) => {
555
+ // Root Σ WS-4: the tracker learns the session's shape from outcomes — deterministic, zero
556
+ // model cooperation; fresh results always outrank Σ (paper §5.3 observation override).
557
+ const tracker = rootTracker;
558
+ if (tracker !== undefined) {
559
+ tracker.observeToolResult(
560
+ event.toolName,
561
+ event.isError,
562
+ event.isError ? firstLine(textContentOf(event.content)) : "",
563
+ );
564
+ }
565
+
401
566
  // ── Keep RLM context fresh after native file mutations ──
402
567
  if (
403
568
  nativeTradeHolds()
404
569
  && MUTATING_FILE_TOOLS.has(event.toolName)
405
- && event.isError !== true
570
+ && !event.isError
406
571
  ) {
407
572
  const cwd = resolve(ctx?.cwd ?? process.cwd());
408
573
  const paths = extractEditPaths(event.input);
409
574
  for (const p of paths) {
410
575
  const body = await readDiskFile(p, cwd);
411
576
  if (body === null) continue;
577
+ rootTracker?.noteFact(`edited ${p}`); // WS-4: the tracker learns the session's file shape
412
578
  try {
413
579
  await sandboxManager.refreshFileFromDisk(p, body, cwd);
414
580
  // Listing must re-inject if we rewrote payload identity
@@ -435,6 +601,19 @@ export default function rlmExtension(pi: ExtensionAPI): void {
435
601
 
436
602
  // ── Session shutdown: cleanup ──
437
603
  pi.on("session_shutdown", async () => {
604
+ // Root Σ WS-4 harvest symmetry: the root tracker teaches the store exactly like engine
605
+ // runs — the ONE notesFromRunState path — then the existing flush persists everything.
606
+ // R0: enableSkillState is enforced; no config check remains on this path.
607
+ const tracker = rootTracker;
608
+ if (skillStore !== undefined && tracker !== undefined && tracker.dirty) {
609
+ try {
610
+ skillStore.merge(notesFromRunState(tracker.snapshot()));
611
+ if (traceEnabled) trace("root-harvest.merged", { notes: tracker.snapshot().verifiedFacts.length });
612
+ } catch (err) {
613
+ if (traceEnabled) trace("root-harvest.fail", { error: errorMessage(err) });
614
+ }
615
+ }
616
+ await skillStore?.flush(); // SKILL.state (Workstream B): persist distilled notes
438
617
  controller.abort();
439
618
  clearInterval(watchdogHeartbeat);
440
619
  background.dispose();
@@ -444,6 +623,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
444
623
  contextBundleRef = undefined;
445
624
  listingPayloadRef = undefined;
446
625
  listingInjected = false;
626
+ skillStore = undefined;
627
+ rootTracker = undefined;
447
628
  sandboxManager.contextPayload = [];
448
629
  });
449
630
  }
@@ -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;