@hicaru/pi-rlm 0.1.9 → 0.2.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
6
6
  "license": "MIT",
@@ -5,88 +5,112 @@
5
5
  *
6
6
  * Caps enforce the divide-and-conquer budget from the RLM method: per-prompt size and batch
7
7
  * fan-out are bounded, and batches run through a fixed-size concurrency pool.
8
+ *
9
+ * Every input that can change between calls (worker model, emitter, parent node, depth,
10
+ * remaining budget) is an accessor, so a single bridge instance serves both the headless
11
+ * engine — which binds them once per run — and the native `repl` tool, which swaps them per
12
+ * invocation without recreating the sandbox.
8
13
  */
9
14
 
10
15
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
11
16
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
17
  import type { RlmEmitter } from "../tool/rlm-events.ts";
13
- import { modelRef, resolveModelId } from "../config/settings.ts";
18
+ import { displayModelRef, resolveModelId } from "../config/settings.ts";
14
19
  import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
15
20
  import type { Sampling } from "../core/types.ts";
16
21
  import { type ChatMsg, modelComplete } from "./model.ts";
17
22
  import { previewText } from "../text/preview.ts";
18
- import { formatError, isErrorText } from "../util/errors.ts";
23
+ import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
19
24
  import { mapPool } from "../util/concurrency.ts";
20
25
 
26
+ /**
27
+ * The config slice this bridge reads. Structurally satisfied by `RlmConfig`, and re-read on
28
+ * every call so `/rlm-config` changes take effect without rebuilding the sandbox.
29
+ */
30
+ export interface LlmBridgeConfig {
31
+ readonly maxPromptChars: number;
32
+ readonly maxConcurrentSubcalls: number;
33
+ readonly subSystemPrompt?: string;
34
+ readonly subSampling?: Sampling;
35
+ }
36
+
21
37
  export interface LlmBridgeOptions {
22
- readonly workerModel: Model<Api>;
38
+ /** Resolved per call so provider/config changes between calls are picked up. */
39
+ readonly workerModel: () => Model<Api>;
23
40
  readonly registry: ModelRegistry;
24
- readonly subSystem?: string;
25
- readonly maxPromptChars?: number;
26
- readonly maxConcurrent?: number;
27
- readonly sampling?: Sampling;
41
+ readonly config: () => LlmBridgeConfig;
28
42
  readonly signal?: AbortSignal;
29
43
  readonly onUsage?: (usage: Usage, model: Model<Api>) => void;
30
44
  /** Parent run's remaining budget/timeout; checked before every sub-call. */
31
- readonly remainingBudget?: () => RemainingResources;
32
- /** Live RlmDetails reporting via onUpdate. */
33
- readonly emitter?: RlmEmitter;
34
- readonly parentId?: string;
35
- readonly depth?: number;
45
+ readonly remainingBudget?: () => RemainingResources | undefined;
46
+ /** Live RlmDetails reporting target, resolved per call. */
47
+ readonly emitter?: () => RlmEmitter | undefined;
48
+ readonly parentId?: () => string | undefined;
49
+ readonly depth?: () => number;
36
50
  }
37
51
 
38
- const DEFAULT_MAX_PROMPT_CHARS = 400_000;
39
- const DEFAULT_MAX_CONCURRENT = 4;
52
+ /** Provider failures that a smaller batch or a cheaper model might get past. */
53
+ const RETRYABLE_HINT = /credit|402|payment|quota|rate.limit/i;
40
54
 
41
55
  export interface LlmBridge {
42
56
  llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
43
- llmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
57
+ llmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
58
+ }
59
+
60
+ /** Batch outcome summary, or undefined when every prompt succeeded. */
61
+ function batchError(failed: number, total: number): string | undefined {
62
+ if (failed === 0) return undefined;
63
+ return failed === total
64
+ ? `all ${total} sub-calls failed — reduce batch size or try llm_query individually`
65
+ : `${failed}/${total} sub-calls failed`;
44
66
  }
45
67
 
46
68
  export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
47
- const maxPromptChars = opts.maxPromptChars ?? DEFAULT_MAX_PROMPT_CHARS;
48
- const maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
49
- const { emitter } = opts;
50
69
  const displayModel = (model: string | null): string =>
