@hicaru/pi-rlm 0.1.0 → 0.1.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.
@@ -162,11 +162,13 @@ function nativeReplGlossary(): string {
162
162
  "- `rlm_query(prompt, model=None) -> str` — recursive RLM with its own REPL for complex sub-tasks needing iterative reasoning.",
163
163
  "- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
164
164
  "",
165
- "**Choosing between `llm_query` and `rlm_query`:** default to `llm_query` (fast/cheap) for one-shot tasks",
166
- "and fan out with `llm_query_batched`; reach for `rlm_query` only when a sub-task needs its own iterative",
167
- "reasoning. Avoid excessive recursive sub-calls when a batched one-shot suffices.",
165
+ "**Choosing between `llm_query` and `rlm_query`:** default to `llm_query`/batched; use `rlm_query` only for iterative sub-tasks.",
168
166
  "- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
169
167
  "- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
168
+ "- `stage_edit(path, old_text, new_text) -> str`: stage a file edit computed inside the REPL.",
169
+ " Read the file from `context`, compute the exact change in Python, then call",
170
+ " stage_edit once per file. The repl() result will include a STAGED_EDITS JSON block.",
171
+ " The main agent must then call `edit` for each entry verbatim — zero analysis needed.",
170
172
  "- `answer`: dict `{\"content\": \"\", \"ready\": False}`. To submit: `answer[\"content\"] = \"...\"; answer[\"ready\"] = True`.",
171
173
  "",
172
174
  "### Orchestrator Pattern",
@@ -198,45 +200,23 @@ function nativeReplGlossary(): string {
198
200
  "| `repl({code})` | Need to chunk/delegate `context` to sub-LLMs; need Python scripting; need REPL state across calls |",
199
201
  "| `read` / `grep` | Inspect a few specific files directly; small codebase |",
200
202
  "| `zebra-mcp` | Semantic search over the codebase |",
201
- "| `apply_diff({diff})` | Apply a unified diff to any file shows patch preview before writing |",
203
+ "| `edit` | Modify an existing file with exact text replacement (native Pi flow, visible to all plugins) |",
204
+ "| `write` | Create a new file (native Pi flow, visible to all plugins) |",
202
205
  "| `llm_query` (inside repl) | Extract, summarize, or classify a chunk of text |",
203
206
  "| `rlm_query` (inside repl) | Complex sub-task needing iterative reasoning with its own REPL |",
204
207
  "| `todo` (inside repl) | Track multi-step progress visibly to the user |",
208
+ "| `stage_edit(path, old, new)` (inside repl) | Sub-agent stages exact edit params; relay STAGED_EDITS to `edit` |",
205
209
  "",
206
210
  "### Workflow",
207
211
  "1. **Plan**: Create todos for the multi-step analysis. Probe `context` — print length, inspect a few entries.",
208
212
  "2. **Chunk & Delegate**: Slice `context` into batches, delegate each batch to sub-LLMs via `llm_query_batched`.",
209
213
  "3. **Aggregate**: Collect results in Python, pass aggregated results to a final `llm_query` or produce the answer directly.",
210
- "4. **Finalize**: Set `answer[\"content\"]` and `answer[\"ready\"] = True`, or just write your final answer as a normal message.",
214
+ "4. **Finalize**: For file changes, stage them inside repl() via stage_edit(path, old, new), then relay the STAGED_EDITS from the result to `edit`. For analysis tasks, write a normal message.",
211
215
  "",
212
216
  "### Task-Specific Patterns",
213
- "",
214
- "**Architecture analysis / \"learn this project\" / diagram**:",
215
- "1. Probe `repl()` to list all files grouped by directory",
216
- "2. Chunk → split files into module batches (~10-15 files each)",
217
- "3. **DELEGATE ALL** → `llm_query_batched` on EVERY module: \"Summarize each file's role, what it exports, and how it connects\". Send ALL batches.",
218
- "4. Aggregate → collect all sub-LLM summaries, synthesize diagram from them.",
219
- "5. If sub-LLM credits exhausted → report to user: \"Credits exhausted after N batches. Results so far: ...\"",
220
- "",
221
- "**Bug investigation / \"find the issue\"**:",
222
- "1. `repl()` → grep context for keywords (use Python re/in operators)",
223
- "2. `llm_query` on matching files: \"Is there a bug here? What could cause X?\"",
224
- "",
225
- "**Full code review / audit**:",
226
- "1. `repl()` → chunk all files, delegate ALL to `llm_query_batched` with review criteria",
227
- "2. Aggregate findings, report to user",
228
- "",
229
- "CRITICAL: Never read files directly. If sub-LLMs fail → report, don't fall back to read.",
230
- "",
231
- "### Handling Sub-LLM Failures",
232
- "Sub-LLM calls can fail (credit limits, rate limits, timeouts). Handle gracefully:",
233
- "",
234
- "| Failure | Action |",
235
- "|---------|--------|",
236
- "| `llm_query_batched` all fail | Reduce batch size (try 3-5 instead of 10+). If still failing, use individual `llm_query` calls. |",
237
- "| Individual `llm_query` fails | Check error message. If credit/rate-limit, wait and retry once. If still failing, read files directly with `read`/`grep`. |",
238
- "| `rlm_query` fails | Fall back to `llm_query` — it's a one-shot call that uses fewer resources. |",
239
- "| All sub-LLMs exhausted | Read key files directly. For small repos (<20 files), direct reading is fine. For large repos, prioritize the most important files. |",
217
+ "- Architecture/code review: chunk relevant files and delegate summaries or review to `llm_query_batched`.",
218
+ "- Bug investigation: use Python string/regex search over `context`; delegate matching files for analysis.",
219
+ "- If sub-LLM credits are exhausted, report partial results and stop — do not bypass REPL restrictions.",
240
220
  "",
241
221
  "Reserve your own tokens for high-level decisions: what to ask next, how to combine sub-LLM outputs, when to finalize.",
242
222
  "Delegate everything else. Do not submit a final answer before inspecting `context`.",
@@ -252,12 +232,14 @@ export function buildNativeSystemPrompt(): string {
252
232
  "",
253
233
  "ABSOLUTE RESTRICTION: Do NOT use `read`, `grep`, or `bash` to access files.",
254
234
  "All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
255
- "You may read at most 2 hub files directly (README.md, package.json) for quick orientation.",
256
235
  "If sub-LLM credits are exhausted → report the error to the user and stop.",
257
236
  "",
258
- "ABSOLUTE RESTRICTION: Do NOT use `write` or `edit` to modify files directly.",
259
- "All file modifications MUST go through apply_diff({diff}).",
260
- "The diff MUST be a complete unified diff with --- a/<path> / +++ b/<path> header and @@ hunk markers.",
237
+ "For file changes, use `edit` (modify existing) or `write` (create new) these route through",
238
+ "Pi's native tool flow, visible to all plugins with a `+/-` diff preview.",
239
+ "",
240
+ "When repl() returns a STAGED_EDITS block, apply each entry by calling `edit` verbatim:",
241
+ " edit({ path: entry.path, edits: [{ oldText: entry.oldText, newText: entry.newText }] })",
242
+ "Do not analyze or modify the parameters — relay them exactly as provided by the sub-agent.",
261
243
  "",
262
244
  nativeReplGlossary(),
263
245
  ].join("\n");
@@ -32,10 +32,6 @@ export interface ProposedEdit {
32
32
  readonly newText: string;
33
33
  }
34
34
 
35
- export interface ProposedDiffEdit {
36
- readonly diff: string;
37
- }
38
-
39
35
  /** A normal response to a request (keyed by the request `id`). */
40
36
  export interface WorkerResponse {
41
37
  readonly id: string;
@@ -47,7 +43,6 @@ export interface WorkerResponse {
47
43
  readonly final_answer?: string | null;
48
44
  readonly answer_content?: string;
49
45
  readonly edits?: readonly ProposedEdit[];
50
- readonly diffs?: readonly ProposedDiffEdit[];
51
46
  readonly raised?: boolean;
52
47
  readonly execution_time?: number;
53
48
  // user-created variable names after this exec (filters builtins/context) — Metadata(stdout) for history orientation
@@ -183,7 +178,6 @@ export interface ReplResult {
183
178
  readonly finalAnswer: string | null;
184
179
  readonly answerContent: string;
185
180
  readonly edits: readonly ProposedEdit[];
186
- readonly diffs: readonly ProposedDiffEdit[];
187
181
  readonly raised: boolean;
188
182
  readonly executionTimeMs: number;
189
183
  /** User-created variable names after this exec (builtins/context filtered out). */
@@ -189,7 +189,6 @@ export class PythonSandbox {
189
189
  finalAnswer: res.final_answer ?? null,
190
190
  answerContent: res.answer_content ?? "",
191
191
  edits: res.edits ?? [],
192
- diffs: res.diffs ?? [],
193
192
  raised: res.raised ?? false,
194
193
  executionTimeMs: Math.round((res.execution_time ?? 0) * 1000),
195
194
  varNames: res.var_names ?? [],
@@ -65,7 +65,8 @@ RESERVED = frozenset(
65
65
  "llm_query", "llm_query_batched", "rlm_query", "rlm_query_batched",
66
66
  "advance_phase",
67
67
  "ask_user_question", "todo",
68
- "SHOW_EDITS", "SHOW_DIFFS", "SHOW_VARS", "answer", "context",
68
+ "stage_edit",
69
+ "SHOW_VARS", "answer", "context",
69
70
  }
70
71
  )
71
72
 
@@ -103,6 +104,7 @@ class Worker:
103
104
  def _setup(self) -> None:
104
105
  self.ns = {"__builtins__": _SAFE_BUILTINS.copy(), "__name__": "__main__"}
105
106
  self._ctx_payloads: dict[int, Any] = {}
107
+ self._staged_edits: list[dict[str, str]] = []
106
108
  self._restore_scaffold()
107
109
 
108
110
  def _capture_answer(self, content: Any) -> None:
@@ -118,8 +120,7 @@ class Worker:
118
120
  ns["advance_phase"] = self._advance_phase
119
121
  ns["ask_user_question"] = self._ask_user_question
120
122
  ns["todo"] = self._todo
121
- ns["SHOW_EDITS"] = self._show_edits
122
- ns["SHOW_DIFFS"] = self._show_diffs
123
+ ns["stage_edit"] = self._stage_edit
123
124
  ns["SHOW_VARS"] = self._show_vars
124
125
  if not isinstance(ns.get("answer"), _AnswerDict):
125
126
  cur = ns.get("answer")
@@ -153,12 +154,6 @@ class Worker:
153
154
  avail = {k: type(self.ns[k]).__name__ for k in self._user_var_names()}
154
155
  return f"Available variables: {avail}" if avail else "No variables created yet."
155
156
 
156
- def _show_edits(self) -> str:
157
- return "No edits — edit tools are not available in this run."
158
-
159
- def _show_diffs(self) -> str:
160
- return "No diffs — edit tools are not available in this run."
161
-
162
157
  # ---- sub-LLM bridge over stdio --------------------------------------------------------
163
158
 
164
159
  def _rpc(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
@@ -263,6 +258,12 @@ class Worker:
263
258
  return f"Error: {r['error']}"
264
259
  return str(r.get("response", "ok"))
265
260
 
261
+ def _stage_edit(self, path: str, old_text: str, new_text: str) -> str:
262
+ if not isinstance(path, str) or not isinstance(old_text, str) or not isinstance(new_text, str):
263
+ return "Error: path, old_text, new_text must be strings"
264
+ self._staged_edits.append({"path": path, "oldText": old_text, "newText": new_text})
265
+ return f"Staged edit for {path} ({len(old_text)} → {len(new_text)} chars)"
266
+
266
267
  def _advance_phase(self, phase: str, summary: str | None = None) -> str:
267
268
  """Transition the root RLM pipeline to a new phase.
268
269
 
@@ -348,6 +349,7 @@ class Worker:
348
349
  stdout = out.getvalue()
349
350
  stderr = err.getvalue() + f"\n{type(e).__name__}: {e}\n" + traceback.format_exc()
350
351
  final, self._final_answer = self._final_answer, None
352
+ edits, self._staged_edits = self._staged_edits, []
351
353
  answer = self.ns.get("answer")
352
354
  answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
353
355
  return {
@@ -355,8 +357,7 @@ class Worker:
355
357
  "stderr": stderr,
356
358
  "final_answer": final,
357
359
  "answer_content": str(answer_content),
358
- "edits": [],
359
- "diffs": [],
360
+ "edits": edits,
360
361
  "raised": raised,
361
362
  "execution_time": time.perf_counter() - start,
362
363
  "var_names": self._user_var_names(),
@@ -6,6 +6,7 @@
6
6
  * accumulated into the subcalls array for tree rendering.
7
7
  */
8
8
 
9
+ import type { ProposedEdit } from "../sandbox/protocol.ts";
9
10
  import type { RlmSubcall } from "./rlm-details.ts";
10
11
 
11
12
  export interface ReplDetails {
@@ -20,4 +21,6 @@ export interface ReplDetails {
20
21
  readonly subcalls: readonly RlmSubcall[];
21
22
  /** Running totals for this repl() call (cost + tokens from sub-LLM calls). */
22
23
  readonly totals: { readonly costUsd: number; readonly tokens: number };
24
+ /** File edits staged inside the REPL for native relay through edit(). */
25
+ readonly edits?: readonly ProposedEdit[];
23
26
  }
@@ -27,7 +27,7 @@ import { checkResourceLimits } from "../core/resource-limits.ts";
27
27
  import type { RlmConfig, Sampling } from "../core/types.ts";
28
28
  import { SandboxManager } from "../sandbox/sandbox-manager.ts";
29
29
  import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
30
- import type { ReplResult } from "../sandbox/protocol.ts";
30
+ import type { ProposedEdit, ReplResult } from "../sandbox/protocol.ts";
31
31
  import { RlmEmitter } from "./rlm-events.ts";
32
32
  import { SubcallStore } from "./subcall-store.ts";
33
33
  import type { ReplDetails } from "./repl-details.ts";
@@ -47,6 +47,10 @@ export const ReplToolParams = Object.freeze(Type.Object({
47
47
  code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
48
48
  }));
49
49
 
50
+ export function surfaceReplEdits(edits: readonly ProposedEdit[], raised: boolean): readonly ProposedEdit[] | undefined {
51
+ return edits.length > 0 && !raised ? edits : undefined;
52
+ }
53
+
50
54
  // ── Mutable bridge state (handler indirection) ──
51
55
 
52
56
  /**
@@ -411,6 +415,12 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
411
415
 
412
416
  if (queuedId) emitter.emitSubcallUpdated({ id: queuedId, status: "done" });
413
417
 
418
+ const baseText = result.stdout || result.answerContent || "(no output)";
419
+ const surfacedEdits = surfaceReplEdits(result.edits, result.raised);
420
+ const editsBlock = surfacedEdits
421
+ ? `\n\nSTAGED_EDITS:\n${JSON.stringify(surfacedEdits)}`
422
+ : "";
423
+
414
424
  const details: ReplDetails = {
415
425
  status: "done",
416
426
  output: result.stdout,
@@ -418,10 +428,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
418
428
  executionTimeMs: elapsed,
419
429
  subcalls: store.getSubcalls(),
420
430
  totals: store.getTotals(),
431
+ edits: surfacedEdits,
421
432
  };
422
433
  // Final progressive update
423
434
  onUpdate?.({ content: [{ type: "text", text: result.stdout.slice(0, 500) || "(no output)" }], details });
424
- return { content: [{ type: "text", text: result.stdout || result.answerContent || "(no output)" }], details };
435
+ return { content: [{ type: "text", text: baseText + editsBlock }], details };
425
436
  } catch (e) {
426
437
  progressStatus = "error";
427
438
  const msg = errorMessage(e);
@@ -477,6 +488,9 @@ function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
477
488
  parts.push(formatCost(details.totals.costUsd));
478
489
  if (details.totals.tokens > 0) parts.push(`${formatTokens(details.totals.tokens)} tok`);
479
490
  if (details.executionTimeMs > 0) parts.push(`${details.executionTimeMs}ms`);
491
+ if (details.edits && details.edits.length > 0) {
492
+ parts.push(theme.fg("success", `${details.edits.length} staged`));
493
+ }
480
494
  const stats = parts.length > 0 ? ` ${theme.fg("dim", parts.join(" · "))}` : "";
481
495
 
482
496
  const header = `${glyph} ${theme.fg("toolTitle", theme.bold("REPL"))}${stats}`;
@@ -511,6 +525,15 @@ function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
511
525
  container.addChild(new Text(out, 0, 0));
512
526
  }
513
527
 
528
+ if (details.edits && details.edits.length > 0) {
529
+ const editFiles = new Set<string>();
530
+ for (const edit of details.edits) editFiles.add(edit.path);
531
+ container.addChild(new Spacer(1));
532
+ container.addChild(new Text(theme.fg("success",
533
+ `${details.edits.length} edit${details.edits.length > 1 ? "s" : ""} staged across ${editFiles.size} file${editFiles.size > 1 ? "s" : ""}`,
534
+ ), 0, 0));
535
+ }
536
+
514
537
  // Stderr
515
538
  if (details.stderr) {
516
539
  container.addChild(new Spacer(1));
@@ -2,8 +2,8 @@
2
2
  * RlmEmitter — typed EventEmitter wrapper for RLM lifecycle events.
3
3
  *
4
4
  * Replaces RlmToolBridge mutation methods. The engine and bridges emit typed
5
- * events instead of calling bridge.addSubcall/updateSubcall/etc. Listeners
6
- * (RlmEventAggregator, TelemetrySink) subscribe to build derived state.
5
+ * events instead of calling bridge.addSubcall/updateSubcall/etc. The
6
+ * RlmEventAggregator subscribes to build derived state.
7
7
  *
8
8
  * Node.js EventEmitter is synchronous — listeners run in registration order
9
9
  * during emit. No backpressure needed: engine events are sequential, one at
@@ -11,7 +11,6 @@
11
11
  */
12
12
 
13
13
  import { EventEmitter } from "node:events";
14
- import type { TelemetrySink } from "../telemetry/sink.ts";
15
14
  import type { SubcallKind, SubcallStatus, RlmRunStatus } from "./rlm-details.ts";
16
15
  import type { ProposedEdit } from "../sandbox/protocol.ts";
17
16
 
@@ -76,7 +75,6 @@ export interface RootPromptEvent {
76
75
  *
77
76
  * Auto-generates monotonic subcall IDs (`s1`, `s2`, …) via `emitSubcallCreated()`.
78
77
  * Provides typed `on*` methods that return unsubscribe functions.
79
- * `attachSink()` wires all events to a TelemetrySink and returns a detach function.
80
78
  */
81
79
  export class RlmEmitter {
82
80
  private readonly ee = new EventEmitter();
@@ -169,43 +167,6 @@ export class RlmEmitter {
169
167
  return () => { this.ee.off("root-prompt", handler); };
170
168
  }
171
169
 
172
- // ── Sink integration ──
173
-
174
- /**
175
- * Wire all lifecycle events to a TelemetrySink.
176
- * Returns a detach function that unsubscribes all sink listeners.
177
- * The caller is responsible for calling `sink.shutdown()` after detaching.
178
- */
179
- attachSink(sink: TelemetrySink): () => void {
180
- const unsubs: (() => void)[] = [];
181
-
182
- unsubs.push(this.onSubcallCreated((event) => {
183
- sink.start(event.id, {
184
- kind: event.kind,
185
- depth: event.depth,
186
- parentId: event.parentId,
187
- model: event.model,
188
- label: event.label,
189
- detail: event.detail,
190
- args: event.args,
191
- });
192
- }));
193
-
194
- unsubs.push(this.onSubcallUpdated((event) => {
195
- if (event.costUsd !== undefined || event.tokens !== undefined) {
196
- sink.usage(event.id, event.costUsd ?? 0, event.tokens ?? 0);
197
- }
198
- if (event.status !== undefined && event.status !== "running") {
199
- sink.end(event.id, {
200
- error: event.status === "error" ? (event.detail ?? "error") : undefined,
201
- resultPreview: event.resultPreview,
202
- });
203
- }
204
- }));
205
-
206
- return () => { unsubs.forEach((fn) => fn()); };
207
- }
208
-
209
170
  // ── Lifecycle ──
210
171
 
211
172
  /** Remove all listeners. Call after the run completes to prevent leaks. */
@@ -11,7 +11,6 @@ import { Container, Markdown, Spacer, Text, type Component } from "@earendil-wor
11
11
  import { Type } from "typebox";
12
12
  import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
13
13
  import type { RlmController, StartInput } from "../mode/rlm-mode.ts";
14
- import { createTelemetrySink } from "../telemetry/index.ts";
15
14
  import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
16
15
  import { errorMessage } from "../util/errors.ts";
17
16
  import { type RlmDetails } from "./rlm-details.ts";
@@ -23,8 +22,6 @@ import {
23
22
  renderExpandedSubcallTree,
24
23
  } from "./subcall-render.ts";
25
24
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
26
- import { applyEdits } from "../patch/index.ts";
27
- import { tryExtractDiff } from "../core/answer.ts";
28
25
 
29
26
  // ── Parameter schema ──
30
27
 
@@ -63,11 +60,8 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
63
60
  if (!validation.ok) return validation.error;
64
61
  const params = validation.value;
65
62
 
66
- const sink = await createTelemetrySink(controller.config.telemetry);
67
63
  const emitter = new RlmEmitter();
68
64
  const aggregator = new RlmEventAggregator(emitter, onUpdate ?? (() => {}));
69
- let detachSink: (() => void) | undefined;
70
- if (sink) detachSink = emitter.attachSink(sink);
71
65
  emitter.emitRootPrompt(params.prompt);
72
66
 
73
67
  // Wire abort signal to controller
@@ -98,10 +92,6 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
98
92
  const result = await done;
99
93
 
100
94
  emitter.emitAnswer(result.answer);
101
- const proposedEdits = result.edits ?? [];
102
- const proposedDiffs = result.diffs?.length ? result.diffs : tryExtractDiff(result.answer);
103
- if (proposedEdits.length > 0) emitter.emitEdits(proposedEdits);
104
- await applyEdits(proposedEdits, proposedDiffs, ctx);
105
95
 
106
96
  return {
107
97
  content: [{ type: "text", text: result.answer }],
@@ -116,11 +106,8 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
116
106
  };
117
107
  } finally {
118
108
  progress.stop();
119
- detachSink?.();
120
109
  aggregator.dispose();
121
110
  emitter.shutdown();
122
- try { await sink?.shutdown(); }
123
- catch (err) { console.warn(`[rlm] telemetry shutdown failed: ${errorMessage(err)}`); }
124
111
  }
125
112
  },
126
113
 
@@ -14,12 +14,19 @@ export interface ModelSelection {
14
14
  const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
15
15
  type SelectableThinkingLevel = (typeof LEVELS)[number];
16
16
 
17
- function items(models: Model<Api>[]): SelectItem[] {
18
- return models.map((m) => ({
17
+ const CHEAPEST_VALUE = "__rlm_cheapest__";
18
+
19
+ function items(models: Model<Api>[], includeCheapest = false): SelectItem[] {
20
+ const modelItems = models.map((m) => ({
19
21
  value: `${m.provider}/${m.id}`,
20
22
  label: `${m.provider}/${m.id}`,
21
23
  description: `in ${formatCost(m.cost.input)}/Mtok · out ${formatCost(m.cost.output)}/Mtok${m.reasoning ? " · reasoning" : ""}`,
22
24
  }));
25
+ if (!includeCheapest) return modelItems;
26
+ return [
27
+ { value: CHEAPEST_VALUE, label: "⟳ cheapest (auto)", description: "Always use the cheapest available model" },
28
+ ...modelItems,
29
+ ];
23
30
  }
24
31
 
25
32
  function supportedThinkingLevels(model: Model<Api>): SelectableThinkingLevel[] {
@@ -75,7 +82,7 @@ export async function selectModel(
75
82
  models: Model<Api>[],
76
83
  current?: Model<Api>,
77
84
  currentThinking?: ThinkingLevel,
78
- ): Promise<ModelSelection | undefined> {
85
+ ): Promise<ModelSelection | null | undefined> {
79
86
  if (models.length === 0) {
80
87
  ctx.ui.notify("RLM: no models with configured auth", "warning");
81
88
  return undefined;
@@ -96,7 +103,7 @@ export async function selectModel(
96
103
  render: (w) => [truncateToWidth(theme.fg("dim", `Filter: ${query || "type to filter…"}`), w)],
97
104
  invalidate: () => {},
98
105
  };
99
- const list = new SelectList(items(models), Math.min(models.length, 12), {
106
+ const list = new SelectList(items(models, true), Math.min(models.length + 1, 13), {
100
107
  selectedPrefix: (t) => theme.fg("accent", t),
101
108
  selectedText: (t) => theme.fg("accent", t),
102
109
  description: (t) => theme.fg("muted", t),
@@ -133,6 +140,7 @@ export async function selectModel(
133
140
  };
134
141
  });
135
142
 
143
+ if (chosen === CHEAPEST_VALUE) return null;
136
144
  const model = chosen ? models.find((m) => `${m.provider}/${m.id}` === chosen) : undefined;
137
145
  if (!model) return undefined;
138
146
  return { model, thinkingLevel: await selectThinkingLevel(ctx, model, currentThinking) };
@@ -1,148 +0,0 @@
1
- /**
2
- * Applies RLM-proposed edits to disk.
3
- *
4
- * Two edit kinds from the sandbox protocol:
5
- * ProposedEdit — oldText / newText anchor replacement
6
- * ProposedDiffEdit — unified diff string (applied via `diff.applyPatch`)
7
- *
8
- * Returns a Result. Caller decides whether to show errors in UI.
9
- */
10
-
11
- import { readFile, writeFile } from "node:fs/promises";
12
- import { resolve } from "node:path";
13
- import * as Diff from "diff";
14
- import type { ProposedDiffEdit, ProposedEdit } from "../sandbox/protocol.ts";
15
- import { err, ok, type Result } from "../util/errors.ts";
16
-
17
- // ── Shared private helpers ─────────────────────────────────────────────────
18
-
19
- /** Normalise to LF so string-replace is CRLF-safe. */
20
- function toLF(s: string): string {
21
- return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
22
- }
23
-
24
- /** Restore original line endings after replacement. */
25
- function restoreEndings(s: string, crlf: boolean): string {
26
- return crlf ? s.replace(/\n/g, "\r\n") : s;
27
- }
28
-
29
- function hasCRLF(s: string): boolean {
30
- return s.includes("\r\n");
31
- }
32
-
33
- // ── ApplyResult ─────────────────────────────────────────────────────────────
34
-
35
- export interface ApplySuccess {
36
- readonly applied: number;
37
- }
38
-
39
- export interface ApplyFailure {
40
- readonly failures: ReadonlyArray<{ readonly path: string; readonly reason: string }>;
41
- }
42
-
43
- export type ApplyResult = Result<ApplySuccess, ApplyFailure>;
44
-
45
- // ── Generic accumulator (shared by anchor + diff apply) ─────────────────────
46
-
47
- /**
48
- * Runs `applyOne` for every item, tallying successes and collecting failures.
49
- * The only thing that differs between anchor and diff apply is the per-item
50
- * helper and the failure-path key — both passed in, so the loop is written once.
51
- */
52
- async function applyAll<T>(
53
- items: readonly T[],
54
- applyOne: (item: T, cwd: string) => Promise<Result<void, string>>,
55
- getKey: (item: T) => string,
56
- cwd: string,
57
- ): Promise<ApplyResult> {
58
- const failures: Array<{ readonly path: string; readonly reason: string }> = [];
59
- let applied = 0;
60
- for (const item of items) {
61
- const r = await applyOne(item, cwd);
62
- if (r.ok) {
63
- applied++;
64
- } else {
65
- failures.push({ path: getKey(item), reason: r.error });
66
- }
67
- }
68
- return failures.length === 0 ? ok({ applied }) : err({ failures });
69
- }
70
-
71
- // ── ProposedEdit (oldText / newText) ────────────────────────────────────────
72
-
73
- async function applySingleAnchor(
74
- edit: ProposedEdit,
75
- cwd: string,
76
- ): Promise<Result<void, string>> {
77
- const abs = resolve(cwd, edit.path);
78
- let raw: string;
79
- try {
80
- raw = await readFile(abs, "utf8");
81
- } catch (e) {
82
- return err(`read error: ${e instanceof Error ? e.message : String(e)}`);
83
- }
84
- const crlf = hasCRLF(raw);
85
- const content = toLF(raw);
86
- const needle = toLF(edit.oldText);
87
- if (!content.includes(needle)) {
88
- return err(`oldText not found in ${edit.path}`);
89
- }
90
- const replaced = content.replace(needle, toLF(edit.newText));
91
- try {
92
- await writeFile(abs, restoreEndings(replaced, crlf), "utf8");
93
- return ok(undefined);
94
- } catch (e) {
95
- return err(`write error: ${e instanceof Error ? e.message : String(e)}`);
96
- }
97
- }
98
-
99
- export function applyAnchorEdits(
100
- edits: readonly ProposedEdit[],
101
- cwd: string,
102
- ): Promise<ApplyResult> {
103
- return applyAll(edits, applySingleAnchor, (e) => e.path, cwd);
104
- }
105
-
106
- // ── ProposedDiffEdit (unified diff string) ──────────────────────────────────
107
-
108
- async function applySingleDiff(
109
- diffEdit: ProposedDiffEdit,
110
- cwd: string,
111
- ): Promise<Result<void, string>> {
112
- // Extract file path from diff header: "--- a/path" or "--- path"
113
- const match = /^--- (?:a\/)?(.+)$/m.exec(diffEdit.diff);
114
- const relPath = match?.[1]?.trim();
115
- if (relPath === undefined) {
116
- return err("diff has no '---' header; cannot determine target file");
117
- }
118
- const abs = resolve(cwd, relPath);
119
- let raw: string;
120
- try {
121
- raw = await readFile(abs, "utf8");
122
- } catch (e) {
123
- return err(`read error: ${e instanceof Error ? e.message : String(e)}`);
124
- }
125
- const crlf = hasCRLF(raw);
126
- let patched: string | false;
127
- try {
128
- patched = Diff.applyPatch(toLF(raw), diffEdit.diff);
129
- } catch (e) {
130
- return err(`invalid diff — ${e instanceof Error ? e.message : String(e)}`);
131
- }
132
- if (patched === false) {
133
- return err(`patch does not apply cleanly to ${relPath}`);
134
- }
135
- try {
136
- await writeFile(abs, restoreEndings(patched, crlf), "utf8");
137
- return ok(undefined);
138
- } catch (e) {
139
- return err(`write error: ${e instanceof Error ? e.message : String(e)}`);
140
- }
141
- }
142
-
143
- export function applyDiffEdits(
144
- diffs: readonly ProposedDiffEdit[],
145
- cwd: string,
146
- ): Promise<ApplyResult> {
147
- return applyAll(diffs, applySingleDiff, (d) => d.diff.slice(0, 40), cwd);
148
- }
@@ -1,37 +0,0 @@
1
- /**
2
- * applyEdits — THE single call site for applying proposed edits/diffs.
3
- *
4
- * Both rlm-tool.ts and rlm.ts must call this and nothing else.
5
- * No duplication allowed.
6
- */
7
-
8
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import type { ProposedDiffEdit, ProposedEdit } from "../sandbox/protocol.ts";
10
- import { applyAnchorEdits, applyDiffEdits, type ApplyResult } from "./apply.ts";
11
-
12
- /** Surface an apply outcome through a single notify — used for both edit kinds. */
13
- function notifyApplyResult(r: ApplyResult, label: string, ctx: ExtensionContext): void {
14
- if (!r.ok) {
15
- const lines = r.error.failures.map((f) => `• ${f.path}: ${f.reason}`);
16
- ctx.ui.notify(`Some ${label}s failed:\n${lines.join("\n")}`, "error");
17
- } else {
18
- ctx.ui.notify(`Applied ${r.value.applied} ${label}${r.value.applied !== 1 ? "s" : ""}.`, "info");
19
- }
20
- }
21
-
22
- export async function applyEdits(
23
- edits: readonly ProposedEdit[],
24
- diffs: readonly ProposedDiffEdit[],
25
- ctx: ExtensionContext,
26
- ): Promise<void> {
27
- const hasEdits = edits.length > 0 || diffs.length > 0;
28
- if (!hasEdits) return;
29
- const cwd = ctx.cwd ?? process.cwd();
30
-
31
- if (edits.length > 0) {
32
- notifyApplyResult(await applyAnchorEdits(edits, cwd), "edit", ctx);
33
- }
34
- if (diffs.length > 0) {
35
- notifyApplyResult(await applyDiffEdits(diffs, cwd), "diff", ctx);
36
- }
37
- }
@@ -1,22 +0,0 @@
1
- /**
2
- * SubcallStart — parameter object carried forward for telemetry compatibility.
3
- *
4
- * The SubcallObserver interface, treeObserver(), observerWith(), and NOOP_OBSERVER
5
- * have been removed. The engine and bridges now call RlmToolBridge directly.
6
- */
7
-
8
- import type { SubcallKind } from "../tool/rlm-details.ts";
9
-
10
- export interface SubcallStart {
11
- readonly kind: SubcallKind;
12
- readonly depth: number;
13
- readonly parentId?: string;
14
- readonly model?: string;
15
- readonly label: string;
16
- readonly detail?: string;
17
- readonly args?: string;
18
- /** Run ID for the root node — lets MLflow correlate a resumed trace with the original. */
19
- readonly runId?: string;
20
- /** True when this is a resumed root node (not a fresh start). */
21
- readonly resume?: boolean;
22
- }