@hicaru/pi-rlm 0.2.1 → 0.3.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.
Files changed (79) hide show
  1. package/README.md +28 -47
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +22 -19
  5. package/src/bridge/add-context.ts +322 -0
  6. package/src/bridge/subcall-handlers.ts +63 -17
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +8 -18
  10. package/src/config/settings.ts +13 -34
  11. package/src/context/anydoc.ts +67 -0
  12. package/src/context/listing.ts +70 -0
  13. package/src/context/md-cache.ts +112 -0
  14. package/src/context/merge.ts +97 -0
  15. package/src/context/namespace.ts +180 -0
  16. package/src/context/resolve.ts +122 -0
  17. package/src/context/source-dir.ts +166 -0
  18. package/src/context/source-doc.ts +71 -0
  19. package/src/context/source-git.ts +51 -0
  20. package/src/context/source-text.ts +45 -0
  21. package/src/context/types.ts +88 -0
  22. package/src/context/walk.ts +250 -0
  23. package/src/core/engine.ts +61 -345
  24. package/src/core/history.ts +1 -1
  25. package/src/core/limits.ts +5 -12
  26. package/src/core/resource-limits.ts +0 -2
  27. package/src/core/types.ts +10 -38
  28. package/src/index.ts +92 -54
  29. package/src/mode/llm-model.ts +54 -0
  30. package/src/mode/rlm-mode.ts +28 -58
  31. package/src/prompts/glossary.ts +290 -0
  32. package/src/prompts/native.ts +127 -0
  33. package/src/prompts/system.ts +15 -408
  34. package/src/sandbox/context-file.ts +154 -0
  35. package/src/sandbox/interrupts.ts +160 -0
  36. package/src/sandbox/protocol.ts +20 -75
  37. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  38. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  39. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  40. package/src/sandbox/py/guards.py +150 -0
  41. package/src/sandbox/py/retrieval.py +265 -0
  42. package/src/sandbox/py/tasks.py +129 -0
  43. package/src/sandbox/py/worker.py +856 -0
  44. package/src/sandbox/sandbox-manager.ts +24 -9
  45. package/src/sandbox/sandbox.ts +99 -193
  46. package/src/text/tokens.ts +31 -5
  47. package/src/tool/repl-details.ts +2 -2
  48. package/src/tool/repl-render.ts +58 -0
  49. package/src/tool/repl-result.ts +70 -0
  50. package/src/tool/repl-tool.ts +60 -170
  51. package/src/tool/rlm-aggregator.ts +2 -10
  52. package/src/tool/rlm-details.ts +0 -2
  53. package/src/tool/rlm-events.ts +0 -14
  54. package/src/tool/rlm-tool.ts +2 -13
  55. package/src/ui/config-panel.ts +12 -20
  56. package/src/ui/intro.ts +1 -2
  57. package/src/ui/model-picker.ts +34 -10
  58. package/src/ui/status.ts +3 -7
  59. package/src/util/concurrency.ts +9 -5
  60. package/src/bridge/fallback-todo.ts +0 -148
  61. package/src/bridge/interactive.ts +0 -65
  62. package/src/bridge/library.ts +0 -155
  63. package/src/bridge/pi-interactive.ts +0 -41
  64. package/src/context/library-context.ts +0 -266
  65. package/src/context/repomix-context.ts +0 -204
  66. package/src/core/artifacts.ts +0 -89
  67. package/src/core/critique.ts +0 -92
  68. package/src/core/gates.ts +0 -301
  69. package/src/core/pipeline-handlers.ts +0 -319
  70. package/src/core/pipeline.ts +0 -268
  71. package/src/prompts/phases.ts +0 -104
  72. package/src/sandbox/worker.py +0 -1456
  73. package/src/state/index.ts +0 -24
  74. package/src/state/internal.ts +0 -46
  75. package/src/state/paths.ts +0 -44
  76. package/src/state/reads.ts +0 -133
  77. package/src/state/resume.ts +0 -173
  78. package/src/state/rows.ts +0 -123
  79. package/src/state/writes.ts +0 -58
