@hicaru/pi-rlm 0.1.9 → 0.2.1

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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/bridge/fallback-todo.ts +12 -1
  3. package/src/bridge/subcall-handlers.ts +336 -0
  4. package/src/commands/rlm-config.ts +8 -8
  5. package/src/commands/rlm.ts +48 -12
  6. package/src/config/defaults.ts +4 -1
  7. package/src/config/settings.ts +33 -3
  8. package/src/context/repomix-context.ts +5 -10
  9. package/src/core/answer.ts +4 -3
  10. package/src/core/artifacts.ts +4 -3
  11. package/src/core/engine.ts +101 -267
  12. package/src/core/gates.ts +3 -3
  13. package/src/core/limits.ts +19 -1
  14. package/src/core/pipeline-handlers.ts +319 -0
  15. package/src/core/pipeline.ts +2 -2
  16. package/src/core/types.ts +25 -27
  17. package/src/index.ts +63 -17
  18. package/src/mode/rlm-mode.ts +8 -11
  19. package/src/prompts/system.ts +164 -52
  20. package/src/prompts/user.ts +1 -5
  21. package/src/sandbox/protocol.ts +6 -7
  22. package/src/sandbox/sandbox-manager.ts +25 -11
  23. package/src/sandbox/sandbox.ts +93 -22
  24. package/src/sandbox/worker.py +798 -66
  25. package/src/state/paths.ts +1 -1
  26. package/src/state/reads.ts +12 -4
  27. package/src/state/resume.ts +5 -11
  28. package/src/text/parsing.ts +0 -6
  29. package/src/tool/background-tasks.ts +95 -0
  30. package/src/tool/repl-details.ts +2 -0
  31. package/src/tool/repl-tool.ts +223 -318
  32. package/src/tool/rlm-details.ts +0 -10
  33. package/src/tool/rlm-events.ts +10 -2
  34. package/src/tool/rlm-tool.ts +18 -31
  35. package/src/tool/subcall-render.ts +75 -11
  36. package/src/tool/subcall-store.ts +57 -1
  37. package/src/ui/config-panel.ts +41 -21
  38. package/src/ui/intro.ts +2 -1
  39. package/src/ui/status.ts +8 -5
  40. package/src/ui/theme-adapter.ts +36 -0
  41. package/src/ui/theme.ts +0 -25
  42. package/src/util/concurrency.ts +87 -13
  43. package/src/util/trace.ts +42 -0
  44. package/src/bridge/llm-query.ts +0 -133
  45. package/src/bridge/rlm-query.ts +0 -122
  46. package/src/mode/input-router.ts +0 -23
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.1",
4
4
  "type": "module",
5
5
  "description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
6
6
  "license": "MIT",
@@ -90,12 +90,22 @@ function withPatch(task: Task, params: TodoParams): Task {
90
90
  });
91
91
  }
92
92
 
