@hicaru/pi-rlm 0.3.8 → 0.3.13

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 (55) hide show
  1. package/README.md +3 -4
  2. package/package.json +1 -1
  3. package/src/bridge/handlers/completion.ts +5 -0
  4. package/src/bridge/handlers/emitting.ts +33 -23
  5. package/src/bridge/handlers/index.ts +1 -1
  6. package/src/bridge/handlers/llm-query.ts +23 -24
  7. package/src/bridge/handlers/rlm-query.ts +10 -32
  8. package/src/bridge/handlers/types.ts +8 -1
  9. package/src/bridge/model.ts +33 -15
  10. package/src/commands/pins.ts +51 -0
  11. package/src/commands/rlm-config.ts +4 -88
  12. package/src/commands/rlm-llm.ts +59 -0
  13. package/src/commands/rlm-rlm.ts +58 -0
  14. package/src/commands/rlm.ts +2 -2
  15. package/src/config/defaults.ts +14 -4
  16. package/src/config/settings.ts +26 -3
  17. package/src/core/budget.ts +1 -1
  18. package/src/core/compaction.ts +4 -0
  19. package/src/core/engine.ts +21 -4
  20. package/src/core/iteration.ts +12 -0
  21. package/src/core/ledger.ts +15 -123
  22. package/src/core/memory.ts +13 -1
  23. package/src/core/model-registry.ts +1 -1
  24. package/src/core/types.ts +14 -0
  25. package/src/index.ts +53 -4
  26. package/src/mode/rlm-mode.ts +11 -1
  27. package/src/prompts/glossary.ts +11 -3
  28. package/src/prompts/native.ts +1 -1
  29. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  30. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  31. package/src/tool/repl-render.ts +4 -10
  32. package/src/tool/repl-tool.ts +30 -17
  33. package/src/tool/rlm-aggregator.ts +16 -3
  34. package/src/tool/rlm-details.ts +8 -0
  35. package/src/tool/rlm-events.ts +17 -1
  36. package/src/tool/rlm-tool.ts +25 -14
  37. package/src/tool/subcall-render.ts +14 -129
  38. package/src/tool/subcall-store.ts +11 -1
  39. package/src/ui/intro.ts +13 -4
  40. package/src/ui/modal/agent-modal.ts +104 -0
  41. package/src/ui/modal/modal-view.ts +132 -0
  42. package/src/ui/modal/timeline-store.ts +85 -0
  43. package/src/ui/model-picker/drilldown.ts +173 -0
  44. package/src/ui/model-picker/grouping.ts +81 -0
  45. package/src/ui/model-picker/levels.ts +63 -0
  46. package/src/ui/model-picker.ts +7 -197
  47. package/src/ui/panel/run-registry.ts +135 -0
  48. package/src/ui/panel/tree-panel.ts +46 -0
  49. package/src/ui/status.ts +26 -13
  50. package/src/ui/theme.ts +0 -4
  51. package/src/ui/tree/tree-model.ts +226 -0
  52. package/src/ui/tree/tree-rows.ts +74 -0
  53. package/src/ui/tree/tree-widget.ts +186 -0
  54. package/src/util/retry.ts +180 -0
  55. package/src/util/throttle.ts +90 -0
package/README.md CHANGED
@@ -18,8 +18,8 @@
18
18
 
19
19
  **The ONLY Recursive Language Model plugin for Pi.** No new agent to learn, no
20
20
  separate CLI, no YAML workflows — just `/rlm` and your existing Pi session becomes a
21
- recursive orchestration engine that saves **99% tokens** by delegating work to cheap
22
- worker models.
21
+ recursive orchestration engine your best model orchestrates, cheap worker models
22
+ do the reading.
23
23
 
24
24
  > **One install. One toggle. Infinite context.**
25
25
 
@@ -156,8 +156,7 @@ on `poolside/laguna-xs-2.1:free` (a free ~32B model):
156
156
  | Coding (retry fix) | orchestrator | **correct** (file edited) |
157
157
  | Live smoke needle | classic RLM | **hit** (~5k tokens) |
158
158
 
