@hicaru/pi-rlm 0.1.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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +237 -0
  3. package/README.ru.md +200 -0
  4. package/README.zh-CN.md +224 -0
  5. package/package.json +54 -0
  6. package/src/bridge/fallback-todo.ts +137 -0
  7. package/src/bridge/interactive.ts +65 -0
  8. package/src/bridge/llm-query.ts +124 -0
  9. package/src/bridge/model.ts +97 -0
  10. package/src/bridge/pi-interactive.ts +86 -0
  11. package/src/bridge/rlm-query.ts +78 -0
  12. package/src/commands/rlm-config.ts +42 -0
  13. package/src/commands/rlm.ts +165 -0
  14. package/src/config/defaults.ts +38 -0
  15. package/src/config/settings.ts +185 -0
  16. package/src/context/repomix-context.ts +253 -0
  17. package/src/core/answer.ts +97 -0
  18. package/src/core/compaction.ts +64 -0
  19. package/src/core/engine.ts +408 -0
  20. package/src/core/history.ts +13 -0
  21. package/src/core/iteration.ts +45 -0
  22. package/src/core/limits.ts +90 -0
  23. package/src/core/pipeline.ts +100 -0
  24. package/src/core/resource-limits.ts +14 -0
  25. package/src/core/types.ts +131 -0
  26. package/src/index.ts +165 -0
  27. package/src/mode/input-router.ts +23 -0
  28. package/src/mode/rlm-mode.ts +149 -0
  29. package/src/patch/apply.ts +148 -0
  30. package/src/patch/index.ts +37 -0
  31. package/src/prompts/system.ts +278 -0
  32. package/src/prompts/user.ts +21 -0
  33. package/src/sandbox/protocol.ts +191 -0
  34. package/src/sandbox/sandbox-manager.ts +143 -0
  35. package/src/sandbox/sandbox.ts +362 -0
  36. package/src/sandbox/worker.py +457 -0
  37. package/src/state/events.ts +22 -0
  38. package/src/state/index.ts +23 -0
  39. package/src/state/internal.ts +46 -0
  40. package/src/state/paths.ts +42 -0
  41. package/src/state/reads.ts +96 -0
  42. package/src/state/resume.ts +154 -0
  43. package/src/state/rows.ts +117 -0
  44. package/src/state/writes.ts +56 -0
  45. package/src/telemetry/dispatcher.ts +116 -0
  46. package/src/telemetry/index.ts +14 -0
  47. package/src/telemetry/mlflow-config.ts +15 -0
  48. package/src/telemetry/mlflow-sink.ts +136 -0
  49. package/src/telemetry/mlflow.ts +99 -0
  50. package/src/telemetry/sink.ts +8 -0
  51. package/src/text/edits.ts +16 -0
  52. package/src/text/parsing.ts +35 -0
  53. package/src/text/preview.ts +18 -0
  54. package/src/text/tokens.ts +64 -0
  55. package/src/tool/apply-diff-tool.ts +125 -0
  56. package/src/tool/emitter-listener.ts +24 -0
  57. package/src/tool/repl-details.ts +23 -0
  58. package/src/tool/repl-tool.ts +528 -0
  59. package/src/tool/rlm-aggregator.ts +115 -0
  60. package/src/tool/rlm-details.ts +53 -0
  61. package/src/tool/rlm-events.ts +215 -0
  62. package/src/tool/rlm-tool.ts +199 -0
  63. package/src/tool/subcall-render.ts +129 -0
  64. package/src/tool/subcall-store.ts +90 -0
  65. package/src/tool/tool-utils.ts +73 -0
  66. package/src/ui/config-panel.ts +92 -0
  67. package/src/ui/intro.ts +23 -0
  68. package/src/ui/model-picker.ts +139 -0
  69. package/src/ui/status.ts +26 -0
  70. package/src/ui/theme.ts +47 -0
  71. package/src/util/concurrency.ts +15 -0
  72. package/src/util/errors.ts +27 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * modelComplete — a single, serverless, in-process LLM completion.