93
+ /** The actions `worker.py::_todo` documents. Anything else is a caller error, not a missing task. */
94
+ const TODO_ACTIONS: ReadonlySet<string> = Object.freeze(new Set([
95
+ "create", "update", "list", "get", "delete", "clear",
96
+ ]));
97
+
93
98
  export function createTodoFallback(): (action: string, params: Record<string, unknown>) => Promise<string> {
94
99
  let nextId = 1;
95
100
  let tasks: readonly Task[] = Object.freeze([]);
96
101
  const fmt = (task: Task): string => taskLines(task)[0] ?? `#${task.id}`;
97
102
 
98
103
  const apply = (action: string, rawParams: Record<string, unknown>): string => {
104
+ // Validated BEFORE the id lookup below: an unknown action used to fall through to it and
105
+ // report "task #? not found", which reads as a missing task rather than a bad action.
106
+ if (!TODO_ACTIONS.has(action)) {
107
+ return formatError(`unknown todo action '${action}' — expected ${[...TODO_ACTIONS].join(", ")}`);
108
+ }
99
109
  const params = toTodoParams(rawParams);
100
110
  if (action === "clear") {
101
111
  const count = tasks.length;
@@ -131,7 +141,8 @@ export function createTodoFallback(): (action: string, params: Record<string, un
131
141
  tasks = Object.freeze(tasks.map((item) => item.id === task.id ? updated : item));
132
142
  return `Updated ${fmt(updated)}`;
133
143
  }
134
- return formatError(`unknown todo action '${action}'`);
144
+ // Unreachable: every TODO_ACTIONS member is handled above. Kept as the exhaustiveness arm.
145
+ return formatError(`unhandled todo action '${action}'`);
135
146
  };
136
147
  return async (action, params) => apply(action, params);
137
148
  }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The single implementation of the sub-LLM handler set (llm_query, llm_query_batched,
3
+ * rlm_query, rlm_query_batched).
4
+ *
5
+ * Resolves AGENTS.md DRY #1–#5, which previously lived twice: once in `createLlmBridge` /
6
+ * `createRlmHandlers` for the headless engine, once in `NativeBridgeState` for the repl()
7
+ * tool. The two callers only ever differed in how they answer one question — "which emitter,
8
+ * limits and parent node does THIS interrupt belong to?" — so that is the only thing they
9
+ * still supply, as `resolve`. The engine binds one Invocation for a whole run; the repl()
10
+ * tool swaps one per turn and routes `spawn()`ed work to its session registry.
11
+ *
12
+ * Concurrency: every leaf completion passes through `gates.leaf`, every child engine through
13
+ * `gates.rlm.at(depth)`, so a sandbox that puts 25 batches on the wire at once is still
14
+ * bounded session-wide. See util/concurrency.ts for why the rlm gate is per-depth.
15
+ */
16
+
17
+ import type { Api, Model, Usage } from "@earendil-works/pi-ai";
18
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
19
+ import { displayModelRef, modelRef, resolveModelId } from "../config/settings.ts";
20
+ import { type ChatMsg, modelComplete } from "./model.ts";
21
+ import { previewText } from "../text/preview.ts";
22
+ import { checkResourceLimits } from "../core/resource-limits.ts";
23
+ import type { RlmInput, RlmResult, Sampling } from "../core/types.ts";
24
+ import type { SubcallGates } from "../util/concurrency.ts";
25
+ import type { SubcallOpts, SubLlmHandlers } from "../sandbox/sandbox.ts";
26
+ import type { RlmEmitter } from "../tool/rlm-events.ts";
27
+ import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
28
+
29
+ /**
30
+ * 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
+ * shapes satisfy this.
33
+ */
34
+ export interface InvocationLimits {
35
+ remainingBudgetUsd(): number | undefined;
36
+ remainingTimeoutMs(): number | undefined;
37
+ addUsage(usage: Usage): void;
38
+ addRaw(costUsd: number, inputTokens: number, outputTokens: number): void;
39
+ }
40
+
41
+ /**
42
+ * Adapt a "how much is left?" callback to InvocationLimits.
43
+ *
44
+ * The headless engine owns the real LimitGuard and folds usage in through `onUsage` /
45
+ * `onChildUsage`, so the accounting methods here are deliberately inert.
46
+ */
47
+ export function limitsFromRemaining(
48
+ remaining?: () => { readonly budgetUsd?: number; readonly timeoutMs?: number },
49
+ ): InvocationLimits {
50
+ return {
51
+ remainingBudgetUsd: () => remaining?.().budgetUsd,
52
+ remainingTimeoutMs: () => remaining?.().timeoutMs,
53
+ addUsage: () => {},
54
+ addRaw: () => {},
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Where one interrupt's reporting and accounting go.
60
+ *
61
+ * Captured at interrupt entry and threaded down, never re-read: once handlers can outlive
62
+ * 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.
64
+ */
65
+ export interface Invocation {
66
+ readonly emitter: RlmEmitter;
67
+ readonly parentId: string | undefined;
68
+ readonly depth: number;
69
+ readonly limits: InvocationLimits;
70
+ }
71
+
72
+ /**
73
+ * The config slice these handlers read. Structurally satisfied by `RlmConfig`, and re-read on
74
+ * every call so `/rlm-config` changes take effect without rebuilding the sandbox.
75
+ */
76
+ export interface SubcallConfig {
77
+ readonly maxPromptChars: number;
78
+ readonly maxDepth: number;
79
+ readonly subSampling?: Sampling;
80
+ readonly subSystemPrompt?: string;
81
+ }
82
+
83
+ export interface SubcallHandlerDeps {
84
+ /**
85
+ * Pick the Invocation for this interrupt. `null` ⇒ the bridge is not wired yet.
86
+ *
87
+ * `depth` is the depth the sandbox reported. The headless engine trusts it (its handlers
88
+ * serve one sandbox per depth); the repl() tool overrides it with its own turn depth.
89
+ */
90
+ readonly resolve: (opts: SubcallOpts, depth: number) => Invocation | null;
91
+ /** Session-wide admission control. Required — a per-caller default would silently unbound it. */
92
+ readonly gates: SubcallGates;
93
+ readonly registry: ModelRegistry;
94
+ readonly getWorkerModel: () => Model<Api>;
95
+ /** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
96
+ readonly getConfig: () => SubcallConfig;
97
+ readonly signal?: AbortSignal;
98
+ readonly onUsage?: (usage: Usage, role: "sub") => void;
99
+
100
+ // ── recursion (omit all three to get llm-only handlers) ──
101
+ /**
102
+ * Spawns a child RLM for rlm_query.
103
+ *
104
+ * Receives the parent's Invocation so the child can report on the same emitter its
105
+ * parent subcall node lives on — a child of detached work must not emit to a turn
106
+ * emitter that will be shut down before it finishes, and keeping parent and child on
107
+ * one emitter is what lets the session registry drain the subtree intact.
108
+ */
109
+ readonly runChild?: (input: RlmInput, inv: Invocation) => Promise<RlmResult>;
110
+ readonly getModel?: () => Model<Api>;
111
+ /**
112
+ * What rlm_query degrades to at the depth cap. A child RLM there would just be an LM, so
113
+ * both callers hand in their own one-shot path rather than re-deriving one here.
114
+ */
115
+ readonly degrade?: (prompt: string, model: string | null, depth: number) => Promise<string>;
116
+ /** Called with a child run's totals so a caller-side guard can debit them too. */
117
+ readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
118
+ /** Wraps detached work so a session registry can count what is still in flight. */
119
+ readonly trackDetached?: <T>(run: () => Promise<T>) => Promise<T>;
120
+ }
121
+
122
+ /** DRY #4 — the batch failure summary, previously written out in both copies. */
123
+ export function summarizeBatch(out: readonly string[]): { readonly failed: number; readonly error?: string } {
124
+ let failed = 0;
125
+ for (const item of out) if (isErrorText(item)) failed += 1;
126
+ if (failed === 0) return { failed: 0 };
127
+ const error = failed === out.length
128
+ ? `all ${out.length} sub-calls failed — reduce batch size or try llm_query individually`
129
+ : `${failed}/${out.length} sub-calls failed`;
130
+ return { failed, error };
131
+ }
132
+
133
+ export type SubcallHandlers = Pick<
134
+ SubLlmHandlers,
135
+ "llmQuery" | "llmQueryBatched" | "rlmQuery" | "rlmQueryBatched"
136
+ >;
137
+
138
+ const UNWIRED = formatError("RLM bridge not wired for this invocation");
139
+
140
+ function emptyResult(answer: string): RlmResult {
141
+ return { answer, iterations: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, durationMs: 0 };
142
+ }
143
+
144
+ export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers {
145
+ /** DRY #3 — the one display-model resolution. */
146
+ const displayModel = (model: string | null): string =>
147
+ displayModelRef(deps.registry, model, deps.getWorkerModel());
148
+
149
+ /** Detached work is counted by the session registry; attached work runs as-is. */
150
+ const detachable = <T>(opts: SubcallOpts, run: () => Promise<T>): Promise<T> =>
151
+ opts.detached && deps.trackDetached !== undefined ? deps.trackDetached(run) : run();
152
+
153
+ /** One leaf completion. Reports cost/tokens via `track`; never throws. */
154
+ async function complete1(
155
+ inv: Invocation,
156
+ prompt: string,
157
+ model: string | null,
158
+ track: (usage: Usage) => void,
159
+ ): Promise<string> {
160
+ const config = deps.getConfig();
161
+ const limitError = checkResourceLimits({
162
+ budgetUsd: inv.limits.remainingBudgetUsd(),
163
+ timeoutMs: inv.limits.remainingTimeoutMs(),
164
+ });
165
+ if (limitError !== undefined) return limitError;
166
+ if (prompt.length > config.maxPromptChars) {
167
+ return formatError(
168
+ `sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ` +
169
+ `${config.maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`,
170
+ );
171
+ }
172
+ const resolved = model ? resolveModelId(deps.registry, model) : undefined;
173
+ if (model && !resolved) return formatError(`unknown model override '${model}'`);
174
+ try {
175
+ const messages: ChatMsg[] = [{ role: "user", content: prompt }];
176
+ const res = await deps.gates.leaf.run(() => modelComplete(messages, {
177
+ model: resolved ?? deps.getWorkerModel(),
178
+ registry: deps.registry,
179
+ system: config.subSystemPrompt,
180
+ maxTokens: config.subSampling?.maxTokens,
181
+ temperature: config.subSampling?.temperature,
182
+ reasoning: config.subSampling?.reasoning,
183
+ signal: deps.signal,
184
+ }));
185
+ inv.limits.addUsage(res.usage);
186
+ deps.onUsage?.(res.usage, "sub");
187
+ track(res.usage);
188
+ return res.text;
189
+ } catch (err) {
190
+ const msg = errorMessage(err);
191
+ const hint = /credit|402|payment|quota|rate.limit/i.test(msg)
192
+ ? " — try smaller batches or individual llm_query calls"
193
+ : "";
194
+ return formatError(`${msg}${hint}`);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * DRY #5 — the create → execute → update emit pattern for leaf sub-calls, in one place.
200
+ * rlm_query does NOT use this: its node is created inside `childRun`, and a wrapper here
201
+ * would double-report it.
202
+ */
203
+ async function emitting<T>(
204
+ opts: SubcallOpts,
205
+ depth: number,
206
+ init: { kind: "llm" | "batch"; label: string; model: string | null; args: string },
207
+ run: (inv: Invocation, track: (usage: Usage) => void) => Promise<T>,
208
+ summarize: (out: T) => {
209
+ readonly preview: string;
210
+ readonly error?: string;
211
+ readonly failed?: number;
212
+ readonly total?: number;
213
+ },
214
+ unwired: () => T,
215
+ ): Promise<T> {
216
+ const inv = deps.resolve(opts, depth);
217
+ if (inv === null) return unwired();
218
+ const id = inv.emitter.emitSubcallCreated({
219
+ kind: init.kind, parentId: inv.parentId, label: init.label,
220
+ model: displayModel(init.model), args: init.args, depth: inv.depth,
221
+ });
222
+ let costUsd = 0;
223
+ let tokens = 0;
224
+ const track = (usage: Usage): void => { costUsd += usage.cost.total; tokens += usage.totalTokens; };
225
+ const out = await detachable(opts, () => run(inv, track));
226
+ const summary = summarize(out);
227
+ inv.emitter.emitSubcallUpdated({
228
+ id,
229
+ status: summary.error !== undefined ? "error" : "done",
230
+ costUsd, tokens,
231
+ resultPreview: summary.preview,
232
+ detail: summary.error,
233
+ failedCount: summary.failed,
234
+ totalCount: summary.total,
235
+ });
236
+ return out;
237
+ }
238
+
239
+ /**
240
+ * One child RLM run: depth cap → resource guard → spawn engine → debit parent.
241
+ * Emits its own subcall node, so callers must not wrap it in another.
242
+ */
243
+ async function childRun(inv: Invocation, prompt: string, model: string | null): Promise<RlmResult> {
244
+ const childDepth = inv.depth + 1;
245
+ const run = deps.runChild;
246
+ const maxDepth = deps.getConfig().maxDepth;
247
+
248
+ // At the cap a child RLM would just be an LM — short-circuit to the caller's one-shot path.
249
+ if (run === undefined || childDepth >= maxDepth) {
250
+ const degrade = deps.degrade;
251
+ const answer = degrade !== undefined
252
+ ? await degrade(prompt, model, inv.depth)
253
+ : await complete1(inv, prompt, model, () => {});
254
+ return emptyResult(answer);
255
+ }
256
+
257
+ const remBudget = inv.limits.remainingBudgetUsd();
258
+ const remTimeout = inv.limits.remainingTimeoutMs();
259
+ const limitError = checkResourceLimits({ budgetUsd: remBudget, timeoutMs: remTimeout });
260
+ if (limitError) return emptyResult(limitError);
261
+
262
+ const rootModel = deps.getModel?.();
263
+ const resolvedOverride = model ? resolveModelId(deps.registry, model) : undefined;
264
+ const modelLabel = model
265
+ ? (modelRef(resolvedOverride) ?? `unknown/${model}`)
266
+ : (rootModel === undefined ? undefined : (modelRef(rootModel) ?? rootModel.id));
267
+ const subId = inv.emitter.emitSubcallCreated({
268
+ kind: "rlm", parentId: inv.parentId, label: "rlm_query",
269
+ model: modelLabel, detail: prompt.slice(0, 60), depth: childDepth,
270
+ });
271
+ try {
272
+ const res = await deps.gates.rlm.at(childDepth).run(() => run({
273
+ rootPrompt: "",
274
+ context: prompt,
275
+ depth: childDepth,
276
+ parentNodeId: subId,
277
+ modelOverride: model ?? undefined,
278
+ remainingBudgetUsd: remBudget,
279
+ remainingTimeoutMs: remTimeout,
280
+ }, inv));
281
+ inv.limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
282
+ deps.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
283
+ // The child emits live usage deltas on the shared emitter, so no aggregate cost here
284
+ // — adding it would double-count against SubcallStore's running totals.
285
+ inv.emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: res.answer.slice(0, 200) });
286
+ return res;
287
+ } catch (err) {
288
+ const msg = errorMessage(err);
289
+ inv.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
290
+ return emptyResult(formatError(`child RLM failed - ${msg}`));
291
+ }
292
+ }
293
+
294
+ return {
295
+ llmQuery: (prompt, model, depth, opts) => emitting(
296
+ opts, depth,
297
+ { kind: "llm", label: "llm_query", model, args: `prompt: ${previewText(prompt)}` },
298
+ (inv, track) => complete1(inv, prompt, model, track),
299
+ (out) => ({ preview: previewText(out), error: isErrorText(out) ? out : undefined }),
300
+ () => UNWIRED,
301
+ ),
302
+
303
+ llmQueryBatched: (prompts, model, depth, opts) => emitting(
304
+ opts, depth,
305
+ { kind: "batch", label: `llm_query ×${prompts.length}`, model, args: `prompt: ${previewText(prompts[0] ?? "")}` },
306
+ // NO outer gate. `complete1` already takes the single `leaf` slot each prompt needs;
307
+ // an outer `gates.leaf.map` here deadlocked every batch of >= limit prompts.
308
+ (inv, track) => Promise.all(prompts.map((p) => complete1(inv, p, model, track))),
309
+ (out) => {
310
+ const { failed, error } = summarizeBatch(out);
311
+ const first = previewText(out[0] ?? "");
312
+ return {
313
+ preview: out.length > 1 ? `${first} (+${out.length - 1} more)` : first,
314
+ error, failed, total: out.length,
315
+ };
316
+ },
317
+ () => prompts.map(() => UNWIRED),
318
+ ),
319
+
320
+ async rlmQuery(prompt, model, depth, opts) {
321
+ const inv = deps.resolve(opts, depth);
322
+ if (inv === null) return UNWIRED;
323
+ return detachable(opts, async () => (await childRun(inv, prompt, model)).answer);
324
+ },
325
+
326
+ async rlmQueryBatched(prompts, model, depth, opts) {
327
+ const inv = deps.resolve(opts, depth);
328
+ if (inv === null) return prompts.map(() => UNWIRED);
329
+ // Bounded by the per-depth rlm gate inside childRun, not by an outer pool.
330
+ return detachable(opts, async () => {
331
+ const results = await Promise.all(prompts.map((p) => childRun(inv, p, model)));
332
+ return results.map((r) => r.answer);
333
+ });
334
+ },
335
+ };
336
+ }
@@ -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);
@@ -16,7 +16,10 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
16
16
  maxIterations: 30,
17
17
  execTimeoutS: 120,
18
18
  requestTimeoutMs: 10 * 60_000,
19
- maxConcurrentSubcalls: 4,
19
+ // 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,
20
23
  maxPromptChars: 400_000,
21
24
  maxErrors: 5,
22
25
  orchestrator: true,
@@ -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;