@hicaru/pi-rlm 0.3.6 → 0.3.9

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 (63) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/bridge/handlers/emitting.ts +5 -23
  4. package/src/bridge/handlers/index.ts +1 -1
  5. package/src/bridge/handlers/llm-query.ts +84 -29
  6. package/src/bridge/handlers/rlm-query.ts +133 -33
  7. package/src/bridge/handlers/types.ts +12 -0
  8. package/src/commands/pins.ts +51 -0
  9. package/src/commands/rlm-config.ts +4 -88
  10. package/src/commands/rlm-llm.ts +59 -0
  11. package/src/commands/rlm-rlm.ts +58 -0
  12. package/src/commands/rlm.ts +2 -2
  13. package/src/config/defaults.ts +17 -0
  14. package/src/config/settings.ts +58 -5
  15. package/src/core/answer.ts +7 -10
  16. package/src/core/budget.ts +182 -0
  17. package/src/core/compaction.ts +46 -0
  18. package/src/core/engine.ts +185 -5
  19. package/src/core/iteration.ts +5 -0
  20. package/src/core/ledger.ts +343 -0
  21. package/src/core/memory.ts +589 -0
  22. package/src/core/model-registry.ts +88 -0
  23. package/src/core/types.ts +44 -3
  24. package/src/index.ts +107 -12
  25. package/src/mode/rlm-mode.ts +58 -10
  26. package/src/prompts/glossary.ts +147 -57
  27. package/src/prompts/native.ts +12 -7
  28. package/src/prompts/system.ts +22 -7
  29. package/src/prompts/user.ts +6 -3
  30. package/src/sandbox/interrupts.ts +24 -0
  31. package/src/sandbox/protocol.ts +69 -5
  32. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  33. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  34. package/src/sandbox/py/guards.py +11 -6
  35. package/src/sandbox/py/scaffold.py +615 -0
  36. package/src/sandbox/py/worker.py +53 -506
  37. package/src/sandbox/sandbox.ts +21 -3
  38. package/src/text/repl-output.ts +15 -0
  39. package/src/tool/repl-render.ts +4 -10
  40. package/src/tool/repl-result.ts +54 -10
  41. package/src/tool/repl-tool.ts +50 -3
  42. package/src/tool/rlm-aggregator.ts +16 -3
  43. package/src/tool/rlm-details.ts +7 -0
  44. package/src/tool/rlm-events.ts +17 -1
  45. package/src/tool/rlm-tool.ts +25 -14
  46. package/src/tool/subcall-render.ts +14 -129
  47. package/src/tool/subcall-store.ts +11 -1
  48. package/src/ui/intro.ts +13 -4
  49. package/src/ui/modal/agent-modal.ts +104 -0
  50. package/src/ui/modal/modal-view.ts +132 -0
  51. package/src/ui/modal/timeline-store.ts +85 -0
  52. package/src/ui/model-picker/drilldown.ts +173 -0
  53. package/src/ui/model-picker/grouping.ts +81 -0
  54. package/src/ui/model-picker/levels.ts +63 -0
  55. package/src/ui/model-picker.ts +7 -197
  56. package/src/ui/panel/run-registry.ts +135 -0
  57. package/src/ui/panel/tree-panel.ts +46 -0
  58. package/src/ui/status.ts +26 -10
  59. package/src/ui/theme.ts +0 -4
  60. package/src/ui/tree/tree-model.ts +221 -0
  61. package/src/ui/tree/tree-rows.ts +73 -0
  62. package/src/ui/tree/tree-widget.ts +186 -0
  63. package/src/util/concurrency.ts +47 -0