3
+ *
4
+ * This is the one place that talks to a provider. It resolves the API key from pi's
5
+ * ModelRegistry (keys live here, never in the sandbox) and calls pi-ai's `completeSimple`.
6
+ * Used both for `llm_query` (one user prompt) and for the headless RLM root (full history).
7
+ */
8
+
9
+ import { type Api, completeSimple, type Message, type Model, type ThinkingLevel, type Usage } from "@earendil-works/pi-ai";
10
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
11
+
12
+ export type Role = "system" | "user" | "assistant";
13
+ export interface ChatMsg {
14
+ readonly role: Role;
15
+ readonly content: string;
16
+ }
17
+
18
+ export interface CompleteOptions {
19
+ readonly model: Model<Api>;
20
+ readonly registry: ModelRegistry;
21
+ readonly system?: string;
22
+ readonly maxTokens?: number;
23
+ readonly temperature?: number;
24
+ readonly reasoning?: ThinkingLevel;
25
+ readonly signal?: AbortSignal;
26
+ }
27
+
28
+ export interface CompleteResult {
29
+ readonly text: string;
30
+ readonly usage: Usage;
31
+ }
32
+
33
+ /** Build a synthetic AssistantMessage (pi-ai requires the full shape for history replay). */
34
+ function assistantMessage(text: string, model: Model<Api>): Message {
35
+ return {
36
+ role: "assistant",
37
+ content: [{ type: "text", text }],
38
+ api: model.api,
39
+ provider: model.provider,
40
+ model: model.id,
41
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
42
+ stopReason: "stop",
43
+ timestamp: Date.now(),
44
+ };
45
+ }
46
+
47
+ function toPiMessages(messages: readonly ChatMsg[], model: Model<Api>): { readonly systemPrompt?: string; readonly messages: Message[] } {
48
+ let systemPrompt: string | undefined;
49
+ const out: Message[] = [];
50
+ for (const m of messages) {
51
+ if (m.role === "system") {
52
+ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${m.content}` : m.content;
53
+ } else if (m.role === "user") {
54
+ out.push({ role: "user", content: m.content, timestamp: Date.now() });
55
+ } else {
56
+ out.push(assistantMessage(m.content, model));
57
+ }
58
+ }
59
+ return { systemPrompt, messages: out };
60
+ }
61
+
62
+ /** Extract the assistant's plain text from a completion. */
63
+ function extractText(content: readonly { readonly type: string; readonly text?: string }[]): string {
64
+ return content
65
+ .filter((c) => c.type === "text" && typeof c.text === "string")
66
+ .map((c) => c.text)
67
+ .join("");
68
+ }
69
+
70
+ export async function modelComplete(messages: readonly ChatMsg[], opts: CompleteOptions): Promise<CompleteResult> {
71
+ const auth = await opts.registry.getApiKeyAndHeaders(opts.model);
72
+ if (!auth.ok) throw new Error(`auth for ${opts.model.provider}/${opts.model.id}: ${auth.error}`);
73
+
74
+ const built = toPiMessages(messages, opts.model);
75
+ const systemPrompt = opts.system
76
+ ? built.systemPrompt
77
+ ? `${opts.system}\n\n${built.systemPrompt}`
78
+ : opts.system
79
+ : built.systemPrompt;
80
+
81
+ const msg = await completeSimple(
82
+ opts.model,
83
+ { systemPrompt, messages: built.messages },
84
+ {
85
+ apiKey: auth.apiKey,
86
+ headers: auth.headers,
87
+ maxTokens: opts.maxTokens,
88
+ temperature: opts.temperature,
89
+ reasoning: opts.reasoning,
90
+ signal: opts.signal,
91
+ },
92
+ );
93
+ if (msg.stopReason === "error" || msg.stopReason === "aborted") {
94
+ throw new Error(msg.errorMessage ?? msg.stopReason);
95
+ }
96
+ return { text: extractText(msg.content), usage: msg.usage };
97
+ }
@@ -0,0 +1,86 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { InteractiveDeps } from "../core/types.ts";
3
+ import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
4
+ import { formatError } from "../util/errors.ts";
5
+ import { createTodoFallback } from "./fallback-todo.ts";
6
+
7
+ interface ToolInvoker {
8
+ readonly callTool?: (name: string, params: unknown) => Promise<unknown>;
9
+ }
10
+
11
+ function hasAnswers(value: unknown): value is { readonly answers: readonly unknown[] } {
12
+ return typeof value === "object" && value !== null && Array.isArray((value as { readonly answers?: unknown }).answers);
13
+ }
14
+
15
+ function isAskAnswer(value: unknown): value is AskAnswer {
16
+ if (typeof value !== "object" || value === null) return false;
17
+ const candidate = value as { readonly question?: unknown; readonly selected?: unknown; readonly custom?: unknown };
18
+ return typeof candidate.question === "string"
19
+ && Array.isArray(candidate.selected)
20
+ && candidate.selected.every((item) => typeof item === "string")
21
+ && (candidate.custom === undefined || typeof candidate.custom === "string");
22
+ }
23
+
24
+ function normalizeAnswers(result: unknown): AskAnswer[] | undefined {
25
+ if (!hasAnswers(result)) return undefined;
26
+ const answers = result.answers;
27
+ return answers.every(isAskAnswer) ? Array.from(answers) : undefined;
28
+ }
29
+
30
+ async function askViaUi(ctx: ExtensionContext, questions: readonly AskQuestion[]): Promise<AskAnswer[]> {
31
+ if (!ctx.hasUI) throw new Error("ask_user_question requires UI");
32
+ const answers = new Array<AskAnswer>(questions.length);
33
+ for (let i = 0; i < questions.length; i++) {
34
+ const q = questions[i];
35
+ if (!q) {
36
+ answers[i] = { question: "", selected: [], custom: formatError("malformed question") };
37
+ continue;
38
+ }
39
+ if (q.multiSelect) {
40
+ const selected: string[] = [];
41
+ while (true) {
42
+ const pick = await ctx.ui.select(`${q.header}: ${q.question}`, [...q.options.map((o) => o.label), "Done"]);
43
+ if (!pick || pick === "Done") break;
44
+ if (!selected.includes(pick)) selected.push(pick);
45
+ }
46
+ answers[i] = { question: q.question, selected };
47
+ continue;
48
+ }
49
+ const pick = await ctx.ui.select(`${q.header}: ${q.question}`, [...q.options.map((o) => o.label), "Type something."]);
50
+ if (!pick) answers[i] = { question: q.question, selected: [], custom: formatError("user cancelled") };
51
+ else if (pick === "Type something.") answers[i] = { question: q.question, selected: [], custom: await ctx.ui.input(q.question) ?? "" };
52
+ else answers[i] = { question: q.question, selected: [pick] };
53
+ }
54
+ return answers;
55
+ }
56
+
57
+ export function createPiInteractiveDeps(ctx: ExtensionContext): InteractiveDeps {
58
+ const fallbackTodo = createTodoFallback();
59
+ return Object.freeze({
60
+ onAskUserQuestion: async (questions: readonly AskQuestion[]): Promise<AskAnswer[]> => {
61
+ const callTool = (ctx as unknown as ToolInvoker).callTool;
62
+ if (typeof callTool === "function") {
63
+ try {
64
+ const result = await callTool.call(ctx, "ask_user_question", { questions });
65
+ const answers = normalizeAnswers(result);
66
+ if (answers) return answers;
67
+ } catch {
68
+ // Fall through to native UI fallback when the extension tool is not registered or fails.
69
+ }
70
+ }
71
+ return askViaUi(ctx, questions);
72
+ },
73
+ onTodo: async (action: string, params: Record<string, unknown>): Promise<string> => {
74
+ const callTool = (ctx as unknown as ToolInvoker).callTool;
75
+ if (typeof callTool === "function") {
76
+ try {
77
+ const result = await callTool.call(ctx, "todo", { action, ...params });
78
+ return typeof result === "string" ? result : JSON.stringify(result);
79
+ } catch {
80
+ // Fall through to in-process task store when the extension tool is not registered or fails.
81
+ }
82
+ }
83
+ return fallbackTodo(action, params);
84
+ },
85
+ });
86
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The `rlm_query` recursion bridge.
3
+ *
4
+ * A child RLM gets its own sandbox and iterates over the prompt as its context. At/over the
5
+ * depth cap it degrades to a plain `llm_query` (ported from rlm/core/rlm.py `_subcall`). The
6
+ * concurrency pool bounds parallel children for `rlm_query_batched`.
7
+ */
8
+
9
+ import type { RunRlm } from "../core/types.ts";
10
+ import type { LlmBridge } from "./llm-query.ts";
11
+ import type { RlmEmitter } from "../tool/rlm-events.ts";
12
+ import { checkResourceLimits } from "../core/resource-limits.ts";
13
+ import { formatError } from "../util/errors.ts";
14
+ import { mapPool } from "../util/concurrency.ts";
15
+
16
+ export interface RlmHandlers {
17
+ rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
18
+ rlmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
19
+ }
20
+
21
+ export interface RlmBridgeOptions {
22
+ readonly run: RunRlm;
23
+ readonly llm: LlmBridge;
24
+ /** Live RlmDetails reporting via onUpdate. Required — replaces SubcallObserver for recursive subcalls. */
25
+ readonly emitter: RlmEmitter;
26
+ readonly maxDepth: number;
27
+ readonly maxConcurrent: number;
28
+ /** Parent subcall ID that this run is attached under. */
29
+ readonly parentNodeId?: string;
30
+ /** Returns the parent's remaining budget/timeout for seeding child runs. */
31
+ readonly remainingBudget?: () => { readonly budgetUsd?: number; readonly timeoutMs?: number };
32
+ /** Called with a child run's total cost/tokens so the parent LimitGuard debits it. */
33
+ readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
34
+ }
35
+
36
+ export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
37
+ async function child(prompt: string, model: string | null, depth: number): Promise<string> {
38
+ const childDepth = depth + 1;
39
+ // At the cap, a child RLM would just be an LM — short-circuit to a one-shot llm_query.
40
+ if (childDepth >= opts.maxDepth) return opts.llm.llmQuery(prompt, model, depth);
41
+ let subId: string | undefined;
42
+ try {
43
+ const rem = opts.remainingBudget?.() ?? {};
44
+ // Pre-spawn guard: refuse if the parent's budget or timeout is already exhausted
45
+ // (reference: _subcall checks remaining_budget/timeout before spawning).
46
+ const limitError = checkResourceLimits(rem);
47
+ if (limitError) return limitError;
48
+ subId = opts.emitter.emitSubcallCreated({
49
+ kind: "rlm", parentId: opts.parentNodeId, label: "rlm_query",
50
+ model: model ?? undefined, detail: prompt.slice(0, 60),
51
+ depth: childDepth,
52
+ });
53
+ const res = await opts.run({
54
+ rootPrompt: "",
55
+ context: prompt,
56
+ depth: childDepth,
57
+ parentNodeId: subId,
58
+ modelOverride: model ?? undefined,
59
+ remainingBudgetUsd: rem.budgetUsd,
60
+ remainingTimeoutMs: rem.timeoutMs,
61
+ });
62
+ opts.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
63
+ opts.emitter.emitSubcallUpdated({ id: subId,
64
+ status: "done", resultPreview: res.answer.slice(0, 200),
65
+ });
66
+ return res.answer;
67
+ } catch (err) {
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ if (subId) opts.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
70
+ return formatError(`child RLM failed - ${msg}`);
71
+ }
72
+ }
73
+
74
+ return {
75
+ rlmQuery: (prompt, model, depth) => child(prompt, model, depth),
76
+ rlmQueryBatched: (prompts, model, depth) => mapPool(prompts, opts.maxConcurrent, (p) => child(p, model, depth)),
77
+ };
78
+ }
@@ -0,0 +1,42 @@
1
+ /** `/rlm-config` — choose worker model, reasoning level, and run settings (smart is always pi's active model). */
2
+
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { modelRef } from "../config/settings.ts";
5
+ import { cheapestModel, type RlmController } from "../mode/rlm-mode.ts";
6
+ import { setRlmModeStatus } from "../ui/status.ts";
7
+ import { showConfigPanel } from "../ui/config-panel.ts";
8
+ import { selectModel } from "../ui/model-picker.ts";
9
+
10
+ export async function runRlmConfig(controller: RlmController, ctx: ExtensionContext): Promise<boolean> {
11
+ const models = ctx.modelRegistry.getAvailable();
12
+
13
+ const worker = await selectModel(ctx, "Worker model (sub-LLM / llm_query)", models, controller.workerModel, controller.config.subSampling.reasoning);
14
+ if (worker) {
15
+ controller.workerModel = worker.model;
16
+ controller.config.subSampling.reasoning = worker.thinkingLevel;
17
+ }
18
+
19
+ await showConfigPanel(ctx, controller.config);
20
+
21
+ const effectiveWorker = controller.workerModel ?? cheapestModel(ctx.modelRegistry);
22
+ controller.savedWorkerRef = modelRef(controller.workerModel) ?? modelRef(effectiveWorker);
23
+ const persisted = await controller.persist();
24
+ if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
25
+ setRlmModeStatus(ctx.ui, controller);
26
+
27
+ const w = controller.workerModel;
28
+ ctx.ui.notify(
29
+ `RLM: worker=${w ? `${w.provider}/${w.id}` : "(cheapest)"}${controller.config.subSampling.reasoning ? `/${controller.config.subSampling.reasoning}` : ""}`,
30
+ "info",
31
+ );
32
+ return worker !== undefined;
33
+ }
34
+
35
+ export function registerRlmConfigCommand(pi: ExtensionAPI, controller: RlmController): void {
36
+ pi.registerCommand("rlm-config", {
37
+ description: "Configure RLM worker model and run settings.",
38
+ handler: async (_args, ctx) => {
39
+ await runRlmConfig(controller, ctx);
40
+ },
41
+ });
42
+ }
@@ -0,0 +1,165 @@
1
+ /** `/rlm` — toggle persistent Recursive Language Model mode. */
2
+
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
5
+ import type { RlmController, RunHandle } from "../mode/rlm-mode.ts";
6
+ import { postRlmGuide } from "../ui/intro.ts";
7
+ import { clearRlmStatus, setRlmModeStatus } from "../ui/status.ts";
8
+ import { listRunIds, readContextSidecar, readHeader, resolveRunId } from "../state/index.ts";
9
+ import { DEFAULT_RUN_DIR } from "../config/defaults.ts";
10
+ import { reconstructRlmState } from "../state/resume.ts";
11
+ import type { ReconstructResult } from "../state/resume.ts";
12
+ import type { RunHeader } from "../state/rows.ts";
13
+ import { buildRlmSystemPrompt } from "../prompts/system.ts";
14
+ import { RlmEmitter } from "../tool/rlm-events.ts";
15
+ import { RlmEventAggregator } from "../tool/rlm-aggregator.ts";
16
+ import { createTelemetrySink } from "../telemetry/index.ts";
17
+ import { applyEdits } from "../patch/index.ts";
18
+ import { tryExtractDiff } from "../core/answer.ts";
19
+
20
+ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController): void {
21
+ pi.registerCommand("rlm", {
22
+ description: "Toggle persistent RLM mode (route plain prompts through the RLM engine).",
23
+ handler: async (_args, ctx) => {
24
+ const enabled = controller.toggle();
25
+ setRlmModeStatus(ctx.ui, controller);
26
+ ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
27
+ },
28
+ });
29
+
30
+ pi.registerCommand("rlm-stop", {
31
+ description: "Abort the in-progress RLM run.",
32
+ handler: async (_args, ctx) => {
33
+ if (!controller.isBusy()) {
34
+ ctx.ui.notify("No RLM run in progress.", "info");
35
+ return;
36
+ }
37
+ controller.abort();
38
+ ctx.ui.notify("RLM run aborted.", "info");
39
+ },
40
+ });
41
+
42
+ pi.registerCommand("rlm-help", {
43
+ description: "Show the RLM startup guide and command cheatsheet.",
44
+ handler: async () => {
45
+ postRlmGuide(pi, controller);
46
+ },
47
+ });
48
+
49
+ pi.registerCommand("rlm-resume", {
50
+ description: "Resume an interrupted RLM run (default @latest).",
51
+ handler: async (args, ctx) => {
52
+ if (controller.isBusy()) {
53
+ ctx.ui.notify("RLM is busy (use /rlm-stop to cancel).", "warning");
54
+ return;
55
+ }
56
+ const ref = args.trim() || "@latest";
57
+ const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
58
+ const cwd = ctx.cwd ?? process.cwd();
59
+ const runId = await resolveRunId(cwd, dir, ref);
60
+ if (!runId) { ctx.ui.notify(`No resumable RLM run for '${ref}'.`, "error"); return; }
61
+ const header = await readHeader(cwd, dir, runId);
62
+ if (!header) { ctx.ui.notify(`Run ${runId} has no header.`, "error"); return; }
63
+ const systemPrompt = buildRlmSystemPrompt(
64
+ { contextType: header.context.type, contextChars: header.context.chars, rootPrompt: header.rootPrompt },
65
+ {
66
+ orchestrator: header.meta.orchestrator,
67
+ recursion: 1 < header.meta.maxDepth,
68
+ askUserQuestion: controller.config.askUserQuestion,
69
+ todo: controller.config.todo,
70
+ },
71
+ );
72
+ let recon: ReconstructResult;
73
+ try { recon = await reconstructRlmState(cwd, dir, runId, systemPrompt); }
74
+ catch (e) {
75
+ ctx.ui.notify(`RLM resume failed: corrupt run state — ${e instanceof Error ? e.message : String(e)}`, "error");
76
+ return;
77
+ }
78
+ if (!recon.ok) { ctx.ui.notify(`Cannot resume ${runId}: ${recon.reason}.`, "error"); return; }
79
+ if (recon.terminated) { ctx.ui.notify(`Run ${runId} already finished.`, "info"); return; }
80
+ const context = await readContextSidecar(cwd, dir, runId, header.context.json);
81
+ if (context === undefined) // R-C2: warn instead of silently resuming on empty context
82
+ ctx.ui.notify(`Warning: context sidecar missing for ${runId} — resuming without original context.`, "warning");
83
+ await executeRlmRunWithResume(pi, controller, ctx, recon, header, context ?? "");
84
+ },
85
+ });
86
+
87
+ pi.registerCommand("rlm-runs", {
88
+ description: "List recent RLM runs.",
89
+ handler: async (_args, ctx) => {
90
+ const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
91
+ const ids = (await listRunIds(ctx.cwd ?? process.cwd(), dir)).slice(0, 20);
92
+ ctx.ui.notify(ids.length ? ids.join("\n") : "No RLM runs recorded.", "info");
93
+ },
94
+ });
95
+
96
+ pi.registerShortcut?.("ctrl+shift+r", {
97
+ description: "Toggle RLM mode (off also stops a running query)",
98
+ handler: async (ctx) => {
99
+ const enabled = controller.toggle();
100
+ setRlmModeStatus(ctx.ui, controller);
101
+ ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
102
+ },
103
+ });
104
+ }
105
+
106
+ async function executeRlmRunWithResume(
107
+ pi: ExtensionAPI,
108
+ controller: RlmController,
109
+ ctx: ExtensionContext,
110
+ recon: ReconstructResult & { ok: true },
111
+ header: RunHeader,
112
+ context: unknown,
113
+ ): Promise<void> {
114
+ let handle: RunHandle | undefined;
115
+ let sink: Awaited<ReturnType<typeof createTelemetrySink>>;
116
+ let emitter: RlmEmitter | undefined;
117
+ let detachSink: (() => void) | undefined;
118
+ let aggregator: RlmEventAggregator | undefined;
119
+ try {
120
+ sink = await createTelemetrySink(controller.config.telemetry);
121
+ emitter = new RlmEmitter();
122
+ if (sink) detachSink = emitter.attachSink(sink);
123
+ aggregator = new RlmEventAggregator(emitter, (partial) => {
124
+ const d = partial.details;
125
+ if (!d) return;
126
+ const turn = d.turns.max > 0 ? ` · turn ${d.turns.current}/${d.turns.max}` : "";
127
+ const cost = d.totals.costUsd > 0 ? ` · $${d.totals.costUsd.toFixed(4)}` : "";
128
+ const glyph = d.status === "running" ? "⏳" : d.status === "done" ? "✓" : "✗";
129
+ ctx.ui.setWidget?.("rlm-status", [`${glyph} RLM resume${turn}${cost}`], { placement: "aboveEditor" });
130
+ });
131
+ emitter.emitRootPrompt(header.rootPrompt);
132
+ const interactive = createPiInteractiveDeps(ctx);
133
+ if (controller.config.todo) {
134
+ for (const row of recon.todoRows) await interactive.onTodo?.(row.action, row.params);
135
+ }
136
+ handle = controller.start(ctx, { kind: "resume", resume: recon, context }, emitter, {
137
+ onAskUserQuestion: controller.config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
138
+ onTodo: controller.config.todo ? interactive.onTodo : undefined,
139
+ });
140
+ } catch (e) {
141
+ ctx.ui.notify(`RLM resume failed: ${e instanceof Error ? e.message : String(e)}`, "error");
142
+ return;
143
+ }
144
+ pi.sendMessage({ customType: "rlm-question", content: `[resume] ${header.rootPrompt}`, display: true });
145
+ const { done } = handle;
146
+ try {
147
+ const result = await done;
148
+ pi.sendMessage({ customType: "rlm-answer", content: result.answer, display: true });
149
+ const proposedDiffs = result.diffs?.length ? result.diffs : tryExtractDiff(result.answer);
150
+ await applyEdits(
151
+ result.edits ?? [],
152
+ proposedDiffs,
153
+ ctx,
154
+ );
155
+ } catch (e) {
156
+ ctx.ui.notify(`RLM resume failed: ${e instanceof Error ? e.message : String(e)}`, "error");
157
+ } finally {
158
+ clearRlmStatus(ctx.ui);
159
+ ctx.ui.setWidget?.("rlm-status", undefined);
160
+ detachSink?.();
161
+ aggregator?.dispose();
162
+ emitter?.shutdown();
163
+ try { await sink?.shutdown(); } catch { /* best-effort */ }
164
+ }
165
+ }
@@ -0,0 +1,38 @@
1
+ import type { RlmConfig } from "../core/types.ts";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export const DEFAULT_RUN_DIR = join(tmpdir(), "rlm-runs");
6
+
7
+ /** Frozen default sub-LLM system prompt — avoids re-allocation on every llm_query call. */
8
+ const DEFAULT_SUB_SYSTEM_PROMPT =
9
+ "Answer directly and concisely. Return only the requested information. " +
10
+ "No preamble, no meta-commentary, no explanation of your approach. " +
11
+ "If listing items, use compact bullet form.";
12
+
13
+ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
14
+ enabled: true,
15
+ maxDepth: 4,
16
+ maxIterations: 30,
17
+ execTimeoutS: 120,
18
+ requestTimeoutMs: 10 * 60_000,
19
+ maxConcurrentSubcalls: 4,
20
+ maxPromptChars: 400_000,
21
+ maxErrors: 5,
22
+ orchestrator: true,
23
+ compaction: true,
24
+ compactionThresholdPct: 0.65,
25
+ python: "python3",
26
+ sandboxInitTimeoutMs: 30_000,
27
+ askUserQuestion: true,
28
+ todo: true,
29
+ rootSampling: Object.freeze({ maxTokens: 16_384 }),
30
+ subSystemPrompt: DEFAULT_SUB_SYSTEM_PROMPT,
31
+ subSampling: Object.freeze({ maxTokens: 8192 }),
32
+ runLog: Object.freeze({
33
+ enabled: true,
34
+ dir: DEFAULT_RUN_DIR,
35
+ snapshot: true,
36
+ maxRuns: 50,
37
+ }),
38
+ });