@hicaru/pi-rlm 0.2.1 → 0.2.2

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 (61) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +63 -17
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +6 -17
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +55 -335
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +23 -12
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -407
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +8 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/{worker.py → py/worker.py} +76 -696
  30. package/src/sandbox/sandbox-manager.ts +13 -0
  31. package/src/sandbox/sandbox.ts +99 -193
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/repl-details.ts +2 -2
  34. package/src/tool/repl-render.ts +58 -0
  35. package/src/tool/repl-result.ts +70 -0
  36. package/src/tool/repl-tool.ts +37 -159
  37. package/src/tool/rlm-aggregator.ts +2 -10
  38. package/src/tool/rlm-details.ts +0 -2
  39. package/src/tool/rlm-events.ts +0 -14
  40. package/src/tool/rlm-tool.ts +1 -12
  41. package/src/ui/config-panel.ts +4 -16
  42. package/src/ui/intro.ts +1 -2
  43. package/src/ui/model-picker.ts +34 -10
  44. package/src/ui/status.ts +3 -7
  45. package/src/util/concurrency.ts +9 -5
  46. package/src/bridge/fallback-todo.ts +0 -148
  47. package/src/bridge/interactive.ts +0 -65
  48. package/src/bridge/pi-interactive.ts +0 -41
  49. package/src/core/artifacts.ts +0 -89
  50. package/src/core/critique.ts +0 -92
  51. package/src/core/gates.ts +0 -301
  52. package/src/core/pipeline-handlers.ts +0 -319
  53. package/src/core/pipeline.ts +0 -268
  54. package/src/prompts/phases.ts +0 -104
  55. package/src/state/index.ts +0 -24
  56. package/src/state/internal.ts +0 -46
  57. package/src/state/paths.ts +0 -44
  58. package/src/state/reads.ts +0 -133
  59. package/src/state/resume.ts +0 -173
  60. package/src/state/rows.ts +0 -123
  61. package/src/state/writes.ts +0 -58
@@ -20,6 +20,7 @@ import { displayModelRef, modelRef, resolveModelId } from "../config/settings.ts
20
20
  import { type ChatMsg, modelComplete } from "./model.ts";
21
21
  import { previewText } from "../text/preview.ts";
22
22
  import { checkResourceLimits } from "../core/resource-limits.ts";
23
+ import { filterContextByPaths } from "../context/library-context.ts";
23
24
  import type { RlmInput, RlmResult, Sampling } from "../core/types.ts";
24
25
  import type { SubcallGates } from "../util/concurrency.ts";
25
26
  import type { SubcallOpts, SubLlmHandlers } from "../sandbox/sandbox.ts";
@@ -28,11 +29,10 @@ import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
28
29
 
29
30
  /**
30
31
  * The slice of LimitGuard these handlers need. Narrow on purpose: the headless bridge is
31
- * constructed from a `remainingBudget()` callback rather than owning a guard, and both
32
+ * constructed from a remaining-timeout callback rather than owning a guard, and both
32
33
  * shapes satisfy this.
33
34
  */