51
- modelRef(model ? (resolveModelId(opts.registry, model) ?? opts.workerModel) : opts.workerModel) ?? opts.workerModel.id;
70
+ displayModelRef(opts.registry, model, opts.workerModel());
52
71
 
53
72
  // Run one completion; report cost/tokens via `track` (a per-call or per-batch accumulator).
54
73
  async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
74
+ const config = opts.config();
55
75
  const rem = opts.remainingBudget?.();
56
76
  if (rem !== undefined) {
57
77
  const limitError = checkResourceLimits(rem);
58
78
  if (limitError !== undefined) return limitError;
59
79
  }
60
- if (prompt.length > maxPromptChars) {
61
- return formatError(`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ${maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`);
80
+ if (prompt.length > config.maxPromptChars) {
81
+ return formatError(`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ${config.maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`);
62
82
  }
63
83
  const resolved = model ? resolveModelId(opts.registry, model) : undefined;
64
84
  if (model && !resolved) return formatError(`unknown model override '${model}'`);
85
+ const target = resolved ?? opts.workerModel();
65
86
  try {
66
87
  const messages: ChatMsg[] = [{ role: "user", content: prompt }];
67
88
  const res = await modelComplete(messages, {
68
- model: resolved ?? opts.workerModel,
89
+ model: target,
69
90
  registry: opts.registry,
70
- system: opts.subSystem,
71
- maxTokens: opts.sampling?.maxTokens,
72
- temperature: opts.sampling?.temperature,
73
- reasoning: opts.sampling?.reasoning,
91
+ system: config.subSystemPrompt,
92
+ maxTokens: config.subSampling?.maxTokens,
93
+ temperature: config.subSampling?.temperature,
94
+ reasoning: config.subSampling?.reasoning,
74
95
  signal: opts.signal,
75
96
  });
76
- opts.onUsage?.(res.usage, resolved ?? opts.workerModel);
97
+ opts.onUsage?.(res.usage, target);
77
98
  track(res.usage);
78
99
  return res.text;
79
100
  } catch (err) {
80
- return formatError(err instanceof Error ? err.message : String(err));
101
+ const msg = errorMessage(err);
102
+ const hint = RETRYABLE_HINT.test(msg) ? " — try smaller batches or individual llm_query calls" : "";
103
+ return formatError(`${msg}${hint}`);
81
104
  }
82
105
  }
83
106
 