package/src/core/types.ts CHANGED
@@ -15,9 +15,10 @@ export interface RlmConfig {
15
15
  readonly maxDepth: number;
16
16
  /** Max turns before the engine must finalize. */
17
17
  readonly maxIterations: number;
18
- /** Per-`repl`-block wall-clock timeout inside the worker (seconds). */
18
+ /** Per-`repl`-block wall-clock timeout inside the worker (seconds).
19
+ * v5 doctrine: content limits are the token budget's job — this is a HANG backstop only. */
19
20
  readonly execTimeoutS: number;
20
- /** Parent-side watchdog per sandbox request (ms). */
21
+ /** Parent-side watchdog per sandbox request (ms). Hang backstop (see execTimeoutS). */
21
22
  readonly requestTimeoutMs: number;
22
23
  /** Concurrency pool for *_batched sub-calls. */
23
24
  readonly maxConcurrentSubcalls: number;
@@ -28,7 +29,10 @@ export interface RlmConfig {
28
29
  readonly maxPromptChars: number;
29
30
  /** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */
30
31
  readonly maxTimeoutMs?: number;
31
- /** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap). */
32
+ /** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap).
33
+ * ⚠ HARD ABORT (audit H8): when set, exceeding this throws a LimitError mid-run and the run
34
+ * ends with its best partial — NO wrap-up, NO continuation. The graceful path is the v5
35
+ * token budget (`enableTokenBudget`); leave this unset unless a hard tree-wide stop is wanted. */
32
36
  readonly maxTokens?: number;
33
37
  /** Max consecutive error turns before the engine stops (undefined = no cap). */
34
38
  readonly maxErrors?: number;
@@ -61,6 +65,37 @@ export interface RlmConfig {
61
65
  readonly subSystemPrompt?: string;
62
66
  /** Sampling for sub-LLM (worker) calls. */
63
67
  readonly subSampling: Readonly<Sampling>;
68
+ /** v5 token budget: cap = budgetShare × contextWindow, clamped by budgetTaskCap. When on,
69
+ * the budget is the PRIMARY run-length control (soft wrap-up → continuation chain). */
70
+ readonly enableTokenBudget: boolean;
71
+ /** Fraction of the model's context window that forms one run's token cap. */
72
+ readonly budgetShare: number;
73
+ /** Soft wrap-up fires at this fraction of the cap (one wrap-up turn). */
74
+ readonly budgetSoftFrac: number;
75
+ /** Absolute single-run ceiling; 0 = no clamp beyond the share. */
76
+ readonly budgetTaskCap: number;
77
+ /** Max continuation runs after a hard stop (chain ≤ 1 + this). */
78
+ readonly budgetMaxContinuations: number;
79
+ /** Char budget for the deterministic continuation handoff. */
80
+ readonly budgetHandoffChars: number;
81
+ /** v5 TaskLedger blackboard: claim coalescing + ancestor-echo reject + `[ledger]` injection. */
82
+ readonly enableLedger: boolean;
83
+ /** Real rlm spawns allowed before extra rlm_query demotes to llm_query (0 = never). */
84
+ readonly rlmBudget: number;
85
+ /** v5 durable memory: L1 episode replay + L2 BM25 notes under `<root>/.rlm/memory`. */
86
+ readonly enableMemory: boolean;
87
+ /** Char budget for the `[memory]` injection = tokens × 4. */
88
+ readonly injectNoteTokens: number;
89
+ /** Pending episodes per L2 consolidation batch (0 = never auto-consolidate). */
90
+ readonly evolveEvery: number;
91
+ /** Override the memory dir. `null` (default) = `<root>/.rlm/memory` — this field only
92
+ * RELOCATES the store; the on/off switch is `enableMemory` (audit M3). */
93
+ readonly memoryDir: string | null;
94
+ /** v5: per-provider concurrent-request caps (e.g. `{ zai: 4 }`). Caps only lower limits. */
95
+ readonly providerMaxConcurrent?: Readonly<Record<string, number>>;
96
+ /** v5 doctrine: "delegation" = child engines get llm/memory/ledger only (no repo retrieval);
97
+ * "legacy" keeps today's full child surface as a one-flip rollback. */
98
+ readonly childSurface: "delegation" | "legacy";
64
99
  }
65
100
 
66
101
  /** Input to a (headless) RLM run. */
@@ -75,6 +110,12 @@ export interface RlmInput {
75
110
  readonly parentNodeId?: string;
76
111
  /** Remaining timeout for this subtree (set by parent from its LimitGuard). */
77
112
  readonly remainingTimeoutMs?: number;
113
+ /** v5: budget for this run. Set only by the engine itself when chaining a continuation —
114
+ * a fresh budget is resolved from config when omitted. */
115
+ readonly budget?: import("./budget.ts").TokenBudget;
116
+ /** v5: the shared TaskLedger blackboard. Children inherit the parent's instance —
117
+ * set by childRun (the one child-RlmInput construction path); a fresh run gets a new one. */
118
+ readonly ledger?: import("./ledger.ts").TaskLedger;
78
119
  }
79
120
 
80
121
  /** Result of a completed RLM run. */
package/src/index.ts CHANGED
@@ -4,6 +4,9 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { Markdown } from "@earendil-works/pi-tui";
5
5
  import { registerRlmCommand } from "./commands/rlm.ts";
6
6
  import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
7
+ import { registerRlmLlmCommand } from "./commands/rlm-llm.ts";
8
+ import { registerRlmRlmCommand } from "./commands/rlm-rlm.ts";
9
+ import type { RlmConfig } from "./core/types.ts";
7
10
  import { createRlmTool } from "./tool/rlm-tool.ts";
8
11
  import { createReplTool } from "./tool/repl-tool.ts";
9
12
  import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts";
@@ -11,10 +14,15 @@ import { RlmController } from "./mode/rlm-mode.ts";
11
14
  import { cheapestModel } from "./mode/llm-model.ts";
12
15
  import { postRlmGuide } from "./ui/intro.ts";
13
16
  import { setRlmModeStatus } from "./ui/status.ts";
17
+ import { RunRegistry } from "./ui/panel/run-registry.ts";
18
+ import { installTreePanel } from "./ui/panel/tree-panel.ts";
14
19
  import { markdownTheme } from "./ui/theme-adapter.ts";
20
+ import { SANDBOX_WATCHDOG_HEARTBEAT_MS } from "./sandbox/sandbox.ts";
15
21
  import { SandboxManager } from "./sandbox/sandbox-manager.ts";
16
- import { createSubcallGates } from "./util/concurrency.ts";
22
+ import { buildSessionGates, type SubcallGates } from "./util/concurrency.ts";
17
23
  import { BackgroundTasks } from "./tool/background-tasks.ts";
24
+ import { MemoryStore } from "./core/memory.ts";
25
+ import { modelComplete } from "./bridge/model.ts";
18
26
  import { resolve } from "node:path";
19
27
  import { resolveSource } from "./context/resolve.ts";
20
28
  import { formatContextListing } from "./context/listing.ts";
@@ -38,8 +46,6 @@ export {
38
46
  processRlmDepth,
39
47
  } from "./mode/subagent.ts";
40
48
 
41
- /** How often to keep the parent sandbox's request watchdog alive during detached work. */
42
- const WATCHDOG_HEARTBEAT_MS = 30_000;
43
49
  /** Soft token guard — cap bulk tool stdout; do NOT hard-block read/grep/bash readers. */
44
50
  const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls", "read", "grep"]));
45
51
 
@@ -62,7 +68,21 @@ export default function rlmExtension(pi: ExtensionAPI): void {
62
68
 
63
69
  // Init synchronously with defaults — ensures commands/tools/handlers register before session_start
64
70
  const config = mergeConfig({});
65
- const controller = new RlmController(config);
71
+ // v5 durable memory: one store per session under <cwd>/.rlm/memory (L1 replay + L2 notes).
72
+ // NOTE (audit M4): this store IS shared by both composition roots, but the TaskLedger is
73
+ // NOT — the native repl() session and each headless rlm run each keep their own blackboard
74
+ // (v5 parity: per-run ledger). Claims/coalescing reset at that boundary, by design.
75
+ // The consolidation LLM + real workspace root are attached in session_start (setLlm/setRoot).
76
+ const memory = new MemoryStore(
77
+ process.cwd(),
78
+ {
79
+ dir: config.memoryDir ?? undefined,
80
+ injectNoteTokens: config.injectNoteTokens,
81
+ evolveEvery: config.evolveEvery,
82
+ },
83
+ config.enableMemory,
84
+ );
85
+ const controller = new RlmController(config, memory);
66
86
  let onSandboxDiscardExtra: (() => void) | undefined;
67
87
  const sandboxManager = new SandboxManager({
68
88
  execTimeoutS: config.execTimeoutS,
@@ -75,20 +95,32 @@ export default function rlmExtension(pi: ExtensionAPI): void {
75
95
  awaitTimeoutS: Math.round(config.requestTimeoutMs / 1000),
76
96
  onSandboxDiscarded: () => { onSandboxDiscardExtra?.(); },
77
97
  });
78
- // One admission gate for the whole session: spawn() lets the sandbox put many requests on
79
- // the wire at once, so nothing smaller than session scope actually bounds fan-out.
80
- const gates = createSubcallGates(config.maxConcurrentSubcalls, config.maxConcurrentChildren);
98
+ // v5: sub-call admission is built per session (see session_start) so provider concurrency
99
+ // caps resolve against the models actually in use.
81
100
  const background = new BackgroundTasks({
82
101
  maxTimeoutMs: config.maxTimeoutMs,
83
102
  maxTokens: config.maxTokens,
84
103
  maxErrors: config.maxErrors,
85
104
  });
105
+ // Session tree panel: every repl cell / rlm run / detached bg task registers here;
106
+ // the below-editor widget + agent modal read from it. Background is persistent
107
+ // and hides itself while idle.
108
+ const runRegistry = new RunRegistry();
109
+ runRegistry.register({
110
+ runId: "background",
111
+ label: "background tasks",
112
+ emitter: background.emitter,
113
+ subcalls: () => background.liveSubcalls(),
114
+ totals: () => background.liveTotals(),
115
+ hideWhenEmpty: true,
116
+ });
117
+ let treePanelInstalled = false;
86
118
  // A detached child works in its OWN sandbox, so this one sees no frames and its request
87
119
  // watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
88
120
  // with it. Keep it alive while detached work is genuinely in flight.
89
121
  const watchdogHeartbeat = setInterval(() => {
90
122
  if (background.pending > 0) sandboxManager.refreshWatchdog();
91
- }, WATCHDOG_HEARTBEAT_MS);
123
+ }, SANDBOX_WATCHDOG_HEARTBEAT_MS);
92
124
  watchdogHeartbeat.unref();
93
125
 
94
126
  /** Memoised cwd seed — one resolveSource(pathPrefix:"") per session. */
@@ -146,9 +178,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
146
178
  // ── Commands ──
147
179
  registerRlmCommand(pi, controller);
148
180
  registerRlmConfigCommand(pi, controller);
181
+ registerRlmLlmCommand(pi, controller);
182
+ registerRlmRlmCommand(pi, controller);
149
183
 
150
184
  // ── Tool registration ──
151
- pi.registerTool(createRlmTool(controller));
185
+ pi.registerTool(createRlmTool(controller, runRegistry));
152
186
  let guidePosted = false;
153
187
 
154
188
  pi.on("session_start", async (_event, ctx) => {
@@ -157,6 +191,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
157
191
  const persisted = await loadSettings();
158
192
  controller.config = mergeConfig(persisted.config);
159
193
  controller.savedLlmRef = persisted.llm ?? undefined;
194
+ controller.savedRlmRef = persisted.rlm ?? undefined;
160
195
 
161
196
  // An explicit --rlm flag wins over the persisted setting for this session.
162
197
  const flag = pi.getFlag("rlm");
@@ -173,6 +208,25 @@ export default function rlmExtension(pi: ExtensionAPI): void {
173
208
  console.warn(`[rlm] model registry refresh failed: ${errorMessage(err)}`);
174
209
  }
175
210
 
211
+ if (controller.savedRlmRef) {
212
+ const resolvedRlm = resolveModelId(ctx.modelRegistry, controller.savedRlmRef);
213
+ if (resolvedRlm) {
214
+ controller.rlmModel = resolvedRlm;
215
+ } else {
216
+ console.warn(
217
+ `[rlm] pinned rlm model ${controller.savedRlmRef} not in registry; following session model until it reappears`,
218
+ );
219
+ try {
220
+ ctx.ui.notify(
221
+ `RLM: pinned rlm=${controller.savedRlmRef} unavailable — following session model until it is`,
222
+ "warning",
223
+ );
224
+ } catch {
225
+ // Some hosts have no UI at session_start.
226
+ }
227
+ }
228
+ }
229
+
176
230
  if (controller.savedLlmRef) {
177
231
  const resolved = resolveModelId(ctx.modelRegistry, controller.savedLlmRef);
178
232
  if (resolved) {
@@ -198,6 +252,40 @@ export default function rlmExtension(pi: ExtensionAPI): void {
198
252
  const llmModel = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
199
253
  const model = ctx.model;
200
254
  if (llmModel && model) {
255
+ // Consolidation runs on the cheap worker model through the single completion entry point;
256
+ // the workspace root is only known once the session starts.
257
+ const consolidateModel = llmModel;
258
+ memory.setLlm((prompt) =>
259
+ modelComplete([{ role: "user", content: prompt }], { model: consolidateModel, registry: ctx.modelRegistry })
260
+ .then((r) => r.text));
261
+ memory.setRoot(ctx.cwd ?? process.cwd());
262
+ // v5 provider caps (audit C1/C6): ONE resolver shared by both composition roots — the
263
+ // repl() tool and RlmController.start admit through the same pool, each gate capped
264
+ // against the model that actually runs on it (leaves = worker, children = smart).
265
+ // Memoized on (config, providers) so /rlm-config changes apply without a restart.
266
+ let gatesMemo:
267
+ | { readonly config: RlmConfig; readonly smart: string; readonly worker: string; readonly gates: SubcallGates }
268
+ | undefined;
269
+ const resolveSessionGates = (): SubcallGates => {
270
+ const smart = model;
271
+ const worker = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
272
+ const workerProvider = worker.provider;
273
+ if (
274
+ gatesMemo === undefined ||
275
+ gatesMemo.config !== controller.config ||
276
+ gatesMemo.smart !== smart.provider ||
277
+ gatesMemo.worker !== workerProvider
278
+ ) {
279
+ gatesMemo = {
280
+ config: controller.config,
281
+ smart: smart.provider,
282
+ worker: workerProvider,
283
+ gates: buildSessionGates(controller.config, smart.provider, workerProvider),
284
+ };
285
+ }
286
+ return gatesMemo.gates;
287
+ };
288
+ controller.setSessionGates(resolveSessionGates);
201
289
  try {
202
290
  pi.registerTool(createReplTool({
203
291
  sandboxManager,
@@ -207,8 +295,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
207
295
  getLlmModel: () => controller.resolveModels(ctx)?.llm,
208
296
  registry: ctx.modelRegistry,
209
297
  getConfig: () => controller.config,
210
- gates,
298
+ gates: resolveSessionGates(),
299
+ resolveGates: resolveSessionGates,
211
300
  background,
301
+ runRegistry,
302
+ memory,
212
303
  registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
213
304
  registerContextBundle: (bundle) => {
214
305
  contextBundleRef = bundle;
@@ -231,7 +322,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
231
322
  }
232
323
  }
233
324
 
234
- setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
325
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
326
+ if (!treePanelInstalled) {
327
+ treePanelInstalled = true;
328
+ installTreePanel(ctx, runRegistry);
329
+ }
235
330
  if (!guidePosted && controller.enabled) {
236
331
  guidePosted = true;
237
332
  postRlmGuide(pi, controller);
@@ -240,7 +335,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
240
335
 
241
336
  // ── Keep the footer's context reading live (RLM exists to shrink this number) ──
242
337
  pi.on("turn_end", async (_event, ctx) => {
243
- setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
338
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
244
339
  });
245
340
 
246
341
  /** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
@@ -9,13 +9,16 @@
9
9
  import type { Api, Model } from "@earendil-works/pi-ai";
10
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { modelRef, resolveModelId, saveSettings } from "../config/settings.ts";
12
- import { createEngine } from "../core/engine.ts";
12
+ import { createEngine, type EngineDeps } from "../core/engine.ts";
13
13
  import { limitsFromConfig } from "../core/limits.ts";
14
14
  import type { RlmConfig, RlmResult } from "../core/types.ts";
15
15
  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";
20
+ import type { RunRlm } from "../core/types.ts";
21
+ import type { SubcallGates } from "../util/concurrency.ts";
19
22
 
20
23
  export interface RunHandle {
21
24
  readonly abort: () => void;
@@ -32,9 +35,25 @@ export class RlmController {
32
35
  savedLlmRef: string | undefined;
33
36
  /** Set by applyLlmSelection when the user explicitly picks "cheapest (auto)". */
34
37
  explicitClearPin = false;
38
+ /** Pinned rlm root/worker model — when unset, child engines follow pi's session model. */
39
+ rlmModel: Model<Api> | undefined;
40
+ savedRlmRef: string | undefined;
41
+ /** Set by applyRlmSelection when the user explicitly picks "(follow session model)". */
42
+ explicitClearRlmPin = false;
35
43
  private active: AbortController | null = null;
36
-
37
- constructor(public config: RlmConfig) {}
44
+ /** v5: session admission gates (provider-capped), shared with the repl() tool — set at
45
+ * session_start so BOTH composition roots admit through one pool (audit C1). */
46
+ private sessionGates: (() => SubcallGates) | undefined;
47
+
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
+ ) {}
53
+
54
+ setSessionGates(getGates: () => SubcallGates): void {
55
+ this.sessionGates = getGates;
56
+ }
38
57
 
39
58
  get enabled(): boolean {
40
59
  return this.config.enabled;
@@ -64,6 +83,9 @@ export class RlmController {
64
83
  llm: this.explicitClearPin
65
84
  ? null
66
85
  : (modelRef(this.llmModel) ?? this.savedLlmRef),
86
+ rlm: this.explicitClearRlmPin
87
+ ? null
88
+ : (modelRef(this.rlmModel) ?? this.savedRlmRef),
67
89
  });
68
90
  }
69
91
 
@@ -77,12 +99,41 @@ export class RlmController {
77
99
 
78
100
  resolveModels(ctx: ExtensionContext): { model: Model<Api>; llm: Model<Api> } | undefined {
79
101
  if (!this.llmModel && this.savedLlmRef) this.llmModel = resolveModelId(ctx.modelRegistry, this.savedLlmRef);
80
- const model = ctx.model ?? cheapestModel(ctx.modelRegistry);
102
+ if (!this.rlmModel && this.savedRlmRef) this.rlmModel = resolveModelId(ctx.modelRegistry, this.savedRlmRef);
103
+ // The rlm pin wins over the session model; unset → follow pi's active model.
104
+ const model = this.rlmModel ?? ctx.model ?? cheapestModel(ctx.modelRegistry);
81
105
  if (!model) return undefined;
82
106
  const llm = this.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
83
107
  return { model, llm };
84
108
  }
85
109
 
110
+ /** Test seam (audit R7): intercept the exact object `createEngine` receives. */
111
+ protected spawnEngine(deps: EngineDeps): RunRlm {
112
+ return createEngine(deps);
113
+ }
114
+
115
+ /** The ONE engine construction path for this controller (DRY #6 — a second path that
116
+ * forgets to grow is exactly how issue #4 and audit C1 happened). Protected so tests can
117
+ * subclass and assert the wiring without touching the network. */
118
+ protected buildEngine(args: {
119
+ readonly ctx: ExtensionContext;
120
+ readonly models: { readonly model: Model<Api>; readonly llm: Model<Api> };
121
+ readonly signal: AbortSignal;
122
+ readonly emitter: RlmEmitter;
123
+ }): RunRlm {
124
+ return this.spawnEngine({
125
+ model: args.models.model,
126
+ llmModel: args.models.llm,
127
+ registry: args.ctx.modelRegistry,
128
+ config: this.config,
129
+ signal: args.signal,
130
+ emitter: args.emitter,
131
+ limits: limitsFromConfig(this.config),
132
+ memory: this.memory,
133
+ gates: this.sessionGates?.(),
134
+ });
135
+ }
136
+
86
137
  start(ctx: ExtensionContext, input: StartInput, emitter?: RlmEmitter): RunHandle {
87
138
  const models = this.resolveModels(ctx);
88
139
  if (!models) throw new Error("no model with configured auth is available");
@@ -102,14 +153,11 @@ export class RlmController {
102
153
  ? result.value.payload
103
154
  : formatError(`failed to pack repository — ${result.error}`);
104
155
  }
105
- const engine = createEngine({
106
- model: models.model,
107
- llmModel: models.llm,
108
- registry: ctx.modelRegistry,
109
- config: this.config,
156
+ const engine = this.buildEngine({
157
+ ctx,
158
+ models,
110
159
  signal: abortController.signal,
111
160
  emitter: emitter ?? new RlmEmitter(),
112
- limits: limitsFromConfig(this.config),
113
161
  });
114
162
  return await engine({ rootPrompt: input.rootPrompt, context: contextValue, depth: 0 });
115
163
  })().finally(() => {