package/src/core/types.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  /** Shared configuration + runtime types for the RLM engine. */
2
2
 
3
3
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
4
- import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
5
- import type { ReconstructResult } from "../state/resume.ts";
6
4
 
7
5
  export interface Sampling {
8
6
  readonly maxTokens?: number;
@@ -10,17 +8,6 @@ export interface Sampling {
10
8
  readonly reasoning?: ThinkingLevel;
11
9
  }
12
10
 
13
- export interface RunLogConfig {
14
- /** Default: true — always-on, opt-out. */
15
- readonly enabled?: boolean;
16
- /** Default: ".rlm/runs". Directory under cwd for run artifacts. */
17
- readonly dir?: string;
18
- /** Default: true — whether to write sandbox.pkl snapshots. */
19
- readonly snapshot?: boolean;
20
- /** Default: 50 — prune oldest runs beyond this count on each new run. */
21
- readonly maxRuns?: number;
22
- }
23
-
24
11
  export interface RlmConfig {
25
12
  /** Persistent editor-routing mode; when enabled, plain interactive prompts use RLM. */
26
13
  readonly enabled: boolean;
@@ -34,10 +21,11 @@ export interface RlmConfig {
34
21
  readonly requestTimeoutMs: number;
35
22
  /** Concurrency pool for *_batched sub-calls. */
36
23
  readonly maxConcurrentSubcalls: number;
24
+ /** Concurrent recursive child engines admitted per depth. Lower than maxConcurrentSubcalls:
25
+ * each child is a Python subprocess holding its own copy of the inherited context. */
26
+ readonly maxConcurrentChildren: number;
37
27
  /** Reject sub-LLM prompts larger than this many chars. */
38
28
  readonly maxPromptChars: number;
39
- /** Max USD spend across the whole tree before the engine stops (undefined = no cap). */
40
- readonly maxBudgetUsd?: number;
41
29
  /** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */
42
30
  readonly maxTimeoutMs?: number;
43
31
  /** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap). */
@@ -46,10 +34,6 @@ export interface RlmConfig {
46
34
  readonly maxErrors?: number;
47
35
  /** Append the orchestrator addendum to the system prompt. */
48
36
  readonly orchestrator: boolean;
49
- /** Enable the phase pipeline (advance_phase + stall nags) at depth 0. */
50
- readonly pipeline: boolean;
51
- /** Max validate→blueprint corrective re-entries when validation reports blockers (default 2). */
52
- readonly maxBackwardJumps: number;
53
37
  /** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
54
38
  readonly compaction: boolean;
55
39
  /** Compact when estimated history tokens reach this fraction of the model's context window. */
@@ -58,12 +42,13 @@ export interface RlmConfig {
58
42
  readonly python: string;
59
43
  /** Worker startup wait before treating sandbox init as failed (ms). */
60
44
  readonly sandboxInitTimeoutMs: number;
61
- /** Allow ask_user_question() calls from the root REPL. */
62
- readonly askUserQuestion: boolean;
63
- /** Allow todo() calls from the REPL. */
64
- readonly todo: boolean;
65
- /** Enable the load_library() REPL scaffold (external dirs/files/git repos as extra context slots). */
66
- readonly libraryLoader: boolean;
45
+ /** Enable the add_context() REPL scaffold (external dirs/files/git repos/documents into context). */
46
+ readonly contextLoader: boolean;
47
+ /**
48
+ * When true, the first repl() call seeds `context` with the working directory
49
+ * (un-prefixed paths). When false, context stays empty until add_context is called.
50
+ */
51
+ readonly autoSeedCwd: boolean;
67
52
  /** ThinkingLevel for the root smart model (set via /rlm-config). */
68
53
  readonly smartReasoning?: ThinkingLevel;
69
54
  /** Output token cap + temperature for the root smart model per turn.
@@ -76,8 +61,6 @@ export interface RlmConfig {
76
61
  readonly subSystemPrompt?: string;
77
62
  /** Sampling for sub-LLM (worker) calls. */
78
63
  readonly subSampling: Readonly<Sampling>;
79
- /** Optional run-state persistence configuration. Enabled by default. */
80
- readonly runLog?: RunLogConfig;
81
64
  }
82
65
 
83
66
  /** Input to a (headless) RLM run. */
@@ -92,12 +75,8 @@ export interface RlmInput {
92
75
  readonly parentNodeId?: string;
93
76
  /** "provider/id" — overrides the root model for this run (set by recursive rlm_query). */
94
77
  readonly modelOverride?: string;
95
- /** Remaining budget for this subtree (set by parent from its LimitGuard). */
96
- readonly remainingBudgetUsd?: number;
97
78
  /** Remaining timeout for this subtree (set by parent from its LimitGuard). */
98
79
  readonly remainingTimeoutMs?: number;
99
- /** Depth-0 resume payload — controller rebuilds this from the trail's `reconstructRlmState()`. */
100
- readonly resume?: ReconstructResult & { readonly ok: true };
101
80
  }
102
81
 
103
82
  /** Result of a completed RLM run. */
@@ -111,11 +90,4 @@ export interface RlmResult {
111
90
  }
112
91
 
113
92
  /** A function that runs an RLM to completion — used to wire recursion (rlm_query). */
114
- export interface InteractiveDeps {
115
- /** Called when the sandbox issues ask_user_question; undefined = feature disabled. */
116
- readonly onAskUserQuestion?: (questions: readonly AskQuestion[]) => Promise<AskAnswer[]>;
117
- /** Called when the sandbox issues todo; undefined = feature disabled. */
118
- readonly onTodo?: (action: string, params: Record<string, unknown>) => Promise<string>;
119
- }
120
-
121
93
  export type RunRlm = (input: RlmInput) => Promise<RlmResult>;
package/src/index.ts CHANGED
@@ -7,15 +7,19 @@ import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
7
7
  import { createRlmTool } from "./tool/rlm-tool.ts";
8
8
  import { createReplTool } from "./tool/repl-tool.ts";
9
9
  import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts";
10
- import { RlmController, cheapestModel } from "./mode/rlm-mode.ts";
10
+ import { RlmController } from "./mode/rlm-mode.ts";
11
+ import { cheapestModel } from "./mode/llm-model.ts";
11
12
  import { postRlmGuide } from "./ui/intro.ts";
12
13
  import { setRlmModeStatus } from "./ui/status.ts";
13
14
  import { markdownTheme } from "./ui/theme-adapter.ts";
14
15
  import { SandboxManager } from "./sandbox/sandbox-manager.ts";
15
16
  import { createSubcallGates } from "./util/concurrency.ts";
16
17
  import { BackgroundTasks } from "./tool/background-tasks.ts";
17
- import { packRepository, formatForLLM, serializeForSandbox } from "./context/repomix-context.ts";
18
- import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/system.ts";
18
+ import { resolve } from "node:path";
19
+ import { resolveSource } from "./context/resolve.ts";
20
+ import { formatContextListing } from "./context/listing.ts";
21
+ import type { AddContextHandlerBundle } from "./bridge/add-context.ts";
22
+ import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/native.ts";
19
23
  import { bashCommandFromInput, isFileReadingCommand, capToolResultText, BASH_BLOCK_REASON } from "./mode/native-guards.ts";
20
24
  import { errorMessage } from "./util/errors.ts";
21
25
 
@@ -42,9 +46,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
42
46
  });
43
47
  // One admission gate for the whole session: spawn() lets the sandbox put many requests on
44
48
  // the wire at once, so nothing smaller than session scope actually bounds fan-out.
45
- const gates = createSubcallGates(config.maxConcurrentSubcalls);
49
+ const gates = createSubcallGates(config.maxConcurrentSubcalls, config.maxConcurrentChildren);
46
50
  const background = new BackgroundTasks({
47
- maxBudgetUsd: config.maxBudgetUsd,
48
51
  maxTimeoutMs: config.maxTimeoutMs,
49
52
  maxTokens: config.maxTokens,
50
53
  maxErrors: config.maxErrors,
@@ -57,29 +60,44 @@ export default function rlmExtension(pi: ExtensionAPI): void {
57
60
  }, WATCHDOG_HEARTBEAT_MS);
58
61
  watchdogHeartbeat.unref();
59
62
 
60
- let packedContextText: string | undefined;
61
- let contextPackPromise: Promise<string | undefined> | undefined;
62
- const ensureRepositoryContext = async (cwd: string): Promise<string | undefined> => {
63
- if (packedContextText !== undefined && sandboxManager.contextPayload !== null) return packedContextText;
64
- contextPackPromise ??= packRepository(cwd)
63
+ /** Memoised cwd seed — one resolveSource(pathPrefix:"") per session. */
64
+ let seedPromise: Promise<void> | undefined;
65
+ let cwdSeeded = false;
66
+ /** Live add_context bundle seed plants the "" sentinel here so add_context(".") is a no-op. */
67
+ let contextBundleRef: AddContextHandlerBundle | undefined;
68
+ /**
69
+ * Payload identity last injected into the context hook. Re-inject only when the payload
70
+ * reference changes (seed, add_context, reset) — not every turn. Keeps the root window small.
71
+ */
72
+ let listingPayloadRef: unknown = undefined;
73
+ let listingInjected = false;
74
+ const seedContext = async (cwd: string): Promise<void> => {
75
+ if (cwdSeeded) return;
76
+ if (!controller.config.autoSeedCwd) {
77
+ cwdSeeded = true;
78
+ return;
79
+ }
80
+ seedPromise ??= resolveSource(cwd, { cwd, pathPrefix: "" })
65
81
  .then((result) => {
82
+ // Sticky either way: a failed seed must not re-walk the whole repo on every repl().
83
+ cwdSeeded = true;
66
84
  if (!result.ok) {
67
- console.warn(`[rlm] repository context pack failed: ${result.error}`);
68
- return undefined;
85
+ console.warn(`[rlm] context seed failed: ${result.error}`);
86
+ return;
69
87
  }
70
- sandboxManager.contextPayload = serializeForSandbox(result.value);
71
- packedContextText = formatForLLM(result.value);
72
- return packedContextText;
88
+ sandboxManager.contextPayload = result.value.payload;
89
+ // Register the cwd seed with the bridge so add_context(".") cannot double the tree.
90
+ contextBundleRef?.markSeededCwd(resolve(cwd));
73
91
  })
74
- .finally(() => { contextPackPromise = undefined; });
75
- return contextPackPromise;
92
+ .finally(() => { seedPromise = undefined; });
93
+ await seedPromise;
76
94
  };
77
95
 
78
96
  // Load persisted settings async — applied before session_start handler reads controller state
79
97
  const settingsReady = loadSettings()
80
98
  .then((persisted) => {
81
99
  controller.config = mergeConfig(persisted.config);
82
- controller.savedWorkerRef = persisted.worker;
100
+ controller.savedLlmRef = persisted.llm;
83
101
  })
84
102
  .catch((err) => {
85
103
  console.warn(`[rlm] settings load failed: ${errorMessage(err)}`);
@@ -120,30 +138,48 @@ export default function rlmExtension(pi: ExtensionAPI): void {
120
138
  const flag = pi.getFlag("rlm");
121
139
  if (typeof flag === "boolean") controller.setConfig(Object.freeze({ ...controller.config, enabled: flag }));
122
140
 
123
- if (controller.savedWorkerRef) {
124
- const resolved = resolveModelId(ctx.modelRegistry, controller.savedWorkerRef);
125
- if (resolved) controller.workerModel = resolved;
141
+ // Reload the catalog before the worker-model pick below reads it. Newer pi builds make
142
+ // `getAvailable()` an async-populated snapshot that starts empty, and picking from an empty
143
+ // catalog silently falls back to the root model. Called with no arguments and awaited so it
144
+ // is valid whether `refresh` returns void (current) or a promise (newer); fail-soft, because
145
+ // a refresh error must not abort session start.
146
+ try {
147
+ await ctx.modelRegistry.refresh();
148
+ } catch (err) {
149
+ console.warn(`[rlm] model registry refresh failed: ${errorMessage(err)}`);
150
+ }
151
+
152
+ if (controller.savedLlmRef) {
153
+ const resolved = resolveModelId(ctx.modelRegistry, controller.savedLlmRef);
154
+ if (resolved) controller.llmModel = resolved;
126
155
  }
127
156
 
128
157
  // Re-register repl tool each session to pick up model provider changes
129
- const workerModel = controller.workerModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
158
+ const llmModel = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
130
159
  const model = ctx.model;
131
- if (workerModel && model) {
160
+ if (llmModel && model) {
132
161
  try {
133
162
  pi.registerTool(createReplTool({
134
163
  sandboxManager,
135
164
  model,
136
- workerModel,
165
+ llmModel,
137
166
  getModel: () => controller.resolveModels(ctx)?.model,
138
- getWorkerModel: () => controller.resolveModels(ctx)?.worker,
167
+ getLlmModel: () => controller.resolveModels(ctx)?.llm,
139
168
  registry: ctx.modelRegistry,
140
169
  getConfig: () => controller.config,
141
170
  gates,
142
171
  background,
143
172
  registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
173
+ registerContextBundle: (bundle) => {
174
+ contextBundleRef = bundle;
175
+ // Tool re-registers each session; re-plant sentinel if seed already landed.
176
+ if (cwdSeeded && Array.isArray(sandboxManager.contextPayload)
177
+ && sandboxManager.contextPayload.length > 0) {
178
+ bundle.markSeededCwd(resolve(ctx.cwd ?? process.cwd()));
179
+ }
180
+ },
144
181
  ensureContext: async () => {
145
- const contextText = await ensureRepositoryContext(ctx.cwd ?? process.cwd());
146
- if (contextText === undefined) throw new Error("repository context could not be loaded into RLM sandbox");
182
+ await seedContext(ctx.cwd ?? process.cwd());
147
183
  },
148
184
  }));
149
185
  } catch (err) {
@@ -173,9 +209,10 @@ export default function rlmExtension(pi: ExtensionAPI): void {
173
209
  return { systemPrompt: event.systemPrompt + "\n\n" + buildNativeSystemPrompt() };
174
210
  });
175
211
 
176
- // ── Context injection: repo listing for the main agent ──
177
- let contextInjected = false;
178
- pi.on("context", async (event, ctx) => {
212
+ // ── Context injection: listing of whatever is currently loaded ──
213
+ // Re-inject only when the payload identity changes (seed / add_context), not every turn —
214
+ // the listing can be up to 200 file lines and the plugin exists to shrink the root window.
215
+ pi.on("context", async (event) => {
179
216
  const filtered = event.messages.filter(
180
217
  (message) =>
181
218
  !(message.role === "custom" && message.customType === "rlm-intro")
@@ -185,25 +222,24 @@ export default function rlmExtension(pi: ExtensionAPI): void {
185
222
 
186
223
  type PiMessage = (typeof filtered)[number];
187
224
 
188
- // Inject repository context as a compact listing (once per session)
189
- if (!contextInjected) {
190
- const cwd = ctx.cwd ?? process.cwd();
191
- const contextText = await ensureRepositoryContext(cwd);
192
- if (contextText !== undefined) {
193
- contextInjected = true;
194
- const instruction = [
195
- "ANALYZE THIS REPOSITORY using repl({code}) read/grep are DISABLED.",
196
- "Repository contents are pre-loaded in the Python REPL `context` variable.",
197
- "Locate with search()/grep_context()/outline() (free), then delegate bulk reading to",
198
- "map_files()/llm_query_batched(). If credits exhausted → report and stop.",
199
- "",
200
- ].join("\n");
201
- filtered.unshift({
202
- role: "user" as const,
203
- content: instruction + contextText,
204
- timestamp: 0,
205
- } as PiMessage);
206
- }
225
+ const payload = sandboxManager.contextPayload;
226
+ if (!listingInjected || payload !== listingPayloadRef) {
227
+ listingInjected = true;
228
+ listingPayloadRef = payload;
229
+ const listing = formatContextListing(payload);
230
+ const instruction = [
231
+ "ANALYZE with repl({code}) — read/grep are DISABLED.",
232
+ "Files you have loaded live in the Python REPL `context` variable (starts empty; cwd seeds on first repl()).",
233
+ "Locate with search()/grep_context()/outline() (free), then delegate bulk reading to",
234
+ "map_files()/llm_query_batched(). Use add_context(path) for external dirs/files/docs/git URLs.",
235
+ "If credits exhausted → report and stop.",
236
+ "",
237
+ ].join("\n");
238
+ filtered.unshift({
239
+ role: "user" as const,
240
+ content: instruction + listing,
241
+ timestamp: 0,
242
+ } as PiMessage);
207
243
  }
208
244
 
209
245
  // Per-turn last-position reminder (not persisted — context hook rebuilds every request)
@@ -225,7 +261,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
225
261
  if (BLOCKED_NATIVE_TOOLS.has(event.toolName)) {
226
262
  return {
227
263
  block: true,
228
- reason: "RLM mode active. Use repl({code}) to read files and search the repository — all files are pre-loaded in the REPL `context` variable. Use `edit`/`write` for file changes. If sub-LLM credits are exhausted, report to the user.",
264
+ reason: "RLM mode active. Use repl({code}) to read files and search the repository — loaded files live in the REPL `context` variable (cwd seeds on first call). Use `edit`/`write` for file changes. If sub-LLM credits are exhausted, report to the user.",
229
265
  };
230
266
  }
231
267
  const bashCommand = event.toolName === "bash" ? bashCommandFromInput(event.input) : undefined;
@@ -253,9 +289,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
253
289
  clearInterval(watchdogHeartbeat);
254
290
  background.dispose();
255
291
  await sandboxManager.dispose();
256
- contextInjected = false;
257
- packedContextText = undefined;
258
- contextPackPromise = undefined;
259
- sandboxManager.contextPayload = null;
292
+ cwdSeeded = false;
293
+ seedPromise = undefined;
294
+ contextBundleRef = undefined;
295
+ listingPayloadRef = undefined;
296
+ listingInjected = false;
297
+ sandboxManager.contextPayload = [];
260
298
  });
261
299
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Sub-LLM model ranking — "cheapest available", with free models winning outright.
3
+ *
4
+ * Pi's `ModelCost` is non-nullable (`packages/ai/src/types.ts`), so a free model is a literal 0,
5
+ * not a null. That makes plain price sorting ambiguous rather than wrong: subscription and
6
+ * token-plan providers also publish 0, and a stable sort would hand back whichever 0-cost entry
7
+ * happened to be first in catalog order. The tie-breaks below are what actually pick a usable
8
+ * free model — and what make the pick identical across sessions and catalog reorderings.
9
+ */
10
+
11
+ import type { Api, Model } from "@earendil-works/pi-ai";
12
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
13
+
14
+ /** $/Mtok, input-weighted 3:1 — a sub-call sends a file body and gets back a sentence. */
15
+ function priceOf(model: Model<Api>): number {
16
+ const { input, output, cacheRead } = model.cost;
17
+ return input * 3 + output + cacheRead;
18
+ }
19
+
20
+ /** True when the model costs nothing to call on any axis. */
21
+ export function isFreeModel(model: Model<Api>): boolean {
22
+ return priceOf(model) === 0;
23
+ }
24
+
25
+ /**
26
+ * Negative when `a` is the better sub-LLM.
27
+ *
28
+ * Window before maxTokens before id: a free model with a 4K context is useless for bulk reading,
29
+ * so price alone must not decide. The final id comparison exists only to make the result
30
+ * deterministic — without it the pick drifts whenever a provider reorders its catalog.
31
+ */
32
+ export function compareLlm(a: Model<Api>, b: Model<Api>): number {
33
+ return (priceOf(a) - priceOf(b))
34
+ || (b.contextWindow - a.contextWindow)
35
+ || (b.maxTokens - a.maxTokens)
36
+ || `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`);
37
+ }
38
+
39
+ /**
40
+ * Best sub-LLM among the models whose provider has configured auth.
41
+ *
42
+ * Single pass rather than `[...models].sort()[0]`: the copy and the sort both allocate for a
43
+ * result that is one element.
44
+ */
45
+ export function cheapestModel(registry: ModelRegistry): Model<Api> | undefined {
46
+ const models = registry.getAvailable();
47
+ let best: Model<Api> | undefined;
48
+ for (let i = 0; i < models.length; i++) {
49
+ const model = models[i];
50
+ if (model === undefined) continue;
51
+ if (best === undefined || compareLlm(model, best) < 0) best = model;
52
+ }
53
+ return best;
54
+ }
@@ -1,42 +1,35 @@
1
1
  /**
2
2
  * RlmController — holds RLM config + chosen models.
3
3
  *
4
- * The engine drives the root model turn-by-turn over ```repl``` blocks with full budget/token/
4
+ * The engine drives the root model turn-by-turn over ```repl``` blocks with full token/
5
5
  * timeout/error guards, compaction, and a finalize fallback. `start()` returns a RunHandle with
6
6
  * the completion promise.
7
7
  */
8
8
 
9
9
  import type { Api, Model } from "@earendil-works/pi-ai";
10
- import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
11
- import { DEFAULT_RUN_DIR } from "../config/defaults.ts";
10
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
11
  import { modelRef, resolveModelId, saveSettings } from "../config/settings.ts";
13
12
  import { createEngine } from "../core/engine.ts";
14
13
  import { limitsFromConfig } from "../core/limits.ts";
15
- import type { InteractiveDeps, RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
16
- import type { ReconstructResult } from "../state/resume.ts";
17
- import { packRepository, serializeForSandbox } from "../context/repomix-context.ts";
14
+ import type { RlmConfig, RlmResult } from "../core/types.ts";
15
+ import { resolveSource } from "../context/resolve.ts";
18
16
  import { RlmEmitter } from "../tool/rlm-events.ts";
19
17
  import { formatError } from "../util/errors.ts";
20
-
21
- export function cheapestModel(registry: ModelRegistry): Model<Api> | undefined {
22
- const models = registry.getAvailable();
23
- if (models.length === 0) return undefined;
24
- return [...models].sort((a, b) => a.cost.input + a.cost.output - (b.cost.input + b.cost.output))[0];
25
- }
18
+ import { cheapestModel } from "./llm-model.ts";
26
19
 
27
20
  export interface RunHandle {
28
21
  readonly abort: () => void;
29
22
  readonly done: Promise<RlmResult>;
30
23
  }
31
24
 
32
- /** B5+SA: discriminated union removes non-null `!` assertions and the `context: ""` hack. */
33
- export type StartInput =
34
- | { readonly kind: "fresh"; readonly rootPrompt: string; readonly context: unknown }
35
- | { readonly kind: "resume"; readonly resume: ReconstructResult & { ok: true }; readonly context: unknown };
25
+ export interface StartInput {
26
+ readonly rootPrompt: string;
27
+ readonly context: unknown;
28
+ }
36
29
 
37
30
  export class RlmController {
38
- workerModel: Model<Api> | undefined;
39
- savedWorkerRef: string | undefined;
31
+ llmModel: Model<Api> | undefined;
32
+ savedLlmRef: string | undefined;
40
33
  private active: AbortController | null = null;
41
34
 
42
35
  constructor(public config: RlmConfig) {}
@@ -65,7 +58,7 @@ export class RlmController {
65
58
  async persist(): Promise<boolean> {
66
59
  return await saveSettings({
67
60
  config: this.config,
68
- worker: modelRef(this.workerModel) ?? this.savedWorkerRef,
61
+ llm: modelRef(this.llmModel) ?? this.savedLlmRef,
69
62
  });
70
63
  }
71
64
 
@@ -77,15 +70,15 @@ export class RlmController {
77
70
  this.active?.abort();
78
71
  }
79
72
 
80
- resolveModels(ctx: ExtensionContext): { model: Model<Api>; worker: Model<Api> } | undefined {
81
- if (!this.workerModel && this.savedWorkerRef) this.workerModel = resolveModelId(ctx.modelRegistry, this.savedWorkerRef);
73
+ resolveModels(ctx: ExtensionContext): { model: Model<Api>; llm: Model<Api> } | undefined {
74
+ if (!this.llmModel && this.savedLlmRef) this.llmModel = resolveModelId(ctx.modelRegistry, this.savedLlmRef);
82
75
  const model = ctx.model ?? cheapestModel(ctx.modelRegistry);
83
76
  if (!model) return undefined;
84
- const worker = this.workerModel ?? cheapestModel(ctx.modelRegistry) ?? model;
85
- return { model, worker };
77
+ const llm = this.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
78
+ return { model, llm };
86
79
  }
87
80
 
88
- start(ctx: ExtensionContext, input: StartInput, emitter?: RlmEmitter, interactive?: InteractiveDeps): RunHandle {
81
+ start(ctx: ExtensionContext, input: StartInput, emitter?: RlmEmitter): RunHandle {
89
82
  const models = this.resolveModels(ctx);
90
83
  if (!models) throw new Error("no model with configured auth is available");
91
84
  if (this.active) throw new Error("RLM run already in progress"); // QC: mutual-exclusion guard
@@ -93,50 +86,27 @@ export class RlmController {
93
86
  const abortController = new AbortController();
94
87
  this.active = abortController;
95
88
 
96
- const runState = this.config.runLog?.enabled !== false
97
- ? { cwd: ctx.cwd ?? process.cwd(), dir: this.config.runLog?.dir ?? DEFAULT_RUN_DIR, snapshot: this.config.runLog?.snapshot !== false }
98
- : undefined;
99
-
100
89
  const done = (async () => {
101
- let engineInput: RlmInput;
102
- if (input.kind === "fresh") {
103
- // Auto-pack empty/undefined context via repomix; pass explicit context through.
104
- let contextValue: unknown = input.context;
105
- if (contextValue === undefined || contextValue === "" || (typeof contextValue === "string" && contextValue.trim() === "")) {
106
- const cwd = ctx.cwd ?? process.cwd();
107
- const result = await packRepository(cwd, abortController.signal);
108
- if (result.ok) {
109
- contextValue = serializeForSandbox(result.value);
110
- } else {
111
- contextValue = formatError(`failed to pack repository — ${result.error}`);
112
- }
113
- }
114
- engineInput = {
115
- rootPrompt: input.rootPrompt,
116
- context: contextValue,
117
- depth: 0,
118
- };
119
- } else {
120
- engineInput = {
121
- rootPrompt: input.resume.header.rootPrompt,
122
- context: input.context, // B5: load the actual context from the sidecar, not ""
123
- depth: 0,
124
- resume: input.resume,
125
- };
90
+ // Auto-seed empty/undefined context from cwd (same resolveSource path as native mode);
91
+ // pass explicit context through.
92
+ let contextValue: unknown = input.context;
93
+ if (contextValue === undefined || (typeof contextValue === "string" && contextValue.trim() === "")) {
94
+ const cwd = ctx.cwd ?? process.cwd();
95
+ const result = await resolveSource(cwd, { cwd, pathPrefix: "", signal: abortController.signal });
96
+ contextValue = result.ok
97
+ ? result.value.payload
98
+ : formatError(`failed to pack repository — ${result.error}`);
126
99
  }
127
100
  const engine = createEngine({
128
101
  model: models.model,
129
- workerModel: models.worker,
102
+ llmModel: models.llm,
130
103
  registry: ctx.modelRegistry,
131
104
  config: this.config,
132
105
  signal: abortController.signal,
133
106
  emitter: emitter ?? new RlmEmitter(),
134
- runState,
135
- onAskUserQuestion: interactive?.onAskUserQuestion,
136
- onTodo: interactive?.onTodo,
137
107
  limits: limitsFromConfig(this.config),
138
108
  });
139
- return await engine(engineInput);
109
+ return await engine({ rootPrompt: input.rootPrompt, context: contextValue, depth: 0 });
140
110
  })().finally(() => {
141
111
  if (this.active === abortController) this.active = null;
142
112
  });