84
107
  return {
85
108
  async llmQuery(prompt, model) {
109
+ const emitter = opts.emitter?.();
86
110
  const id = emitter?.emitSubcallCreated({
87
- kind: "llm", parentId: opts.parentId, label: "llm_query",
111
+ kind: "llm", parentId: opts.parentId?.(), label: "llm_query",
88
112
  model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
89
- depth: opts.depth ?? 0,
113
+ depth: opts.depth?.() ?? 0,
90
114
  });
91
115
  let cost = 0;
92
116
  let tokens = 0;
@@ -103,23 +127,22 @@ export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
103
127
  },
104
128
 
105
129
  async llmQueryBatched(prompts, model) {
130
+ const emitter = opts.emitter?.();
106
131
  const id = emitter?.emitSubcallCreated({
107
- kind: "batch", parentId: opts.parentId, label: `llm_query ×${prompts.length}`,
132
+ kind: "batch", parentId: opts.parentId?.(), label: `llm_query ×${prompts.length}`,
108
133
  model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
109
- depth: opts.depth ?? 0,
134
+ depth: opts.depth?.() ?? 0,
110
135
  });
111
136
  let cost = 0;
112
137
  let tokens = 0;
113
- const out = await mapPool(prompts, maxConcurrent, (p) =>
138
+ const out = await mapPool(prompts, opts.config().maxConcurrentSubcalls, (p) =>
114
139
  complete1(p, model, (u) => {
115
140
  cost += u.cost.total;
116
141
  tokens += u.totalTokens;
117
142
  }),
118
143
  );
119
144
  const failed = out.filter(isErrorText).length;
120
- const error = failed === out.length && out.length > 0
121
- ? `all ${out.length} sub-calls failed`
122
- : failed > 0 ? `${failed}/${out.length} sub-calls failed` : undefined;
145
+ const error = batchError(failed, out.length);
123
146
  const firstPreview = previewText(out[0] ?? "");
124
147
  const resultPreview = out.length > 1 ? `${firstPreview} (+${out.length - 1} more)` : firstPreview;
125
148
  if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
@@ -5,118 +5,104 @@
5
5
  * depth cap it degrades to a plain `llm_query` (ported from rlm/core/rlm.py `_subcall`). The
6
6
  * concurrency pool bounds parallel children for `rlm_query_batched`.
7
7
  *
8
- * `childRun` is the single spawn path shared by `rlmQuery` / `rlmQueryBatched`.
8
+ * As with the llm bridge, everything that can change between calls is an accessor, so the
9
+ * headless engine (which binds them once per run) and the native `repl` tool (which swaps them
10
+ * per invocation) share one implementation.
9
11
  */
10
12
 
11
13
  import type { RlmResult, RunRlm } from "../core/types.ts";
12
14
  import type { LlmBridge } from "./llm-query.ts";
13
15
  import type { RlmEmitter } from "../tool/rlm-events.ts";
14
- import { checkResourceLimits } from "../core/resource-limits.ts";
15
- import { formatError } from "../util/errors.ts";
16
+ import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
17
+ import { errorMessage, formatError } from "../util/errors.ts";
16
18
  import { mapPool } from "../util/concurrency.ts";
19
+ import { previewText } from "../text/preview.ts";
17
20
 
18
- export interface ChildRunInput {
19
- readonly rootPrompt: string;
20
- readonly context: unknown;
21
- readonly depth: number;
22
- readonly label?: string;
23
- readonly model?: string | null;
21
+ /** The config slice this bridge reads; structurally satisfied by `RlmConfig`. */
22
+ export interface RlmBridgeConfig {
23
+ readonly maxDepth: number;
24
+ readonly maxConcurrentSubcalls: number;
24
25
  }
25
26
 
26
27
  export interface RlmHandlers {
27
28
  rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
28
- rlmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
29
- /** Full child-run result (answer + usage) for recursive spawns. */
30
- childRun(input: ChildRunInput): Promise<RlmResult>;
29
+ rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
31
30
  }
32
31
 
33
32
  export interface RlmBridgeOptions {
33
+ /**
34
+ * Spawns one child run. The engine passes its own `run` (self-recursion); the native tool
35
+ * passes a closure that builds a child engine bound to the current invocation's emitter.
36
+ */
34
37
  readonly run: RunRlm;
35
38
  readonly llm: LlmBridge;
36
- /** Live RlmDetails reporting via onUpdate. Required — replaces SubcallObserver for recursive subcalls. */
37
- readonly emitter: RlmEmitter;
38
- readonly maxDepth: number;
39
- readonly maxConcurrent: number;
40
- /** Parent subcall ID that this run is attached under. */
41
- readonly parentNodeId?: string;
39
+ readonly config: () => RlmBridgeConfig;
40
+ /** "provider/id" shown on the sub-call node; see `displayModelRef` in config/settings.ts. */
41
+ readonly modelLabel?: (override: string | null) => string;
42
+ /** Live RlmDetails reporting target, resolved per call. */
43
+ readonly emitter: () => RlmEmitter | undefined;
44
+ /** Parent subcall ID that this run is attached under, resolved per call. */
45
+ readonly parentNodeId?: () => string | undefined;
42
46
  /** Returns the parent's remaining budget/timeout for seeding child runs. */
43
- readonly remainingBudget?: () => { readonly budgetUsd?: number; readonly timeoutMs?: number };
47
+ readonly remainingBudget?: () => RemainingResources | undefined;
44
48
  /** Called with a child run's total cost/tokens so the parent LimitGuard debits it. */
45
49
  readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
46
50
  }
47
51
 
48
- function emptyResult(answer: string): RlmResult {
49
- return {
50
- answer,
51
- iterations: 0,
52
- costUsd: 0,
53
- inputTokens: 0,
54
- outputTokens: 0,
55
- durationMs: 0,
56
- };
57
- }
58
-
59
52
  export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
60
- async function childRun(input: ChildRunInput): Promise<RlmResult> {
61
- const childDepth = input.depth;
53
+ /**
54
+ * One child spawn. `childDepth` is the absolute depth the child will run at.
55
+ * Never throws: failures come back as "Error: ..." strings, matching the sandbox contract.
56
+ */
57
+ async function child(prompt: string, model: string | null, childDepth: number): Promise<string> {
62
58
  // At the cap, a child RLM would just be an LM — short-circuit to a one-shot llm_query.
63
- // (Callers pass the absolute child depth; rlmQuery wraps with depth+1.)
64
- if (childDepth >= opts.maxDepth) {
65
- const answer = await opts.llm.llmQuery(
66
- input.rootPrompt || String(input.context),
67
- input.model ?? null,
68
- childDepth - 1,
69
- );
70
- return emptyResult(answer);
59
+ if (childDepth >= opts.config().maxDepth) {
60
+ return opts.llm.llmQuery(prompt, model, childDepth - 1);
71
61
  }
72
- let subId: string | undefined;
73
- try {
74
- const rem = opts.remainingBudget?.() ?? {};
62
+
63
+ const rem = opts.remainingBudget?.();
64
+ if (rem !== undefined) {
75
65
  // Pre-spawn guard: refuse if the parent's budget or timeout is already exhausted
76
66
  // (reference: _subcall checks remaining_budget/timeout before spawning).
77
67
  const limitError = checkResourceLimits(rem);
78
- if (limitError) return emptyResult(limitError);
79
- const label = input.label ?? "rlm_query";
80
- const detailSource = input.rootPrompt || String(input.context);
81
- subId = opts.emitter.emitSubcallCreated({
82
- kind: "rlm", parentId: opts.parentNodeId, label,
83
- model: input.model ?? undefined, detail: detailSource.slice(0, 60),
84
- depth: childDepth,
85
- });
86
- const res = await opts.run({
87
- rootPrompt: input.rootPrompt,
88
- context: input.context,
68
+ if (limitError !== undefined) return limitError;
69
+ }
70
+
71
+ const emitter = opts.emitter();
72
+ const subId = emitter?.emitSubcallCreated({
73
+ kind: "rlm", parentId: opts.parentNodeId?.(), label: "rlm_query",
74
+ model: opts.modelLabel?.(model) ?? model ?? undefined,
75
+ detail: prompt.slice(0, 60),
76
+ depth: childDepth,
77
+ });
78
+
79
+ try {
80
+ const res: RlmResult = await opts.run({
81
+ rootPrompt: "",
82
+ context: prompt,
89
83
  depth: childDepth,
90
84
  parentNodeId: subId,
91
- modelOverride: input.model ?? undefined,
92
- remainingBudgetUsd: rem.budgetUsd,
93
- remainingTimeoutMs: rem.timeoutMs,
85
+ modelOverride: model ?? undefined,
86
+ remainingBudgetUsd: rem?.budgetUsd,
87
+ remainingTimeoutMs: rem?.timeoutMs,
94
88
  });
95
89
  opts.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
96
- opts.emitter.emitSubcallUpdated({ id: subId,
97
- status: "done", resultPreview: res.answer.slice(0, 200),
98
- });
99
- return res;
90
+ // The child emits live cost/token deltas on the shared emitter as it runs, so the node
91
+ // must NOT also receive a final aggregate — that would double-count.
92
+ if (emitter && subId !== undefined) {
93
+ emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: previewText(res.answer) });
94
+ }
95
+ return res.answer;
100
96
  } catch (err) {
101
- const msg = err instanceof Error ? err.message : String(err);
102
- if (subId) opts.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
103
- return emptyResult(formatError(`child RLM failed - ${msg}`));
97
+ const msg = errorMessage(err);
98
+ if (emitter && subId !== undefined) emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
99
+ return formatError(`child RLM failed - ${msg}`);
104
100
  }
105
101
  }
106
102
 
107
- async function child(prompt: string, model: string | null, depth: number): Promise<string> {
108
- const res = await childRun({
109
- rootPrompt: "",
110
- context: prompt,
111
- depth: depth + 1,
112
- model,
113
- });
114
- return res.answer;
115
- }
116
-
117
103
  return {
118
- rlmQuery: (prompt, model, depth) => child(prompt, model, depth),
119
- rlmQueryBatched: (prompts, model, depth) => mapPool(prompts, opts.maxConcurrent, (p) => child(p, model, depth)),
120
- childRun,
104
+ rlmQuery: (prompt, model, depth) => child(prompt, model, depth + 1),
105
+ rlmQueryBatched: (prompts, model, depth) =>
106
+ mapPool(prompts, opts.config().maxConcurrentSubcalls, (p) => child(p, model, depth + 1)),
121
107
  };
122
108
  }
@@ -11,15 +11,15 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
11
11
  const models = ctx.modelRegistry.getAvailable();
12
12
 
13
13
  const worker = await selectModel(ctx, "Worker model (sub-LLM / llm_query)", models, controller.workerModel, controller.config.subSampling.reasoning);
14
- if (worker === null) {
15
- controller.workerModel = undefined;
16
- controller.config.subSampling.reasoning = undefined;
17
- } else if (worker) {
18
- controller.workerModel = worker.model;
19
- controller.config.subSampling.reasoning = worker.thinkingLevel;
14
+ if (worker !== undefined) {
15
+ controller.workerModel = worker?.model;
16
+ controller.setConfig(Object.freeze({
17
+ ...controller.config,
18
+ subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: worker?.thinkingLevel }),
19
+ }));
20
20
  }
21
21
 
22
- await showConfigPanel(ctx, controller.config);
22
+ controller.setConfig(await showConfigPanel(ctx, controller.config));
23
23
 
24
24
  if (worker === null) {
25
25
  controller.savedWorkerRef = undefined;
@@ -29,7 +29,7 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
29
29
  }
30
30
  const persisted = await controller.persist();
31
31
  if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
32
- setRlmModeStatus(ctx.ui, controller);
32
+ setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
33
33
 
34
34
  const w = controller.workerModel;
35
35
  ctx.ui.notify(
@@ -1,6 +1,7 @@
1
1
  /** `/rlm` — toggle persistent Recursive Language Model mode. */
2
2
 
3
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
+ import { Container, Text, type Component } from "@earendil-works/pi-tui";
4
5
  import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
5
6
  import type { RlmController, RunHandle } from "../mode/rlm-mode.ts";
6
7
  import { postRlmGuide } from "../ui/intro.ts";
@@ -13,13 +14,19 @@ import type { RunHeader } from "../state/rows.ts";
13
14
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
14
15
  import { RlmEmitter } from "../tool/rlm-events.ts";
15
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;
16
23
 
17
24
  export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController): void {
18
25
  pi.registerCommand("rlm", {
19
26
  description: "Toggle persistent RLM mode (route plain prompts through the RLM engine).",
20
27
  handler: async (_args, ctx) => {
21
28
  const enabled = controller.toggle();
22
- setRlmModeStatus(ctx.ui, controller);
29
+ setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
23
30
  ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
24
31
  },
25
32
  });
@@ -45,6 +52,15 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
45
52
 
46
53
  pi.registerCommand("rlm-resume", {
47
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
+ },
48
64
  handler: async (args, ctx) => {
49
65
  if (controller.isBusy()) {
50
66
  ctx.ui.notify("RLM is busy (use /rlm-stop to cancel).", "warning");
@@ -69,7 +85,7 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
69
85
  let recon: ReconstructResult;
70
86
  try { recon = await reconstructRlmState(cwd, dir, runId, systemPrompt); }
71
87
  catch (e) {
72
- ctx.ui.notify(`RLM resume failed: corrupt run state — ${e instanceof Error ? e.message : String(e)}`, "error");
88
+ ctx.ui.notify(`RLM resume failed: corrupt run state — ${errorMessage(e)}`, "error");
73
89
  return;
74
90
  }
75
91
  if (!recon.ok) { ctx.ui.notify(`Cannot resume ${runId}: ${recon.reason}.`, "error"); return; }
@@ -94,12 +110,29 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
94
110
  description: "Toggle RLM mode (off also stops a running query)",
95
111
  handler: async (ctx) => {
96
112
  const enabled = controller.toggle();
97
- setRlmModeStatus(ctx.ui, controller);
113
+ setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
98
114
  ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
99
115
  },
100
116
  });
101
117
  }
102
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
+
103
136
  async function executeRlmRunWithResume(
104
137
  pi: ExtensionAPI,
105
138
  controller: RlmController,
@@ -113,13 +146,16 @@ async function executeRlmRunWithResume(
113
146
  let aggregator: RlmEventAggregator | undefined;
114
147
  try {
115
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;
116
153
  aggregator = new RlmEventAggregator(emitter, (partial) => {
117
- const d = partial.details;
118
- if (!d) return;
119
- const turn = d.turns.max > 0 ? ` · turn ${d.turns.current}/${d.turns.max}` : "";
120
- const cost = d.totals.costUsd > 0 ? ` · $${d.totals.costUsd.toFixed(4)}` : "";
121
- const glyph = d.status === "running" ? "⏳" : d.status === "done" ? "✓" : "✗";
122
- ctx.ui.setWidget?.("rlm-status", [`${glyph} RLM resume${turn}${cost}`], { placement: "aboveEditor" });
154
+ latest = partial.details;
155
+ if (!latest) return;
156
+ ctx.ui.setWidget?.("rlm-status", (_tui, theme) => renderResumeWidget(latest, theme), {
157
+ placement: "aboveEditor",
158
+ });
123
159
  });
124
160
  emitter.emitRootPrompt(header.rootPrompt);
125
161
  const interactive = createPiInteractiveDeps(ctx);
@@ -131,7 +167,7 @@ async function executeRlmRunWithResume(
131
167
  onTodo: controller.config.todo ? interactive.onTodo : undefined,
132
168
  });
133
169
  } catch (e) {
134
- ctx.ui.notify(`RLM resume failed: ${e instanceof Error ? e.message : String(e)}`, "error");
170
+ ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
135
171
  return;
136
172
  }
137
173
  pi.sendMessage({ customType: "rlm-question", content: `[resume] ${header.rootPrompt}`, display: true });
@@ -140,7 +176,7 @@ async function executeRlmRunWithResume(
140
176
  const result = await done;
141
177
  pi.sendMessage({ customType: "rlm-answer", content: result.answer, display: true });
142
178
  } catch (e) {
143
- ctx.ui.notify(`RLM resume failed: ${e instanceof Error ? e.message : String(e)}`, "error");
179
+ ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
144
180
  } finally {
145
181
  clearRlmStatus(ctx.ui);
146
182
  ctx.ui.setWidget?.("rlm-status", undefined);
@@ -30,6 +30,19 @@ function validateString(v: unknown): string | undefined {
30
30
  return typeof v === "string" && v.trim() ? v : undefined;
31
31
  }
32
32
 
33
+ /**
34
+ * Every value pi-ai accepts for `reasoning`. Keyed by the union so a new level added upstream
35
+ * is a compile error here rather than a silently-rejected setting. Note `off` and `max` are
36
+ * NOT ThinkingLevels — a hand-edited rlm.json carrying one is dropped, not forwarded.
37
+ */
38
+ const THINKING_LEVELS: Readonly<Record<ThinkingLevel, true>> = Object.freeze({
39
+ minimal: true, low: true, medium: true, high: true, xhigh: true,
40
+ });
41
+
42
+ function validateThinkingLevel(v: unknown): ThinkingLevel | undefined {
43
+ return typeof v === "string" && Object.hasOwn(THINKING_LEVELS, v) ? (v as ThinkingLevel) : undefined;
44
+ }
45
+
33
46
  function validateRunLog(raw: unknown): Partial<RunLogConfig> | undefined {
34
47
  if (typeof raw !== "object" || raw === null) return undefined;
35
48
  const r = raw as Record<string, unknown>;
@@ -83,7 +96,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
83
96
  if (compactionThresholdPct !== undefined && compactionThresholdPct <= 1) out.compactionThresholdPct = compactionThresholdPct;
84
97
  const python = validateString(r.python);
85
98
  if (python !== undefined) out.python = python;
86
- if (typeof r.smartReasoning === "string") out.smartReasoning = r.smartReasoning as ThinkingLevel;
99
+ const smartReasoning = validateThinkingLevel(r.smartReasoning);
100
+ if (smartReasoning !== undefined) out.smartReasoning = smartReasoning;
87
101
  const subSystemPrompt = validateString(r.subSystemPrompt);
88
102
  if (subSystemPrompt !== undefined) out.subSystemPrompt = subSystemPrompt;
89
103
  const runLog = validateRunLog(r.runLog);
@@ -103,7 +117,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
103
117
  if (maxTokensValue !== undefined) sampling.maxTokens = maxTokensValue;
104
118
  const temperature = validateNumber(ss.temperature, 0);
105
119
  if (temperature !== undefined) sampling.temperature = temperature;
106
- if (typeof ss.reasoning === "string") sampling.reasoning = ss.reasoning as ThinkingLevel;
120
+ const ssReasoning = validateThinkingLevel(ss.reasoning);
121
+ if (ssReasoning !== undefined) sampling.reasoning = ssReasoning;
107
122
  out.subSampling = sampling;
108
123
  }
109
124
  if (typeof r.rootSampling === "object" && r.rootSampling !== null) {
@@ -113,7 +128,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
113
128
  if (rsMaxTokens !== undefined) rootSampling.maxTokens = rsMaxTokens;
114
129
  const rsTemperature = validateNumber(rs.temperature, 0);
115
130
  if (rsTemperature !== undefined) rootSampling.temperature = rsTemperature;
116
- if (typeof rs.reasoning === "string") rootSampling.reasoning = rs.reasoning as ThinkingLevel;
131
+ const rsReasoning = validateThinkingLevel(rs.reasoning);
132
+ if (rsReasoning !== undefined) rootSampling.reasoning = rsReasoning;
117
133
  out.rootSampling = Object.freeze(rootSampling);
118
134
  }
119
135
  return out;
@@ -166,3 +182,17 @@ export function resolveModelId(registry: ModelRegistry, ref?: string): Model<Api
166
182
  export function modelRef(model: Model<Api> | undefined): string | undefined {
167
183
  return model ? `${model.provider}/${model.id}` : undefined;
168
184
  }
185
+
186
+ /**
187
+ * Human-readable "provider/id" for a sub-call node: the resolved override when one was
188
+ * supplied and resolves, otherwise the fallback model. Shared by the llm and rlm bridges
189
+ * so sub-call trees label their nodes identically.
190
+ */
191
+ export function displayModelRef(
192
+ registry: ModelRegistry,
193
+ override: string | null,
194
+ fallback: Model<Api>,
195
+ ): string {
196
+ const resolved = override ? (resolveModelId(registry, override) ?? fallback) : fallback;
197
+ return modelRef(resolved) ?? fallback.id;
198
+ }
@@ -10,9 +10,12 @@
10
10
 
11
11
  import { pack } from "repomix";
12
12
  import type { PackResult as RepomixPackResult } from "repomix";
13
+ /** repomix's own config parameter type — `satisfies` keeps the literal checked against it. */
14
+ type PackConfig = NonNullable<Parameters<typeof pack>[1]>;
13
15
  import { resolve } from "node:path";
14
16
  import { tmpdir } from "node:os";
15
17
  import { errorMessage } from "../util/errors.ts";
18
+ import { estimateTokens } from "../text/tokens.ts";
16
19
 
17
20
  // ── Public types ──
18
21
 
@@ -51,11 +54,6 @@ interface CacheEntry {
51
54
  const cache = new Map<string, CacheEntry>();
52
55
  const DEFAULT_CACHE_TTL_MS = 30_000;
53
56
 
54
- /** Exported for tests — empties the module-level cache. */
55
- export function clearCache(): void {
56
- cache.clear();
57
- }
58
-
59
57
  function cacheKey(cwd: string): string {
60
58
  return resolve(cwd);
61
59
  }
@@ -76,8 +74,6 @@ function cacheSet(key: string, bundle: ContextBundle): void {
76
74
 
77
75
  // ── Core functions ──
78
76
 
79
- const ESTIMATED_CHARS_PER_TOKEN = 4;
80
-
81
77
  export async function packRepository(
82
78
  cwd: string,
83
79
  signal?: AbortSignal,
@@ -133,7 +129,7 @@ export async function packRepository(
133
129
  },
134
130
  security: { enableSecurityCheck: false },
135
131
  tokenCount: { encoding: "o200k_base" as const },
136
- } as Parameters<typeof pack>[1]),
132
+ } satisfies PackConfig),
137
133
  new Promise<never>((_, reject) => {
138
134
  signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
139
135
  }),
@@ -147,8 +143,7 @@ export async function packRepository(
147
143
 
148
144
  for (let i = 0; i < processedFiles.length; i++) {
149
145
  const file = processedFiles[i];
150
- const tokens = tokenCounts[file.path]
151
- ?? Math.ceil(file.content.length / ESTIMATED_CHARS_PER_TOKEN);
146
+ const tokens = tokenCounts[file.path] ?? estimateTokens(file.content.length);
152
147
  files[i] = { path: file.path, content: file.content, tokens };
153
148
  totalTokens += tokens;
154
149
  totalChars += file.content.length;