159
- > On a *free* model. Frontier models do even better. See `rlm_test/RESULTS_AGENT.md`
160
- > and `rlm_test/RESULTS.md` for full methodology.
159
+ > On a *free* model. Frontier models do even better.
161
160
 
162
161
  ## Security
163
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.3.8",
3
+ "version": "0.3.13",
4
4
  "author": "hicaru",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,7 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
12
  import { type ChatMsg, modelComplete } from "../model.ts";
13
13
  import { checkResourceLimits } from "../../core/resource-limits.ts";
14
14
  import { errorMessage, formatError } from "../../util/errors.ts";
15
+ import { retryPolicy } from "../../util/retry.ts";
15
16
  import type { Semaphore } from "../../util/concurrency.ts";
16
17
  import type { Invocation, SubcallConfig } from "./types.ts";
17
18
 
@@ -33,6 +34,7 @@ export async function complete1(
33
34
  prompt: string,
34
35
  track: (usage: Usage) => void,
35
36
  deps: Complete1Deps,
37
+ hooks?: { readonly onThrottlePark?: (ms: number) => void; readonly onThrottleRelease?: () => void },
36
38
  ): Promise<string> {
37
39
  const config = deps.getConfig();
38
40
  const limitError = checkResourceLimits({
@@ -55,6 +57,9 @@ export async function complete1(
55
57
  maxTokens: config.subSampling?.maxTokens,
56
58
  temperature: config.subSampling?.temperature,
57
59
  reasoning: config.subSampling?.reasoning,
60
+ retry: retryPolicy(config),
61
+ onThrottlePark: hooks?.onThrottlePark,
62
+ onThrottleRelease: hooks?.onThrottleRelease,
58
63
  signal: deps.signal,
59
64
  }),
60
65
  );
@@ -8,6 +8,7 @@
8
8
  import type { Usage } from "@earendil-works/pi-ai";
9
9
  import { isErrorText } from "../../util/errors.ts";
10
10
  import { previewText } from "../../text/preview.ts";
11
+ import type { SubcallPhase } from "../../tool/rlm-details.ts";
11
12
  import type { Invocation } from "./types.ts";
12
13
 
13
14
  export interface EmitOpts {
@@ -24,14 +25,19 @@ export interface EmitSummary {
24
25
  readonly total?: number;
25
26
  }
26
27
 
28
+ /** Targeted update for THIS node — the 2nd arg every `fn` handed to emitting() receives. */
29
+ export type EmitNote = (u: { readonly phase?: SubcallPhase; readonly detail?: string }) => void;
30
+
27
31
  /**
28
32
  * Create a subcall node, run `fn`, then update the node with status/cost/preview.
29
33
  * `fn` should not throw for soft failures (prefer Error: strings). Hard throws mark error.
34
+ * The `note` arg lets `fn` surface live per-node state — e.g. parking on the rate-limit
35
+ * cooldown (throttleHooks below).
30
36
  */
31
37
  export async function emitting<T>(
32
38
  inv: Invocation,
33
39
  opts: EmitOpts,
34
- fn: (track: (usage: Usage) => void) => Promise<T>,
40
+ fn: (track: (usage: Usage) => void, note: EmitNote) => Promise<T>,
35
41
  summarize: (out: T) => EmitSummary,
36
42
  ): Promise<T> {
37
43
  const id = inv.emitter.emitSubcallCreated({
@@ -42,6 +48,11 @@ export async function emitting<T>(
42
48
  args: opts.args,
43
49
  depth: inv.depth,
44
50
  });
51
+ // Leaf nodes spend their whole lifetime waiting on the model — say so from birth.
52
+ inv.emitter.emitSubcallUpdated({ id, phase: "waiting" });
53
+ const note: EmitNote = (u): void => {
54
+ inv.emitter.emitSubcallUpdated({ id, ...u });
55
+ };
45
56
 
46
57
  let costUsd = 0;
47
58
  let tokens = 0;
@@ -51,7 +62,7 @@ export async function emitting<T>(
51
62
  };
52
63
 
53
64
  try {
54
- const out = await fn(track);
65
+ const out = await fn(track, note);
55
66
  const summary = summarize(out);
56
67
  inv.emitter.emitSubcallUpdated({
57
68
  id,
@@ -78,27 +89,26 @@ export async function emitting<T>(
78
89
  }
79
90
  }
80
91
 
81
- /** Summarize a batch result for the emitter. */
82
- export function summarizeBatch(out: readonly string[]): EmitSummary {
83
- let failed = 0;
84
- let firstError: string | undefined;
85
- for (const s of out) {
86
- if (isErrorText(s)) {
87
- failed += 1;
88
- firstError ??= s;
89
- }
90
- }
91
- const first = previewText(out[0] ?? "");
92
- const error =
93
- failed === 0
94
- ? undefined
95
- : failed === out.length
96
- ? `all ${out.length} sub-calls failed — reduce batch size or try llm_query individually`
97
- : `${failed}/${out.length} sub-calls failed`;
92
+ /** Summarize a single leaf answer for the emitter — shared by llm_query and every llm_batch item. */
93
+ export function summarizeLeaf(out: string): EmitSummary {
94
+ return { preview: previewText(out), error: isErrorText(out) ? out : undefined };
95
+ }
96
+
97
+ /**
98
+ * Wire a leaf's `note` into modelComplete's throttle callbacks: parked → "queued" with the
99
+ * pending seconds in detail; released → back to plain "waiting". One helper so every leaf
100
+ * call site reports the queue state identically.
101
+ */
102
+ export function throttleHooks(note: EmitNote): {
103
+ readonly onThrottlePark: (ms: number) => void;
104
+ readonly onThrottleRelease: () => void;
105
+ } {
98
106
  return {
99
- preview: out.length > 1 ? `${first} (+${out.length - 1} more)` : first,
100
- error: error ?? firstError,
101
- failed,
102
- total: out.length,
107
+ onThrottlePark: (ms): void => {
108
+ note({ phase: "queued", detail: `rate limit — waiting ${Math.max(1, Math.round(ms / 1000))}s` });
109
+ },
110
+ onThrottleRelease: (): void => {
111
+ note({ phase: "waiting" });
112
+ },
103
113
  };
104
114
  }
@@ -43,6 +43,6 @@ export type {
43
43
  } from "./types.ts";
44
44
 
45
45
  export { limitsFromRemaining } from "./types.ts";
46
- export { summarizeBatch } from "./emitting.ts";
46
+ export { summarizeLeaf } from "./emitting.ts";
47
47
  export { createTaskRegistry, SPAWN_HINT } from "./task-registry.ts";
48
48
  export type { TaskRegistry, SpawnDeps, AwaitDeps } from "./task-registry.ts";
@@ -5,8 +5,8 @@
5
5
  import type { Usage } from "@earendil-works/pi-ai";
6
6
  import { modelRef } from "../../config/settings.ts";
7
7
  import { complete1, type Complete1Deps } from "./completion.ts";
8
- import { emitting, summarizeBatch } from "./emitting.ts";
9
- import { formatError, isErrorText, errorMessage } from "../../util/errors.ts";
8
+ import { emitting, summarizeLeaf, throttleHooks } from "./emitting.ts";
9
+ import { formatError, errorMessage } from "../../util/errors.ts";
10
10
  import { previewText } from "../../text/preview.ts";
11
11
  import type { SpawnResult, SubcallHandlerDeps } from "./types.ts";
12
12
  import type { SubcallOpts } from "../../sandbox/interrupts.ts";
@@ -106,11 +106,8 @@ export function createLlmQueryHandler(
106
106
  args: `prompt: ${previewText(prompt)}`,
107
107
  model: displayModel(deps),
108
108
  },
109
- (track: (u: Usage) => void) => complete1(inv, prompt, track, cdeps),
110
- (out) => ({
111
- preview: previewText(out),
112
- error: isErrorText(out) ? out : undefined,
113
- }),
109
+ (track: (u: Usage) => void, note) => complete1(inv, prompt, track, cdeps, throttleHooks(note)),
110
+ summarizeLeaf,
114
111
  );
115
112
  // v5 TaskLedger for leaves: identical prompts coalesce onto one completion (key has no
116
113
  // context — a leaf's entire world is the prompt text itself).
@@ -153,31 +150,33 @@ export function createLlmBatchHandler(
153
150
  sd,
154
151
  "llm_batch",
155
152
  prompts.length,
153
+ // One visible node per prompt — no collapsed "×N" row, no hidden failures: each item
154
+ // reports its own status/tokens/error (UI parity with pi: every concurrent call renders).
156
155
  () =>
157
- emitting(
158
- inv,
159
- {
160
- kind: "batch",
161
- label: `llm_batch ×${prompts.length}`,
162
- args: `prompt: ${previewText(prompts[0] ?? "")}`,
163
- model: displayModel(deps),
164
- },
165
- // NO outer gate — complete1 takes the single leaf slot per prompt.
166
- // v5 (audit H3): every item routes through the ledger — duplicate prompts inside
167
- // one batch (or twins of other in-flight leaves) coalesce instead of paying N times.
168
- (track: (u: Usage) => void) =>
169
- Promise.all(
170
- prompts.map((p) =>
156
+ Promise.all(
157
+ prompts.map((p) =>
158
+ emitting(
159
+ inv,
160
+ {
161
+ kind: "llm",
162
+ label: "llm_query",
163
+ args: `prompt: ${previewText(p)}`,
164
+ model: displayModel(deps),
165
+ },
166
+ // NO outer gate complete1 takes the single leaf slot per prompt.
167
+ // v5 (audit H3): every item routes through the ledger — duplicate prompts inside
168
+ // one batch (or twins of other in-flight leaves) coalesce instead of paying N times.
169
+ (track: (u: Usage) => void, note) =>
171
170
  runClaimedLeaf(
172
171
  ledger,
173
172
  ledger === undefined ? undefined : leafClaimKey(deps, p),
174
173
  p,
175
174
  inv.depth,
176
- () => complete1(inv, p, track, cdeps),
175
+ () => complete1(inv, p, track, cdeps, throttleHooks(note)),
177
176
  ),
178
- ),
177
+ summarizeLeaf,
179
178
  ),
180
- summarizeBatch,
179
+ ),
181
180
  ),
182
181
  deps.trackDetached,
183
182
  opts.detached,
@@ -15,8 +15,7 @@ import type { Invocation, SpawnResult, SubcallHandlerDeps } from "./types.ts";
15
15
  import type { SubcallOpts } from "../../sandbox/interrupts.ts";
16
16
  import { SPAWN_HINT, spawnAndRun, type SpawnDeps } from "./task-registry.ts";
17
17
  import { complete1, type Complete1Deps } from "./completion.ts";
18
- import { emitting } from "./emitting.ts";
19
- import { isErrorText } from "../../util/errors.ts";
18
+ import { emitting, summarizeLeaf, throttleHooks } from "./emitting.ts";
20
19
  import { leafClaimKey, runClaimedLeaf } from "./llm-query.ts";
21
20
 
22
21
  const UNWIRED = formatError("RLM bridge not wired for this invocation");
@@ -159,6 +158,7 @@ async function childRun(
159
158
  detail: prompt.slice(0, 60),
160
159
  depth: childDepth,
161
160
  });
161
+ inv.emitter.emitSubcallUpdated({ id: subId, phase: "spawning" });
162
162
 
163
163
  if (decision?.type === "echo") {
164
164
  inv.emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: ECHO_STUB.slice(0, 80) });
@@ -265,11 +265,8 @@ export function createRlmQueryHandler(deps: SubcallHandlerDeps, sd: SpawnDeps) {
265
265
  label: "rlm_query→llm (demoted)",
266
266
  args: previewText(task),
267
267
  },
268
- (track) => complete1(inv, task, track, completeDeps(deps)),
269
- (out) => ({
270
- preview: previewText(out),
271
- error: isErrorText(out) ? out : undefined,
272
- }),
268
+ (track, note) => complete1(inv, task, track, completeDeps(deps), throttleHooks(note)),
269
+ summarizeLeaf,
273
270
  ),
274
271
  ),
275
272
  deps.trackDetached,
@@ -311,36 +308,17 @@ export function createRlmBatchHandler(deps: SubcallHandlerDeps, sd: SpawnDeps) {
311
308
  }
312
309
 
313
310
  const pathArg = opts.paths;
314
- const id = inv.emitter.emitSubcallCreated({
315
- kind: "batch",
316
- parentId: inv.parentId,
317
- label: `rlm_batch ×${tasks.length}`,
318
- args: previewText(tasks[0] ?? ""),
319
- depth: inv.depth,
320
- });
321
-
311
+ // No wrapper "rlm_batch ×N" node: every task already gets its own rlm_query node from
312
+ // childRun (DRY #2), parented to the caller — the batch is spawn fan-out, not a UI row.
322
313
  return spawnAndRun(
323
314
  sd,
324
315
  "rlm_batch",
325
316
  tasks.length,
326
317
  async () => {
327
- try {
328
- const results = await Promise.all(
329
- tasks.map((t) => childRun(deps, inv, t, pathArg)),
330
- );
331
- const answers = results.map((r) => r.answer);
332
- inv.emitter.emitSubcallUpdated({
333
- id,
334
- status: "done",
335
- resultPreview: previewText(answers[0] ?? ""),
336
- totalCount: answers.length,
337
- });
338
- return answers;
339
- } catch (err: unknown) {
340
- const msg = errorMessage(err);
341
- inv.emitter.emitSubcallUpdated({ id, status: "error", detail: msg });
342
- throw err;
343
- }
318
+ const results = await Promise.all(
319
+ tasks.map((t) => childRun(deps, inv, t, pathArg)),
320
+ );
321
+ return results.map((r) => r.answer);
344
322
  },
345
323
  deps.trackDetached,
346
324
  opts.detached,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Every subcall (llm_query, llm_batch, rlm_query, rlm_batch) returns a SpawnResult
5
5
  * immediately with a task_id. The model must call await(task_id) to collect the
6
- * real answer. This contract is proven in rlm_test (api_v5 + batch, scores 0.89–1.0).
6
+ * real answer. This contract is proven in bake-off runs (api_v5 + batch, scores 0.89–1.0).
7
7
  */
8
8
 
9
9
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
@@ -93,6 +93,13 @@ export interface SubcallConfig {
93
93
  readonly rlmBudget?: number;
94
94
  /** v5 durable memory gates (optional; omitted → memory off). */
95
95
  readonly enableMemory?: boolean;
96
+ /** v5.1 retry knobs — structural slice of RlmConfig so retryPolicy() can read them. */
97
+ readonly retryMaxAttempts?: number;
98
+ readonly rateLimitMaxAttempts?: number;
99
+ readonly retryBaseDelayMs?: number;
100
+ readonly retryMaxDelayMs?: number;
101
+ readonly throttleBaseMs?: number;
102
+ readonly throttleMaxMs?: number;
96
103
  }
97
104
 
98
105
  export interface SubcallHandlerDeps {
@@ -2,12 +2,16 @@
2
2
  * modelComplete — a single, serverless, in-process LLM completion.
3
3
  *
4
4
  * This is the one place that talks to a provider. It resolves the API key from pi's
5
- * ModelRegistry (keys live here, never in the sandbox) and calls pi-ai's `completeSimple`.
6
- * Used both for `llm_query` (one user prompt) and for the headless RLM root (full history).
5
+ * ModelRegistry (keys live here, never in the sandbox) and calls pi-ai's `completeSimple`,
6
+ * wrapped in completeWithRetry: transient 429/5xx get exponential backoff (honoring
7
+ * `retry-after` via the onResponse hook) and rate limits additionally cool the shared
8
+ * per-provider throttle. Used both for `llm_query` (one user prompt) and for the headless
9
+ * RLM root (full history).
7
10
  */
8
11
 
9
12
  import { type Api, completeSimple, type Message, type Model, type ThinkingLevel, type Usage } from "@earendil-works/pi-ai/compat";
10
13
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
14
+ import { completeWithRetry, DEFAULT_RETRY_POLICY, type RetryPolicy } from "../util/retry.ts";
11
15
 
12
16
  export type Role = "system" | "user" | "assistant";
13
17
  export interface ChatMsg {
@@ -23,6 +27,11 @@ export interface CompleteOptions {
23
27
  readonly temperature?: number;
24
28
  readonly reasoning?: ThinkingLevel;
25
29
  readonly signal?: AbortSignal;
30
+ /** Retry + adaptive throttle for transient 429/5xx; defaults apply when omitted. */
31
+ readonly retry?: RetryPolicy;
32
+ /** v5.1 UX: fired while parked on the rate-limit cooldown ("queued") / when released. */
33
+ readonly onThrottlePark?: (ms: number) => void;
34
+ readonly onThrottleRelease?: () => void;
26
35
  }
27
36
 
28
37
  export interface CompleteResult {
@@ -78,20 +87,29 @@ export async function modelComplete(messages: readonly ChatMsg[], opts: Complete
78
87
  : opts.system
79
88
  : built.systemPrompt;
80
89
 
81
- const msg = await completeSimple(
82
- opts.model,
83
- { systemPrompt, messages: built.messages },
84
- {
85
- apiKey: auth.apiKey,
86
- headers: auth.headers,
87
- maxTokens: opts.maxTokens,
88
- temperature: opts.temperature,
89
- reasoning: opts.reasoning,
90
- signal: opts.signal,
90
+ const msg = await completeWithRetry(
91
+ async (note) => {
92
+ const response = await completeSimple(
93
+ opts.model,
94
+ { systemPrompt, messages: built.messages },
95
+ {
96
+ apiKey: auth.apiKey,
97
+ headers: auth.headers,
98
+ maxTokens: opts.maxTokens,
99
+ temperature: opts.temperature,
100
+ reasoning: opts.reasoning,
101
+ signal: opts.signal,
102
+ onResponse: (res) => { note(res.status, res.headers); },
103
+ },
104
+ );
105
+ // pi-ai folds provider failures into the message: "error"/"aborted" + errorMessage.
106
+ // Throwing here puts every completion failure onto ONE path — the retry classifier.
107
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
108
+ throw new Error(response.errorMessage ?? response.stopReason);
109
+ }
110
+ return response;
91
111
  },
112
+ { policy: opts.retry ?? DEFAULT_RETRY_POLICY, provider: opts.model.provider, signal: opts.signal, onPark: opts.onThrottlePark, onRelease: opts.onThrottleRelease },
92
113
  );
93
- if (msg.stopReason === "error" || msg.stopReason === "aborted") {
94
- throw new Error(msg.errorMessage ?? msg.stopReason);
95
- }
96
114
  return { text: extractText(msg.content), usage: msg.usage };
97
115
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * pins — apply model-picker selections to controller pin state.
3
+ *
4
+ * One function per role; both follow the same contract:
5
+ * `undefined` → ESC, no change · `null` → the role's top option (unpin) ·
6
+ * `ModelSelection` → pin model + its thinking level into the matching sampling slot.
7
+ */
8
+
9
+ import { modelRef } from "../config/settings.ts";
10
+ import type { RlmController } from "../mode/rlm-mode.ts";
11
+ import type { ModelSelection } from "../ui/model-picker.ts";
12
+
13
+ export function applyLlmSelection(controller: RlmController, llm: ModelSelection | null | undefined): void {
14
+ if (llm === undefined) return;
15
+ if (llm === null) {
16
+ controller.llmModel = undefined;
17
+ controller.savedLlmRef = undefined;
18
+ controller.explicitClearPin = true;
19
+ return;
20
+ }
21
+ controller.llmModel = llm.model;
22
+ controller.savedLlmRef = modelRef(llm.model);
23
+ controller.explicitClearPin = false;
24
+ controller.setConfig(Object.freeze({
25
+ ...controller.config,
26
+ subSampling: Object.freeze({
27
+ ...controller.config.subSampling,
28
+ reasoning: llm.thinkingLevel,
29
+ }),
30
+ }));
31
+ }
32
+
33
+ export function applyRlmSelection(controller: RlmController, rlm: ModelSelection | null | undefined): void {
34
+ if (rlm === undefined) return;
35
+ if (rlm === null) {
36
+ controller.rlmModel = undefined;
37
+ controller.savedRlmRef = undefined;
38
+ controller.explicitClearRlmPin = true;
39
+ return;
40
+ }
41
+ controller.rlmModel = rlm.model;
42
+ controller.savedRlmRef = modelRef(rlm.model);
43
+ controller.explicitClearRlmPin = false;
44
+ controller.setConfig(Object.freeze({
45
+ ...controller.config,
46
+ rootSampling: Object.freeze({
47
+ ...(controller.config.rootSampling ?? {}),
48
+ reasoning: rlm.thinkingLevel,
49
+ }),
50
+ }));
51
+ }
@@ -1,104 +1,20 @@
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. */
1
+ /** `/rlm-config` — run settings only. Model pins live in `/rlm-llm` and `/rlm-rlm`. */
3
2
 
4
- import type { Api, Model } from "@earendil-works/pi-ai";
5
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
- import { modelRef } from "../config/settings.ts";
7
4
  import type { RlmController } from "../mode/rlm-mode.ts";
8
- import { cheapestModel } from "../mode/llm-model.ts";
9
5
  import { setRlmModeStatus } from "../ui/status.ts";
10
6
  import { showConfigPanel } from "../ui/config-panel.ts";
11
- import { pickableModels, selectModel, type ModelSelection } 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
- }
20
-
21
- /**
22
- * Apply a model-picker result to controller pin state.
23
- *
24
- * - `null` → explicit "cheapest (auto)" (clear pin; leave reasoning alone)
25
- * - `ModelSelection` → pin that model (and its thinking level, which may be undefined)
26
- * - `undefined` → ESC / no change
27
- */
28
- export function applyLlmSelection(
29
- controller: RlmController,
30
- llm: ModelSelection | null | undefined,
31
- ): void {
32
- if (llm === undefined) return;
33
- if (llm === null) {
34
- controller.llmModel = undefined;
35
- controller.savedLlmRef = undefined;
36
- controller.explicitClearPin = true;
37
- return;
38
- }
39
- controller.llmModel = llm.model;
40
- controller.savedLlmRef = modelRef(llm.model);
41
- controller.explicitClearPin = false;
42
- controller.setConfig(Object.freeze({
43
- ...controller.config,
44
- subSampling: Object.freeze({
45
- ...controller.config.subSampling,
46
- reasoning: llm.thinkingLevel,
47
- }),
48
- }));
49
- }
50
-
51
- export async function runRlmConfig(controller: RlmController, ctx: ExtensionContext): Promise<boolean> {
52
- // Match Pi's native list: refresh so a just-added key appears, then use scoped models when
53
- // the session narrowed them, else every available (auth-configured) model. Never getAll().
54
- try {
55
- await ctx.modelRegistry.refresh();
56
- } catch {
57
- // Fail-soft: show the cached available snapshot rather than aborting config.
58
- }
59
- const models = pickableModels(ctx.modelRegistry, sessionScopedModels(ctx));
60
-
61
- const llm = await selectModel(
62
- ctx,
63
- "LLM model (sub-calls: llm_query / map_files / rlm_query)",
64
- models,
65
- controller.llmModel,
66
- controller.config.subSampling.reasoning,
67
- controller.savedLlmRef,
68
- );
69
- // Only an explicit choice touches the pin. ESC leaves model + reasoning alone.
70
- // Choosing cheapest must NOT wipe subSampling.reasoning (null !== undefined used to).
71
- applyLlmSelection(controller, llm);
72
-
73
- // Persist model choice immediately — if showConfigPanel throws or process exits before it
74
- // returns, the pin survives (Root Cause #2, v0.3.2).
75
- if (llm !== undefined) {
76
- const saved = await controller.persist();
77
- if (!saved) ctx.ui.notify("RLM: failed to save llm setting", "error");
78
- }
79
7
 
8
+ async function runRlmConfig(controller: RlmController, ctx: ExtensionContext): Promise<void> {
80
9
  controller.setConfig(await showConfigPanel(ctx, controller.config));
81
-
82
10
  const persisted = await controller.persist();
83
11
  if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
84
- setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
85
-
86
- // Name the model that actually resolved, not "(cheapest)" — otherwise there is no way to
87
- // tell whether the free model in the catalog was the one picked.
88
- const pinned = controller.llmModel;
89
- const effective = pinned ?? cheapestModel(ctx.modelRegistry);
90
- const reasoning = controller.config.subSampling.reasoning;
91
- ctx.ui.notify(
92
- `RLM: llm=${modelRef(effective) ?? "(none available)"}`
93
- + `${pinned ? "" : " (cheapest, auto)"}${reasoning ? `/${reasoning}` : ""}`,
94
- "info",
95
- );
96
- return llm !== undefined;
12
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
97
13
  }
98
14
 
99
15
  export function registerRlmConfigCommand(pi: ExtensionAPI, controller: RlmController): void {
100
16
  pi.registerCommand("rlm-config", {
101
- description: "Configure the RLM sub-LLM model and run settings.",
17
+ description: "Configure RLM run settings (models: /rlm-llm, /rlm-rlm).",
102
18
  handler: async (_args, ctx) => {
103
19
  await runRlmConfig(controller, ctx);
104
20
  },
@@ -0,0 +1,59 @@
1
+ /** `/rlm-llm` — pin the leaf-LLM model (llm_query / llm_batch / map_files). */
2
+
3
+ import type { Api, Model } from "@earendil-works/pi-ai";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { modelRef } from "../config/settings.ts";
6
+ import { cheapestModel } from "../mode/llm-model.ts";
7
+ import type { RlmController } from "../mode/rlm-mode.ts";
8
+ import { pickableModels, selectModel } from "../ui/model-picker.ts";
9
+ import { setRlmModeStatus } from "../ui/status.ts";
10
+ import { applyLlmSelection } from "./pins.ts";
11
+
12
+ /** Newer Pi hosts expose session-scoped models; 0.79 peers do not — duck-type safely. */
13
+ function sessionScopedModels(
14
+ ctx: ExtensionContext,
15
+ ): readonly { readonly model: Model<Api> }[] | undefined {
16
+ const scoped: unknown = Reflect.get(ctx, "scopedModels");
17
+ return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
18
+ }
19
+
20
+ async function runRlmLlm(controller: RlmController, ctx: ExtensionContext): Promise<void> {
21
+ try {
22
+ await ctx.modelRegistry.refresh();
23
+ } catch {
24
+ // Fail-soft: show the cached available snapshot rather than aborting config.
25
+ }
26
+ const models = pickableModels(ctx.modelRegistry, sessionScopedModels(ctx));
27
+ const llm = await selectModel(
28
+ ctx,
29
+ "llm",
30
+ models,
31
+ controller.llmModel,
32
+ controller.config.subSampling.reasoning,
33
+ controller.savedLlmRef,
34
+ );
35
+ applyLlmSelection(controller, llm);
36
+ const persisted = await controller.persist();
37
+ if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
38
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
39
+
40
+ // Name the model that actually resolved, not "(cheapest)" — otherwise there is no way to
41
+ // tell whether the free model in the catalog was the one picked.
42
+ const pinned = controller.llmModel;
43
+ const effective = pinned ?? cheapestModel(ctx.modelRegistry);
44
+ const reasoning = controller.config.subSampling.reasoning;
45
+ ctx.ui.notify(
46
+ `RLM: llm=${modelRef(effective) ?? "(none available)"}`
47
+ + `${pinned ? "" : " (cheapest, auto)"}${reasoning ? `/${reasoning}` : ""}`,
48
+ "info",
49
+ );
50
+ }
51
+
52
+ export function registerRlmLlmCommand(pi: ExtensionAPI, controller: RlmController): void {
53
+ pi.registerCommand("rlm-llm", {
54
+ description: "Pin the LLM model used by llm_query / llm_batch / map_files sub-calls.",
55
+ handler: async (_args, ctx) => {
56
+ await runRlmLlm(controller, ctx);
57
+ },
58
+ });
59
+ }