34
35
  export interface InvocationLimits {
35
- remainingBudgetUsd(): number | undefined;
36
36
  remainingTimeoutMs(): number | undefined;
37
37
  addUsage(usage: Usage): void;
38
38
  addRaw(costUsd: number, inputTokens: number, outputTokens: number): void;
@@ -45,10 +45,9 @@ export interface InvocationLimits {
45
45
  * `onChildUsage`, so the accounting methods here are deliberately inert.
46
46
  */
47
47
  export function limitsFromRemaining(
48
- remaining?: () => { readonly budgetUsd?: number; readonly timeoutMs?: number },
48
+ remaining?: () => { readonly timeoutMs?: number },
49
49
  ): InvocationLimits {
50
50
  return {
51
- remainingBudgetUsd: () => remaining?.().budgetUsd,
52
51
  remainingTimeoutMs: () => remaining?.().timeoutMs,
53
52
  addUsage: () => {},
54
53
  addRaw: () => {},
@@ -60,7 +59,7 @@ export function limitsFromRemaining(
60
59
  *
61
60
  * Captured at interrupt entry and threaded down, never re-read: once handlers can outlive
62
61
  * their exec, re-reading mutable tool state after an await would attribute a sub-call to
63
- * whichever turn happens to be current when it resumes.
62
+ * whichever turn happens to be current when it settles.
64
63
  */
65
64
  export interface Invocation {
66
65
  readonly emitter: RlmEmitter;
@@ -91,7 +90,7 @@ export interface SubcallHandlerDeps {
91
90
  /** Session-wide admission control. Required — a per-caller default would silently unbound it. */
92
91
  readonly gates: SubcallGates;
93
92
  readonly registry: ModelRegistry;
94
- readonly getWorkerModel: () => Model<Api>;
93
+ readonly getLlmModel: () => Model<Api>;
95
94
  /** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
96
95
  readonly getConfig: () => SubcallConfig;
97
96
  readonly signal?: AbortSignal;
@@ -107,6 +106,15 @@ export interface SubcallHandlerDeps {
107
106
  * one emitter is what lets the session registry drain the subtree intact.
108
107
  */
109
108
  readonly runChild?: (input: RlmInput, inv: Invocation) => Promise<RlmResult>;
109
+ /**
110
+ * The parent's live context, read at spawn time and never captured: a library loaded on turn 3
111
+ * must reach a child spawned on turn 4. `undefined`/`null` ⇒ no inheritance, and the child falls
112
+ * back to prompt-as-context.
113
+ *
114
+ * This is the ONLY inheritance seam. Adding a second construction path for a child's world
115
+ * would re-open issue #4 on whichever path forgets to grow.
116
+ */
117
+ readonly getChildContext?: () => unknown;
110
118
  readonly getModel?: () => Model<Api>;
111
119
  /**
112
120
  * What rlm_query degrades to at the depth cap. A child RLM there would just be an LM, so
@@ -141,10 +149,18 @@ function emptyResult(answer: string): RlmResult {
141
149
  return { answer, iterations: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, durationMs: 0 };
142
150
  }
143
151
 
152
+ /** What a child RLM will see, plus any `paths=` prefix that selected nothing. */
153
+ interface ChildContext {
154
+ readonly context: unknown;
155
+ readonly unmatched: readonly string[];
156
+ }
157
+
158
+ const NO_UNMATCHED: readonly string[] = Object.freeze([]);
159
+
144
160
  export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers {
145
161
  /** DRY #3 — the one display-model resolution. */
146
162
  const displayModel = (model: string | null): string =>
147
- displayModelRef(deps.registry, model, deps.getWorkerModel());
163
+ displayModelRef(deps.registry, model, deps.getLlmModel());
148
164
 
149
165
  /** Detached work is counted by the session registry; attached work runs as-is. */
150
166
  const detachable = <T>(opts: SubcallOpts, run: () => Promise<T>): Promise<T> =>
@@ -159,7 +175,6 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
159
175
  ): Promise<string> {
160
176
  const config = deps.getConfig();
161
177
  const limitError = checkResourceLimits({
162
- budgetUsd: inv.limits.remainingBudgetUsd(),
163
178
  timeoutMs: inv.limits.remainingTimeoutMs(),
164
179
  });
165
180
  if (limitError !== undefined) return limitError;
@@ -174,7 +189,7 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
174
189
  try {
175
190
  const messages: ChatMsg[] = [{ role: "user", content: prompt }];
176
191
  const res = await deps.gates.leaf.run(() => modelComplete(messages, {
177
- model: resolved ?? deps.getWorkerModel(),
192
+ model: resolved ?? deps.getLlmModel(),
178
193
  registry: deps.registry,
179
194
  system: config.subSystemPrompt,
180
195
  maxTokens: config.subSampling?.maxTokens,
@@ -236,11 +251,38 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
236
251
  return out;
237
252
  }
238
253
 
254
+ /**
255
+ * Resolve the child's world: the parent's live context, optionally narrowed by path prefixes.
256
+ *
257
+ * Falls back to prompt-as-context when nothing is wired, and to the FULL context when `paths`
258
+ * matched nothing — a silently blind child is exactly the bug this fixes, so a bad prefix
259
+ * degrades loudly (see the note childRun folds into rootPrompt) rather than quietly.
260
+ */
261
+ function childContextFor(prompt: string, paths: readonly string[] | undefined): ChildContext {
262
+ const inherited = deps.getChildContext?.();
263
+ if (inherited === undefined || inherited === null) {
264
+ return Object.freeze({ context: prompt, unmatched: NO_UNMATCHED });
265
+ }
266
+ if (paths === undefined || paths.length === 0) {
267
+ return Object.freeze({ context: inherited, unmatched: NO_UNMATCHED });
268
+ }
269
+ const filtered = filterContextByPaths(inherited, paths);
270
+ return Object.freeze({
271
+ context: filtered.files.length > 0 ? filtered.files : inherited,
272
+ unmatched: filtered.unmatched,
273
+ });
274
+ }
275
+
239
276
  /**
240
277
  * One child RLM run: depth cap → resource guard → spawn engine → debit parent.
241
278
  * Emits its own subcall node, so callers must not wrap it in another.
242
279
  */
243
- async function childRun(inv: Invocation, prompt: string, model: string | null): Promise<RlmResult> {
280
+ async function childRun(
281
+ inv: Invocation,
282
+ prompt: string,
283
+ model: string | null,
284
+ paths: readonly string[] | undefined,
285
+ ): Promise<RlmResult> {
244
286
  const childDepth = inv.depth + 1;
245
287
  const run = deps.runChild;
246
288
  const maxDepth = deps.getConfig().maxDepth;
@@ -254,9 +296,8 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
254
296
  return emptyResult(answer);
255
297
  }
256
298
 
257
- const remBudget = inv.limits.remainingBudgetUsd();
258
299
  const remTimeout = inv.limits.remainingTimeoutMs();
259
- const limitError = checkResourceLimits({ budgetUsd: remBudget, timeoutMs: remTimeout });
300
+ const limitError = checkResourceLimits({ timeoutMs: remTimeout });
260
301
  if (limitError) return emptyResult(limitError);
261
302
 
262
303
  const rootModel = deps.getModel?.();
@@ -268,14 +309,19 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
268
309
  kind: "rlm", parentId: inv.parentId, label: "rlm_query",
269
310
  model: modelLabel, detail: prompt.slice(0, 60), depth: childDepth,
270
311
  });
312
+ // The child's context is the parent's world, not the prompt text. The prompt becomes the
313
+ // child's rootPrompt, exactly as a depth-0 run takes the user's question.
314
+ const child = childContextFor(prompt, paths);
315
+ const rootPrompt = child.unmatched.length === 0
316
+ ? prompt
317
+ : `${prompt}\n\n[rlm] paths=${child.unmatched.join(", ")} matched no files; you received the full context.`;
271
318
  try {
272
319
  const res = await deps.gates.rlm.at(childDepth).run(() => run({
273
- rootPrompt: "",
274
- context: prompt,
320
+ rootPrompt,
321
+ context: child.context,
275
322
  depth: childDepth,
276
323
  parentNodeId: subId,
277
324
  modelOverride: model ?? undefined,
278
- remainingBudgetUsd: remBudget,
279
325
  remainingTimeoutMs: remTimeout,
280
326
  }, inv));
281
327
  inv.limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
@@ -320,7 +366,7 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
320
366
  async rlmQuery(prompt, model, depth, opts) {
321
367
  const inv = deps.resolve(opts, depth);
322
368
  if (inv === null) return UNWIRED;
323
- return detachable(opts, async () => (await childRun(inv, prompt, model)).answer);
369
+ return detachable(opts, async () => (await childRun(inv, prompt, model, opts.paths)).answer);
324
370
  },
325
371
 
326
372
  async rlmQueryBatched(prompts, model, depth, opts) {
@@ -328,7 +374,7 @@ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers
328
374
  if (inv === null) return prompts.map(() => UNWIRED);
329
375
  // Bounded by the per-depth rlm gate inside childRun, not by an outer pool.
330
376
  return detachable(opts, async () => {
331
- const results = await Promise.all(prompts.map((p) => childRun(inv, p, model)));
377
+ const results = await Promise.all(prompts.map((p) => childRun(inv, p, model, opts.paths)));
332
378
  return results.map((r) => r.answer);
333
379
  });
334
380
  },
@@ -1,47 +1,76 @@
1
- /** `/rlm-config` — choose worker model, reasoning level, and run settings (smart is always pi's active model). */
1
+ /** `/rlm-config` — choose the sub-LLM model, reasoning level, and run settings.
2
+ * The root model is always pi's active model; only the sub-LLM is configurable here. */
2
3
 
4
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
6
  import { modelRef } from "../config/settings.ts";
5
- import { cheapestModel, type RlmController } from "../mode/rlm-mode.ts";
7
+ import type { RlmController } from "../mode/rlm-mode.ts";
8
+ import { cheapestModel } from "../mode/llm-model.ts";
6
9
  import { setRlmModeStatus } from "../ui/status.ts";
7
10
  import { showConfigPanel } from "../ui/config-panel.ts";
8
- import { selectModel } from "../ui/model-picker.ts";
11
+ import { pickableModels, selectModel } from "../ui/model-picker.ts";
12
+
13
+ /** Newer Pi hosts expose session-scoped models; 0.79 peers do not — duck-type safely. */
14
+ function sessionScopedModels(
15
+ ctx: ExtensionContext,
16
+ ): readonly { readonly model: Model<Api> }[] | undefined {
17
+ const scoped: unknown = Reflect.get(ctx, "scopedModels");
18
+ return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
19
+ }
9
20
 
10
21
  export async function runRlmConfig(controller: RlmController, ctx: ExtensionContext): Promise<boolean> {
11
- const models = ctx.modelRegistry.getAvailable();
22
+ // Match Pi's native list: refresh so a just-added key appears, then use scoped models when
23
+ // the session narrowed them, else every available (auth-configured) model. Never getAll().
24
+ try {
25
+ await ctx.modelRegistry.refresh();
26
+ } catch {
27
+ // Fail-soft: show the cached available snapshot rather than aborting config.
28
+ }
29
+ const models = pickableModels(ctx.modelRegistry, sessionScopedModels(ctx));
12
30
 
13
- const worker = await selectModel(ctx, "Worker model (sub-LLM / llm_query)", models, controller.workerModel, controller.config.subSampling.reasoning);
14
- if (worker !== undefined) {
15
- controller.workerModel = worker?.model;
31
+ const llm = await selectModel(
32
+ ctx,
33
+ "LLM model (sub-calls: llm_query / map_files / rlm_query)",
34
+ models,
35
+ controller.llmModel,
36
+ controller.config.subSampling.reasoning,
37
+ );
38
+ if (llm !== undefined) {
39
+ controller.llmModel = llm?.model;
16
40
  controller.setConfig(Object.freeze({
17
41
  ...controller.config,
18
- subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: worker?.thinkingLevel }),
42
+ subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: llm?.thinkingLevel }),
19
43
  }));
20
44
  }
21
45
 
22
46
  controller.setConfig(await showConfigPanel(ctx, controller.config));
23
47
 
24
- if (worker === null) {
25
- controller.savedWorkerRef = undefined;
26
- } else {
27
- const effectiveWorker = controller.workerModel ?? cheapestModel(ctx.modelRegistry);
28
- controller.savedWorkerRef = modelRef(controller.workerModel) ?? modelRef(effectiveWorker);
29
- }
48
+ // Only an explicit choice touches the persisted pin. ESC (`undefined`) used to fall through
49
+ // here and freeze whatever cheapest resolved to at that moment, which silently ended
50
+ // "cheapest (auto)" for every later session — including once a cheaper model appeared.
51
+ if (llm === null) controller.savedLlmRef = undefined; // "⟳ cheapest (auto)"
52
+ else if (llm !== undefined) controller.savedLlmRef = modelRef(llm.model);
53
+
30
54
  const persisted = await controller.persist();
31
55
  if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
32
56
  setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
33
57
 
34
- const w = controller.workerModel;
58
+ // Name the model that actually resolved, not "(cheapest)" — otherwise there is no way to
59
+ // tell whether the free model in the catalog was the one picked.
60
+ const pinned = controller.llmModel;
61
+ const effective = pinned ?? cheapestModel(ctx.modelRegistry);
62
+ const reasoning = controller.config.subSampling.reasoning;
35
63
  ctx.ui.notify(
36
- `RLM: worker=${w ? `${w.provider}/${w.id}` : "(cheapest)"}${controller.config.subSampling.reasoning ? `/${controller.config.subSampling.reasoning}` : ""}`,
64
+ `RLM: llm=${modelRef(effective) ?? "(none available)"}`
65
+ + `${pinned ? "" : " (cheapest, auto)"}${reasoning ? `/${reasoning}` : ""}`,
37
66
  "info",
38
67
  );
39
- return worker !== undefined;
68
+ return llm !== undefined;
40
69
  }
41
70
 
42
71
  export function registerRlmConfigCommand(pi: ExtensionAPI, controller: RlmController): void {
43
72
  pi.registerCommand("rlm-config", {
44
- description: "Configure RLM worker model and run settings.",
73
+ description: "Configure the RLM sub-LLM model and run settings.",
45
74
  handler: async (_args, ctx) => {
46
75
  await runRlmConfig(controller, ctx);
47
76
  },
@@ -1,25 +1,8 @@
1
1
  /** `/rlm` — toggle persistent Recursive Language Model mode. */
2
2
 
3
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
- import { Container, Text, type Component } from "@earendil-works/pi-tui";
5
- import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
6
- import type { RlmController, RunHandle } from "../mode/rlm-mode.ts";
7
- import { postRlmGuide } from "../ui/intro.ts";
8
- import { clearRlmStatus, setRlmModeStatus } from "../ui/status.ts";
9
- import { listRunIds, readContextSidecar, readHeader, resolveRunId } from "../state/index.ts";
10
- import { DEFAULT_RUN_DIR } from "../config/defaults.ts";
11
- import { reconstructRlmState } from "../state/resume.ts";
12
- import type { ReconstructResult } from "../state/resume.ts";
13
- import type { RunHeader } from "../state/rows.ts";
14
- import { buildRlmSystemPrompt } from "../prompts/system.ts";
15
- import { RlmEmitter } from "../tool/rlm-events.ts";
16
- import { RlmEventAggregator } from "../tool/rlm-aggregator.ts";
17
- import type { RlmDetails } from "../tool/rlm-details.ts";
18
- import { cardHeader, cardStatsLine, renderCollapsedSubcallTree } from "../tool/subcall-render.ts";
19
- import { errorMessage } from "../util/errors.ts";
20
-
21
- /** Run ids offered for `/rlm-resume <TAB>`. */
22
- const MAX_COMPLETIONS = 20;
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import type { RlmController } from "../mode/rlm-mode.ts";
5
+ import { setRlmModeStatus } from "../ui/status.ts";
23
6
 
24
7
  export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController): void {
25
8
  pi.registerCommand("rlm", {
@@ -43,69 +26,6 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
43
26
  },
44
27
  });
45
28
 
46
- pi.registerCommand("rlm-help", {
47
- description: "Show the RLM startup guide and command cheatsheet.",
48
- handler: async () => {
49
- postRlmGuide(pi, controller);
50
- },
51
- });
52
-
53
- pi.registerCommand("rlm-resume", {
54
- description: "Resume an interrupted RLM run (default @latest).",
55
- getArgumentCompletions: async (prefix) => {
56
- const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
57
- const ids = await listRunIds(process.cwd(), dir);
58
- const candidates = ["@latest", ...ids];
59
- return candidates
60
- .filter((value) => value.startsWith(prefix))
61
- .slice(0, MAX_COMPLETIONS)
62
- .map((value) => ({ value, label: value }));
63
- },
64
- handler: async (args, ctx) => {
65
- if (controller.isBusy()) {
66
- ctx.ui.notify("RLM is busy (use /rlm-stop to cancel).", "warning");
67
- return;
68
- }
69
- const ref = args.trim() || "@latest";
70
- const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
71
- const cwd = ctx.cwd ?? process.cwd();
72
- const runId = await resolveRunId(cwd, dir, ref);
73
- if (!runId) { ctx.ui.notify(`No resumable RLM run for '${ref}'.`, "error"); return; }
74
- const header = await readHeader(cwd, dir, runId);
75
- if (!header) { ctx.ui.notify(`Run ${runId} has no header.`, "error"); return; }
76
- const systemPrompt = buildRlmSystemPrompt(
77
- { contextType: header.context.type, contextChars: header.context.chars, rootPrompt: header.rootPrompt },
78
- {
79
- orchestrator: header.meta.orchestrator,
80
- recursion: 1 < header.meta.maxDepth,
81
- askUserQuestion: controller.config.askUserQuestion,
82
- todo: controller.config.todo,
83
- },
84
- );
85
- let recon: ReconstructResult;
86
- try { recon = await reconstructRlmState(cwd, dir, runId, systemPrompt); }
87
- catch (e) {
88
- ctx.ui.notify(`RLM resume failed: corrupt run state — ${errorMessage(e)}`, "error");
89
- return;
90
- }
91
- if (!recon.ok) { ctx.ui.notify(`Cannot resume ${runId}: ${recon.reason}.`, "error"); return; }
92
- if (recon.terminated) { ctx.ui.notify(`Run ${runId} already finished.`, "info"); return; }
93
- const context = await readContextSidecar(cwd, dir, runId, header.context.json);
94
- if (context === undefined) // R-C2: warn instead of silently resuming on empty context
95
- ctx.ui.notify(`Warning: context sidecar missing for ${runId} — resuming without original context.`, "warning");
96
- await executeRlmRunWithResume(pi, controller, ctx, recon, header, context ?? "");
97
- },
98
- });
99
-
100
- pi.registerCommand("rlm-runs", {
101
- description: "List recent RLM runs.",
102
- handler: async (_args, ctx) => {
103
- const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
104
- const ids = (await listRunIds(ctx.cwd ?? process.cwd(), dir)).slice(0, 20);
105
- ctx.ui.notify(ids.length ? ids.join("\n") : "No RLM runs recorded.", "info");
106
- },
107
- });
108
-
109
29
  pi.registerShortcut?.("ctrl+shift+r", {
110
30
  description: "Toggle RLM mode (off also stops a running query)",
111
31
  handler: async (ctx) => {
@@ -115,72 +35,3 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
115
35
  },
116
36
  });
117
37
  }
118
-
119
- /** Above-editor progress card for a `/rlm-resume` run: header + the live sub-call tree. */
120
- function renderResumeWidget(details: RlmDetails | undefined, theme: Theme): Component {
121
- const container = new Container();
122
- if (!details) return container;
123
- const turns = details.turns;
124
- const stats = cardStatsLine(
125
- details.totals,
126
- theme,
127
- turns.max > 0 ? `turn ${turns.current}/${turns.max}` : undefined,
128
- );
129
- container.addChild(new Text(cardHeader("RLM resume", details.status, stats, theme), 0, 0));
130
- if (details.subcalls.length > 0) {
131
- container.addChild(new Text(renderCollapsedSubcallTree(details.subcalls, theme), 0, 0));
132
- }
133
- return container;
134
- }
135
-
136
- async function executeRlmRunWithResume(
137
- pi: ExtensionAPI,
138
- controller: RlmController,
139
- ctx: ExtensionContext,
140
- recon: ReconstructResult & { ok: true },
141
- header: RunHeader,
142
- context: unknown,
143
- ): Promise<void> {
144
- let handle: RunHandle | undefined;
145
- let emitter: RlmEmitter | undefined;
146
- let aggregator: RlmEventAggregator | undefined;
147
- try {
148
- emitter = new RlmEmitter();
149
- // Component factory rather than the string[] form: the array form is hard-capped at 10
150
- // lines by pi, which the live sub-call tree exceeds as soon as a run fans out. The factory
151
- // also receives the live theme, so the widget follows /theme switches.
152
- let latest: RlmDetails | undefined;
153
- aggregator = new RlmEventAggregator(emitter, (partial) => {
154
- latest = partial.details;
155
- if (!latest) return;
156
- ctx.ui.setWidget?.("rlm-status", (_tui, theme) => renderResumeWidget(latest, theme), {
157
- placement: "aboveEditor",
158
- });
159
- });
160
- emitter.emitRootPrompt(header.rootPrompt);
161
- const interactive = createPiInteractiveDeps(ctx);
162
- if (controller.config.todo) {
163
- for (const row of recon.todoRows) await interactive.onTodo?.(row.action, row.params);
164
- }
165
- handle = controller.start(ctx, { kind: "resume", resume: recon, context }, emitter, {
166
- onAskUserQuestion: controller.config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
167
- onTodo: controller.config.todo ? interactive.onTodo : undefined,
168
- });
169
- } catch (e) {
170
- ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
171
- return;
172
- }
173
- pi.sendMessage({ customType: "rlm-question", content: `[resume] ${header.rootPrompt}`, display: true });
174
- const { done } = handle;
175
- try {
176
- const result = await done;
177
- pi.sendMessage({ customType: "rlm-answer", content: result.answer, display: true });
178
- } catch (e) {
179
- ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
180
- } finally {
181
- clearRlmStatus(ctx.ui);
182
- ctx.ui.setWidget?.("rlm-status", undefined);
183
- aggregator?.dispose();
184
- emitter?.shutdown();
185
- }
186
- }
@@ -1,8 +1,4 @@
1
1
  import type { RlmConfig } from "../core/types.ts";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
-
5
- export const DEFAULT_RUN_DIR = join(tmpdir(), "rlm-runs");
6
2
 
7
3
  /** Frozen default sub-LLM system prompt — avoids re-allocation on every llm_query call. */
8
4
  const DEFAULT_SUB_SYSTEM_PROMPT =
@@ -17,28 +13,21 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
17
13
  execTimeoutS: 120,
18
14
  requestTimeoutMs: 10 * 60_000,
19
15
  // Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
20
- // the only thing bounding fan-out. Worst case is maxDepth × this many child engines (each
21
- // owning a Python subprocess) plus this many leaf completions — keep it modest.
22
- maxConcurrentSubcalls: 6,
16
+ // the only thing bounding leaf fan-out.
17
+ maxConcurrentSubcalls: 16,
18
+ // Children are bounded separately and lower: each is a Python subprocess holding its own copy
19
+ // of the context it inherited, where a leaf is one HTTP request. Worst case is
20
+ // (maxDepth - 1) × this many concurrent child engines.
21
+ maxConcurrentChildren: 6,
23
22
  maxPromptChars: 400_000,
24
23
  maxErrors: 5,
25
24
  orchestrator: true,
26
- pipeline: false,
27
- maxBackwardJumps: 2,
28
25
  compaction: true,
29
26
  compactionThresholdPct: 0.65,
30
27
  python: "python3",
31
28
  sandboxInitTimeoutMs: 30_000,
32
- askUserQuestion: true,
33
- todo: true,
34
29
  libraryLoader: true,
35
30
  rootSampling: Object.freeze({ maxTokens: 16_384 }),
36
31
  subSystemPrompt: DEFAULT_SUB_SYSTEM_PROMPT,
37
32
  subSampling: Object.freeze({ maxTokens: 8192 }),
38
- runLog: Object.freeze({
39
- enabled: true,
40
- dir: DEFAULT_RUN_DIR,
41
- snapshot: true,
42
- maxRuns: 50,
43
- }),
44
33
  });
@@ -1,15 +1,16 @@
1
- /** Persist RLM settings (tunable config + chosen worker model id). */
1
+ /** Persist RLM settings (tunable config + pinned sub-LLM model id). */
2
2
 
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  import { getAgentDir, type ModelRegistry } from "@earendil-works/pi-coding-agent";
6
6
  import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
7
- import type { RlmConfig, RunLogConfig } from "../core/types.ts";
7
+ import type { RlmConfig } from "../core/types.ts";
8
8
  import { DEFAULT_CONFIG } from "./defaults.ts";
9
9
 
10
10
  export interface PersistedSettings {
11
11
  readonly config: Partial<RlmConfig>;
12
- readonly worker?: string;
12
+ /** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)". */
13
+ readonly llm?: string;
13
14
  }
14
15
 
15
16
  type MutablePartialRlmConfig = { -readonly [K in keyof RlmConfig]?: RlmConfig[K] };
@@ -43,21 +44,6 @@ function validateThinkingLevel(v: unknown): ThinkingLevel | undefined {
43
44
  return typeof v === "string" && Object.hasOwn(THINKING_LEVELS, v) ? (v as ThinkingLevel) : undefined;
44
45
  }
45
46
 
46
- function validateRunLog(raw: unknown): Partial<RunLogConfig> | undefined {
47
- if (typeof raw !== "object" || raw === null) return undefined;
48
- const r = raw as Record<string, unknown>;
49
- const out: { enabled?: boolean; dir?: string; snapshot?: boolean; maxRuns?: number } = {};
50
- const enabled = validateBoolean(r.enabled);
51
- if (enabled !== undefined) out.enabled = enabled;
52
- const dir = validateString(r.dir);
53
- if (dir !== undefined) out.dir = dir;
54
- const snapshot = validateBoolean(r.snapshot);
55
- if (snapshot !== undefined) out.snapshot = snapshot;
56
- const maxRuns = validateNumber(r.maxRuns, 1);
57
- if (maxRuns !== undefined) out.maxRuns = maxRuns;
58
- return Object.keys(out).length > 0 ? Object.freeze(out) : undefined;
59
- }
60
-
61
47
  function validateConfig(raw: unknown): Partial<RlmConfig> {
62
48
  if (typeof raw !== "object" || raw === null) return {};
63
49
  const r = raw as Record<string, unknown>;
@@ -74,10 +60,10 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
74
60
  if (requestTimeoutMs !== undefined) out.requestTimeoutMs = requestTimeoutMs;
75
61
  const maxConcurrentSubcalls = validateNumber(r.maxConcurrentSubcalls, 1);
76
62
  if (maxConcurrentSubcalls !== undefined) out.maxConcurrentSubcalls = maxConcurrentSubcalls;
63
+ const maxConcurrentChildren = validateNumber(r.maxConcurrentChildren, 1);
64
+ if (maxConcurrentChildren !== undefined) out.maxConcurrentChildren = maxConcurrentChildren;
77
65
  const maxPromptChars = validateNumber(r.maxPromptChars, 1000);
78
66
  if (maxPromptChars !== undefined) out.maxPromptChars = maxPromptChars;
79
- const maxBudgetUsd = validateNumber(r.maxBudgetUsd, 0.01);
80
- if (maxBudgetUsd !== undefined) out.maxBudgetUsd = maxBudgetUsd;
81
67
  const maxTimeoutMs = validateNumber(r.maxTimeoutMs, 1000);
82
68
  if (maxTimeoutMs !== undefined) out.maxTimeoutMs = maxTimeoutMs;
83
69
  const maxTokens = validateNumber(r.maxTokens, 1);
@@ -86,10 +72,6 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
86
72
  if (maxErrors !== undefined) out.maxErrors = maxErrors;
87
73
  const orchestrator = validateBoolean(r.orchestrator);
88
74
  if (orchestrator !== undefined) out.orchestrator = orchestrator;
89
- const pipeline = validateBoolean(r.pipeline);
90
- if (pipeline !== undefined) out.pipeline = pipeline;
91
- const maxBackwardJumps = validateNumber(r.maxBackwardJumps, 0);
92
- if (maxBackwardJumps !== undefined) out.maxBackwardJumps = maxBackwardJumps;
93
75
  const compaction = validateBoolean(r.compaction);
94
76
  if (compaction !== undefined) out.compaction = compaction;
95
77
  const compactionThresholdPct = validateNumber(r.compactionThresholdPct, 0);
@@ -100,14 +82,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
100
82
  if (smartReasoning !== undefined) out.smartReasoning = smartReasoning;
101
83
  const subSystemPrompt = validateString(r.subSystemPrompt);
102
84
  if (subSystemPrompt !== undefined) out.subSystemPrompt = subSystemPrompt;
103
- const runLog = validateRunLog(r.runLog);
104
- if (runLog) out.runLog = runLog;
105
85
  const sandboxInitTimeoutMs = validateNumber(r.sandboxInitTimeoutMs, 100);
106
86
  if (sandboxInitTimeoutMs !== undefined) out.sandboxInitTimeoutMs = sandboxInitTimeoutMs;
107
- const askUserQuestion = validateBoolean(r.askUserQuestion);
108
- if (askUserQuestion !== undefined) out.askUserQuestion = askUserQuestion;
109
- const todo = validateBoolean(r.todo);
110
- if (todo !== undefined) out.todo = todo;
111
87
  const libraryLoader = validateBoolean(r.libraryLoader);
112
88
  if (libraryLoader !== undefined) out.libraryLoader = libraryLoader;
113
89
  if (typeof r.subSampling === "object" && r.subSampling !== null) {
@@ -142,7 +118,8 @@ export async function loadSettings(): Promise<PersistedSettings> {
142
118
  const r = raw as Record<string, unknown>;
143
119
  return {
144
120
  config: validateConfig(r.config),
145
- worker: typeof r.worker === "string" ? r.worker : undefined,
121
+ // `worker` is the pre-rename key still read so an existing pin survives the upgrade.
122
+ llm: validateString(r.llm) ?? validateString(r.worker),
146
123
  };
147
124
  } catch {
148
125
  return { config: {} };
@@ -167,7 +144,6 @@ export function mergeConfig(partial: Partial<RlmConfig>): RlmConfig {
167
144
  ...partial,
168
145
  subSampling: { ...DEFAULT_CONFIG.subSampling, ...partial.subSampling },
169
146
  rootSampling: Object.freeze({ ...DEFAULT_CONFIG.rootSampling, ...partial.rootSampling }),
170
- ...(partial.runLog ? { runLog: Object.freeze({ ...DEFAULT_CONFIG.runLog, ...partial.runLog }) } : {}),
171
147
  };
172
148
  }
173
149