@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.
- package/README.md +2 -7
- package/README.ru.md +2 -4
- package/README.zh-CN.md +2 -7
- package/package.json +9 -11
- package/src/bridge/pi-interactive.ts +3 -48
- package/src/commands/rlm-config.ts +10 -3
- package/src/commands/rlm.ts +0 -15
- package/src/config/settings.ts +1 -24
- package/src/core/answer.ts +1 -17
- package/src/core/engine.ts +9 -14
- package/src/core/types.ts +1 -13
- package/src/index.ts +23 -26
- package/src/prompts/system.ts +18 -36
- package/src/sandbox/protocol.ts +0 -6
- package/src/sandbox/sandbox.ts +0 -1
- package/src/sandbox/worker.py +12 -11
- package/src/tool/repl-details.ts +3 -0
- package/src/tool/repl-tool.ts +25 -2
- package/src/tool/rlm-events.ts +2 -41
- package/src/tool/rlm-tool.ts +0 -13
- package/src/ui/model-picker.ts +12 -4
- package/src/patch/apply.ts +0 -148
- package/src/patch/index.ts +0 -37
- package/src/state/events.ts +0 -22
- package/src/telemetry/dispatcher.ts +0 -116
- package/src/telemetry/index.ts +0 -14
- package/src/telemetry/mlflow-config.ts +0 -15
- package/src/telemetry/mlflow-sink.ts +0 -136
- package/src/telemetry/mlflow.ts +0 -99
- package/src/telemetry/sink.ts +0 -8
- package/src/tool/apply-diff-tool.ts +0 -125
package/src/prompts/system.ts
CHANGED
|
@@ -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`
|
|
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
|
-
"| `
|
|
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**:
|
|
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
|
-
"
|
|
215
|
-
"
|
|
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
|
-
"
|
|
259
|
-
"
|
|
260
|
-
"
|
|
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");
|
package/src/sandbox/protocol.ts
CHANGED
|
@@ -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). */
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -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 ?? [],
|
package/src/sandbox/worker.py
CHANGED
|
@@ -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
|
-
"
|
|
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["
|
|
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(),
|
package/src/tool/repl-details.ts
CHANGED
|
@@ -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
|
}
|
package/src/tool/repl-tool.ts
CHANGED
|
@@ -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:
|
|
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));
|
package/src/tool/rlm-events.ts
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
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. */
|
package/src/tool/rlm-tool.ts
CHANGED
|
@@ -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
|
|
package/src/ui/model-picker.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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,
|
|
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) };
|
package/src/patch/apply.ts
DELETED
|
@@ -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
|
-
}
|
package/src/patch/index.ts
DELETED
|
@@ -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
|
-
}
|
package/src/state/events.ts
DELETED
|
@@ -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
|
-
}
|