@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.
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/README.ru.md +200 -0
- package/README.zh-CN.md +224 -0
- package/package.json +54 -0
- package/src/bridge/fallback-todo.ts +137 -0
- package/src/bridge/interactive.ts +65 -0
- package/src/bridge/llm-query.ts +124 -0
- package/src/bridge/model.ts +97 -0
- package/src/bridge/pi-interactive.ts +86 -0
- package/src/bridge/rlm-query.ts +78 -0
- package/src/commands/rlm-config.ts +42 -0
- package/src/commands/rlm.ts +165 -0
- package/src/config/defaults.ts +38 -0
- package/src/config/settings.ts +185 -0
- package/src/context/repomix-context.ts +253 -0
- package/src/core/answer.ts +97 -0
- package/src/core/compaction.ts +64 -0
- package/src/core/engine.ts +408 -0
- package/src/core/history.ts +13 -0
- package/src/core/iteration.ts +45 -0
- package/src/core/limits.ts +90 -0
- package/src/core/pipeline.ts +100 -0
- package/src/core/resource-limits.ts +14 -0
- package/src/core/types.ts +131 -0
- package/src/index.ts +165 -0
- package/src/mode/input-router.ts +23 -0
- package/src/mode/rlm-mode.ts +149 -0
- package/src/patch/apply.ts +148 -0
- package/src/patch/index.ts +37 -0
- package/src/prompts/system.ts +278 -0
- package/src/prompts/user.ts +21 -0
- package/src/sandbox/protocol.ts +191 -0
- package/src/sandbox/sandbox-manager.ts +143 -0
- package/src/sandbox/sandbox.ts +362 -0
- package/src/sandbox/worker.py +457 -0
- package/src/state/events.ts +22 -0
- package/src/state/index.ts +23 -0
- package/src/state/internal.ts +46 -0
- package/src/state/paths.ts +42 -0
- package/src/state/reads.ts +96 -0
- package/src/state/resume.ts +154 -0
- package/src/state/rows.ts +117 -0
- package/src/state/writes.ts +56 -0
- package/src/telemetry/dispatcher.ts +116 -0
- package/src/telemetry/index.ts +14 -0
- package/src/telemetry/mlflow-config.ts +15 -0
- package/src/telemetry/mlflow-sink.ts +136 -0
- package/src/telemetry/mlflow.ts +99 -0
- package/src/telemetry/sink.ts +8 -0
- package/src/text/edits.ts +16 -0
- package/src/text/parsing.ts +35 -0
- package/src/text/preview.ts +18 -0
- package/src/text/tokens.ts +64 -0
- package/src/tool/apply-diff-tool.ts +125 -0
- package/src/tool/emitter-listener.ts +24 -0
- package/src/tool/repl-details.ts +23 -0
- package/src/tool/repl-tool.ts +528 -0
- package/src/tool/rlm-aggregator.ts +115 -0
- package/src/tool/rlm-details.ts +53 -0
- package/src/tool/rlm-events.ts +215 -0
- package/src/tool/rlm-tool.ts +199 -0
- package/src/tool/subcall-render.ts +129 -0
- package/src/tool/subcall-store.ts +90 -0
- package/src/tool/tool-utils.ts +73 -0
- package/src/ui/config-panel.ts +92 -0
- package/src/ui/intro.ts +23 -0
- package/src/ui/model-picker.ts +139 -0
- package/src/ui/status.ts +26 -0
- package/src/ui/theme.ts +47 -0
- package/src/util/concurrency.ts +15 -0
- package/src/util/errors.ts +27 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** Shared configuration + runtime types for the RLM engine. */
|
|
2
|
+
|
|
3
|
+
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { AskAnswer, AskQuestion, ProposedDiffEdit, ProposedEdit } from "../sandbox/protocol.ts";
|
|
5
|
+
import type { ReconstructResult } from "../state/resume.ts";
|
|
6
|
+
|
|
7
|
+
export interface Sampling {
|
|
8
|
+
readonly maxTokens?: number;
|
|
9
|
+
readonly temperature?: number;
|
|
10
|
+
readonly reasoning?: ThinkingLevel;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type MutableSampling = { -readonly [Key in keyof Sampling]?: Sampling[Key] };
|
|
14
|
+
|
|
15
|
+
export interface TelemetryConfig {
|
|
16
|
+
/** Default: enabled iff a tracking URI resolves from config or MLFLOW_TRACKING_URI. */
|
|
17
|
+
readonly enabled?: boolean;
|
|
18
|
+
readonly trackingUri?: string;
|
|
19
|
+
readonly experimentId?: string;
|
|
20
|
+
/** Bearer token is env-only via MLFLOW_TRACKING_TOKEN; never persisted in rlm.json. */
|
|
21
|
+
readonly maxQueueSize?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RunLogConfig {
|
|
25
|
+
/** Default: true — always-on, opt-out. */
|
|
26
|
+
readonly enabled?: boolean;
|
|
27
|
+
/** Default: ".rlm/runs". Directory under cwd for run artifacts. */
|
|
28
|
+
readonly dir?: string;
|
|
29
|
+
/** Default: true — whether to write sandbox.pkl snapshots. */
|
|
30
|
+
readonly snapshot?: boolean;
|
|
31
|
+
/** Default: 50 — prune oldest runs beyond this count on each new run. */
|
|
32
|
+
readonly maxRuns?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RlmConfig {
|
|
36
|
+
/** Persistent editor-routing mode; when enabled, plain interactive prompts use RLM. */
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
/** Max recursion depth. depth >= maxDepth ⇒ rlm_query falls back to a plain llm_query. */
|
|
39
|
+
maxDepth: number;
|
|
40
|
+
/** Max turns before the engine must finalize. */
|
|
41
|
+
maxIterations: number;
|
|
42
|
+
/** Per-`repl`-block wall-clock timeout inside the worker (seconds). */
|
|
43
|
+
execTimeoutS: number;
|
|
44
|
+
/** Parent-side watchdog per sandbox request (ms). */
|
|
45
|
+
requestTimeoutMs: number;
|
|
46
|
+
/** Concurrency pool for *_batched sub-calls. */
|
|
47
|
+
maxConcurrentSubcalls: number;
|
|
48
|
+
/** Reject sub-LLM prompts larger than this many chars. */
|
|
49
|
+
maxPromptChars: number;
|
|
50
|
+
/** Max USD spend across the whole tree before the engine stops (undefined = no cap). */
|
|
51
|
+
maxBudgetUsd?: number;
|
|
52
|
+
/** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */
|
|
53
|
+
maxTimeoutMs?: number;
|
|
54
|
+
/** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap). */
|
|
55
|
+
maxTokens?: number;
|
|
56
|
+
/** Max consecutive error turns before the engine stops (undefined = no cap). */
|
|
57
|
+
maxErrors?: number;
|
|
58
|
+
/** Append the orchestrator addendum to the system prompt. */
|
|
59
|
+
orchestrator: boolean;
|
|
60
|
+
/** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
|
|
61
|
+
compaction: boolean;
|
|
62
|
+
/** Compact when estimated history tokens reach this fraction of the model's context window. */
|
|
63
|
+
compactionThresholdPct: number;
|
|
64
|
+
/** Python executable used to launch the sandbox worker. */
|
|
65
|
+
python: string;
|
|
66
|
+
/** Worker startup wait before treating sandbox init as failed (ms). */
|
|
67
|
+
sandboxInitTimeoutMs: number;
|
|
68
|
+
/** Allow ask_user_question() calls from the root REPL. */
|
|
69
|
+
askUserQuestion: boolean;
|
|
70
|
+
/** Allow todo() calls from the REPL. */
|
|
71
|
+
todo: boolean;
|
|
72
|
+
/** ThinkingLevel for the root smart model (set via /rlm-config). */
|
|
73
|
+
smartReasoning?: ThinkingLevel;
|
|
74
|
+
/** Output token cap + temperature for the root smart model per turn.
|
|
75
|
+
* Keeps each turn short so the next turn's input stays manageable.
|
|
76
|
+
* `reasoning` is read from `smartReasoning` if omitted here. */
|
|
77
|
+
rootSampling?: Readonly<Sampling>;
|
|
78
|
+
/** System prompt injected into every llm_query / llm_query_batched sub-call.
|
|
79
|
+
* Instructs the worker model to respond concisely.
|
|
80
|
+
* undefined = no system prompt (raw completion). */
|
|
81
|
+
subSystemPrompt?: string;
|
|
82
|
+
/** Sampling for sub-LLM (worker) calls. */
|
|
83
|
+
subSampling: MutableSampling;
|
|
84
|
+
/** Optional MLflow telemetry export configuration. Omitted by default. */
|
|
85
|
+
readonly telemetry?: TelemetryConfig;
|
|
86
|
+
/** Optional run-state persistence configuration. Enabled by default. */
|
|
87
|
+
readonly runLog?: RunLogConfig;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Input to a (headless) RLM run. */
|
|
91
|
+
export interface RlmInput {
|
|
92
|
+
/** The question for the root model (folded into the metadata prompt). */
|
|
93
|
+
readonly rootPrompt: string;
|
|
94
|
+
/** The (possibly huge) context loaded into the sandbox REPL. */
|
|
95
|
+
readonly context: unknown;
|
|
96
|
+
/** Recursion depth; 0 = top-level root. */
|
|
97
|
+
readonly depth: number;
|
|
98
|
+
/** AgentTree node to attach this run's node under (set when recursing). */
|
|
99
|
+
readonly parentNodeId?: string;
|
|
100
|
+
/** "provider/id" — overrides the root model for this run (set by recursive rlm_query). */
|
|
101
|
+
readonly modelOverride?: string;
|
|
102
|
+
/** Remaining budget for this subtree (set by parent from its LimitGuard). */
|
|
103
|
+
readonly remainingBudgetUsd?: number;
|
|
104
|
+
/** Remaining timeout for this subtree (set by parent from its LimitGuard). */
|
|
105
|
+
readonly remainingTimeoutMs?: number;
|
|
106
|
+
/** Depth-0 resume payload — controller rebuilds this from the trail's `reconstructRlmState()`. */
|
|
107
|
+
readonly resume?: ReconstructResult & { readonly ok: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Result of a completed RLM run. */
|
|
111
|
+
export interface RlmResult {
|
|
112
|
+
readonly answer: string;
|
|
113
|
+
/** Legacy anchor edits retained for compatibility while older run-state rows exist. */
|
|
114
|
+
readonly edits?: readonly ProposedEdit[];
|
|
115
|
+
readonly diffs?: readonly ProposedDiffEdit[];
|
|
116
|
+
readonly iterations: number;
|
|
117
|
+
readonly costUsd: number;
|
|
118
|
+
readonly inputTokens: number;
|
|
119
|
+
readonly outputTokens: number;
|
|
120
|
+
readonly durationMs: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A function that runs an RLM to completion — used to wire recursion (rlm_query). */
|
|
124
|
+
export interface InteractiveDeps {
|
|
125
|
+
/** Called when the sandbox issues ask_user_question; undefined = feature disabled. */
|
|
126
|
+
readonly onAskUserQuestion?: (questions: readonly AskQuestion[]) => Promise<AskAnswer[]>;
|
|
127
|
+
/** Called when the sandbox issues todo; undefined = feature disabled. */
|
|
128
|
+
readonly onTodo?: (action: string, params: Record<string, unknown>) => Promise<string>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type RunRlm = (input: RlmInput) => Promise<RlmResult>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/** pi-rlm — Recursive Language Model for Pi. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Markdown } from "@earendil-works/pi-tui";
|
|
6
|
+
import { registerRlmCommand } from "./commands/rlm.ts";
|
|
7
|
+
import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
|
|
8
|
+
import { createRlmTool } from "./tool/rlm-tool.ts";
|
|
9
|
+
import { createApplyDiffTool } from "./tool/apply-diff-tool.ts";
|
|
10
|
+
import { createReplTool } from "./tool/repl-tool.ts";
|
|
11
|
+
import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts";
|
|
12
|
+
import { RlmController, cheapestModel } from "./mode/rlm-mode.ts";
|
|
13
|
+
import { postRlmGuide } from "./ui/intro.ts";
|
|
14
|
+
import { setRlmModeStatus } from "./ui/status.ts";
|
|
15
|
+
import { SandboxManager } from "./sandbox/sandbox-manager.ts";
|
|
16
|
+
import { packRepository, formatForLLM, serializeForSandbox } from "./context/repomix-context.ts";
|
|
17
|
+
import { buildNativeSystemPrompt } from "./prompts/system.ts";
|
|
18
|
+
import { errorMessage } from "./util/errors.ts";
|
|
19
|
+
|
|
20
|
+
const BLOCKED_NATIVE_TOOLS = Object.freeze(new Set(["read", "grep", "bash", "write", "edit"]));
|
|
21
|
+
|
|
22
|
+
export default function rlmExtension(pi: ExtensionAPI): void {
|
|
23
|
+
void setupRlmExtension(pi).catch((error) => {
|
|
24
|
+
console.warn(`[rlm] extension setup failed: ${errorMessage(error)}`);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function setupRlmExtension(pi: ExtensionAPI): Promise<void> {
|
|
29
|
+
const persisted = await loadSettings();
|
|
30
|
+
const config = mergeConfig(persisted.config);
|
|
31
|
+
const controller = new RlmController(config);
|
|
32
|
+
controller.savedWorkerRef = persisted.worker;
|
|
33
|
+
|
|
34
|
+
// ── SandboxManager — persistent singleton for native-mode repl() ──
|
|
35
|
+
const sandboxManager = new SandboxManager({
|
|
36
|
+
execTimeoutS: config.execTimeoutS,
|
|
37
|
+
requestTimeoutMs: config.requestTimeoutMs,
|
|
38
|
+
python: config.python,
|
|
39
|
+
sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── Message renderers ──
|
|
43
|
+
pi.registerMessageRenderer(
|
|
44
|
+
"rlm-answer",
|
|
45
|
+
(message, _options, _theme) => new Markdown(String(message.content ?? ""), 1, 0, getMarkdownTheme()),
|
|
46
|
+
);
|
|
47
|
+
pi.registerMessageRenderer("rlm-question", (message, _options, _theme) =>
|
|
48
|
+
new Markdown(`**RLM question**\n\n${String(message.content ?? "")}`, 1, 0, getMarkdownTheme()),
|
|
49
|
+
);
|
|
50
|
+
pi.registerMessageRenderer("rlm-intro", (message, _options, _theme) =>
|
|
51
|
+
new Markdown(String(message.content ?? ""), 1, 0, getMarkdownTheme()),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// ── Commands ──
|
|
55
|
+
registerRlmCommand(pi, controller);
|
|
56
|
+
registerRlmConfigCommand(pi, controller);
|
|
57
|
+
|
|
58
|
+
// ── Tool registration ──
|
|
59
|
+
// Existing rlm tool (stays for backward compat with /rlm mode)
|
|
60
|
+
pi.registerTool(createRlmTool(controller));
|
|
61
|
+
pi.registerTool(createApplyDiffTool());
|
|
62
|
+
|
|
63
|
+
// Native repl tool — re-registered each session to pick up model provider changes
|
|
64
|
+
let guidePosted = false;
|
|
65
|
+
|
|
66
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
67
|
+
// Restore saved worker ref — retry lazily if registry/auth is not ready yet.
|
|
68
|
+
// session_start can fire before the provider/auth registry is fully loaded, so a miss
|
|
69
|
+
// here does NOT mean the ref is stale: RlmController.resolveModels() retries later.
|
|
70
|
+
if (controller.savedWorkerRef) {
|
|
71
|
+
const resolved = resolveModelId(ctx.modelRegistry, controller.savedWorkerRef);
|
|
72
|
+
if (resolved) controller.workerModel = resolved;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Register repl tool with current models (re-registers each session for provider changes)
|
|
76
|
+
const workerModel = controller.workerModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
|
|
77
|
+
const model = ctx.model;
|
|
78
|
+
if (workerModel && model) {
|
|
79
|
+
try {
|
|
80
|
+
pi.registerTool(createReplTool({
|
|
81
|
+
sandboxManager,
|
|
82
|
+
model,
|
|
83
|
+
workerModel,
|
|
84
|
+
getModel: () => controller.resolveModels(ctx)?.model,
|
|
85
|
+
getWorkerModel: () => controller.resolveModels(ctx)?.worker,
|
|
86
|
+
registry: ctx.modelRegistry,
|
|
87
|
+
config,
|
|
88
|
+
}));
|
|
89
|
+
} catch { /* re-registration on provider change — ignore if already registered */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setRlmModeStatus(ctx.ui, controller);
|
|
93
|
+
if (!guidePosted && controller.enabled) {
|
|
94
|
+
guidePosted = true;
|
|
95
|
+
postRlmGuide(pi, controller);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── System prompt: native RLM mode addendum (only when enabled) ──
|
|
100
|
+
pi.on("before_agent_start", async (event) => {
|
|
101
|
+
if (!controller.enabled) return;
|
|
102
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + buildNativeSystemPrompt() };
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── Context injection: repo listing for the main agent ──
|
|
106
|
+
let contextInjected = false;
|
|
107
|
+
pi.on("context", async (event, ctx) => {
|
|
108
|
+
const filtered = event.messages.filter(
|
|
109
|
+
(message) => !(message.role === "custom" && message.customType === "rlm-intro"),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Inject repository context as a compact listing (once per session, only when RLM is enabled)
|
|
113
|
+
if (controller.enabled && !contextInjected) {
|
|
114
|
+
contextInjected = true;
|
|
115
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
116
|
+
const result = await packRepository(cwd);
|
|
117
|
+
if (result.ok) {
|
|
118
|
+
const contextText = formatForLLM(result.value);
|
|
119
|
+
const instruction = [
|
|
120
|
+
"ANALYZE THIS REPOSITORY using repl({code}) — read/grep/bash are DISABLED.",
|
|
121
|
+
`Total: ${result.value.totalFiles} files, ${result.value.totalChars.toLocaleString()} chars — must use repl().`,
|
|
122
|
+
"Chunk context via Python, delegate to llm_query. If credits exhausted → report and stop.",
|
|
123
|
+
"",
|
|
124
|
+
].join("\n");
|
|
125
|
+
const contextMsg = {
|
|
126
|
+
role: "user" as const,
|
|
127
|
+
content: instruction + contextText,
|
|
128
|
+
timestamp: 0,
|
|
129
|
+
} as (typeof filtered)[number];
|
|
130
|
+
|
|
131
|
+
// Store context for sandbox loading on first repl() call
|
|
132
|
+
sandboxManager.contextPayload = serializeForSandbox(result.value);
|
|
133
|
+
|
|
134
|
+
return { messages: [contextMsg, ...filtered] };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { messages: filtered };
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── Input routing: native mode — agent decides whether to use repl() or other tools ──
|
|
142
|
+
// The old black-box rlm() routing is removed; the main agent receives messages normally
|
|
143
|
+
// and chooses natively when to call repl(), read, grep, zebra-mcp, etc.
|
|
144
|
+
pi.on("input", async (_event, _ctx) => {
|
|
145
|
+
return { action: "continue" };
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── Tool restriction: block read/grep/bash when RLM is ON ──
|
|
149
|
+
pi.on("tool_call", async (event) => {
|
|
150
|
+
if (!controller.enabled) return;
|
|
151
|
+
if (BLOCKED_NATIVE_TOOLS.has(event.toolName)) {
|
|
152
|
+
return {
|
|
153
|
+
block: true,
|
|
154
|
+
reason: "RLM mode active. Use repl({code}) to read files and apply_diff({diff}) to modify them. All files are pre-loaded in the REPL. If sub-LLM credits are exhausted, report to the user.",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ── Session shutdown: cleanup ──
|
|
160
|
+
pi.on("session_shutdown", async () => {
|
|
161
|
+
controller.abort();
|
|
162
|
+
await sandboxManager.dispose();
|
|
163
|
+
contextInjected = false;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { InputSource } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export interface InputRouteState {
|
|
4
|
+
readonly enabled: boolean;
|
|
5
|
+
readonly busy: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface InputRouteEvent {
|
|
9
|
+
readonly source: InputSource;
|
|
10
|
+
readonly text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type InputRouteDecision = "continue" | "route" | "busy";
|
|
14
|
+
|
|
15
|
+
export function decideRlmInputRoute(event: InputRouteEvent, state: InputRouteState): InputRouteDecision {
|
|
16
|
+
const eligible = state.enabled && event.source === "interactive" && !event.text.trimStart().startsWith("/");
|
|
17
|
+
if (!eligible) return "continue";
|
|
18
|
+
return state.busy ? "busy" : "route";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function shouldRouteRlmInput(event: InputRouteEvent, state: InputRouteState): boolean {
|
|
22
|
+
return decideRlmInputRoute(event, state) === "route";
|
|
23
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RlmController — holds RLM config + chosen models.
|
|
3
|
+
*
|
|
4
|
+
* The engine drives the root model turn-by-turn over ```repl``` blocks with full budget/token/
|
|
5
|
+
* timeout/error guards, compaction, and a finalize fallback. `start()` returns a RunHandle with
|
|
6
|
+
* the completion promise.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { DEFAULT_RUN_DIR } from "../config/defaults.ts";
|
|
12
|
+
import { modelRef, resolveModelId, saveSettings } from "../config/settings.ts";
|
|
13
|
+
import { createEngine } from "../core/engine.ts";
|
|
14
|
+
import type { InteractiveDeps, RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
|
|
15
|
+
import type { ReconstructResult } from "../state/resume.ts";
|
|
16
|
+
import { packRepository, serializeForSandbox } from "../context/repomix-context.ts";
|
|
17
|
+
import { RlmEmitter } from "../tool/rlm-events.ts";
|
|
18
|
+
import { formatError } from "../util/errors.ts";
|
|
19
|
+
|
|
20
|
+
export function cheapestModel(registry: ModelRegistry): Model<Api> | undefined {
|
|
21
|
+
const models = registry.getAvailable();
|
|
22
|
+
if (models.length === 0) return undefined;
|
|
23
|
+
return [...models].sort((a, b) => a.cost.input + a.cost.output - (b.cost.input + b.cost.output))[0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RunHandle {
|
|
27
|
+
readonly abort: () => void;
|
|
28
|
+
readonly done: Promise<RlmResult>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** B5+SA: discriminated union removes non-null `!` assertions and the `context: ""` hack. */
|
|
32
|
+
export type StartInput =
|
|
33
|
+
| { readonly kind: "fresh"; readonly rootPrompt: string; readonly context: unknown }
|
|
34
|
+
| { readonly kind: "resume"; readonly resume: ReconstructResult & { ok: true }; readonly context: unknown };
|
|
35
|
+
|
|
36
|
+
export class RlmController {
|
|
37
|
+
workerModel: Model<Api> | undefined;
|
|
38
|
+
savedWorkerRef: string | undefined;
|
|
39
|
+
private active: AbortController | null = null;
|
|
40
|
+
|
|
41
|
+
constructor(public config: RlmConfig) {}
|
|
42
|
+
|
|
43
|
+
get enabled(): boolean {
|
|
44
|
+
return this.config.enabled;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setEnabled(enabled: boolean): void {
|
|
48
|
+
this.config.enabled = enabled;
|
|
49
|
+
void this.persist();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
toggle(): boolean {
|
|
53
|
+
const next = !this.enabled;
|
|
54
|
+
this.setEnabled(next);
|
|
55
|
+
if (!next) this.abort(); // turning the mode OFF also stops an in-flight run
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
hasSavedModels(): boolean {
|
|
60
|
+
return Boolean(this.savedWorkerRef || this.workerModel);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async persist(): Promise<boolean> {
|
|
64
|
+
return await saveSettings({
|
|
65
|
+
config: this.config,
|
|
66
|
+
worker: modelRef(this.workerModel) ?? this.savedWorkerRef,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
isBusy(): boolean {
|
|
71
|
+
return this.active !== null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
abort(): void {
|
|
75
|
+
this.active?.abort();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
resolveModels(ctx: ExtensionContext): { model: Model<Api>; worker: Model<Api> } | undefined {
|
|
79
|
+
if (!this.workerModel && this.savedWorkerRef) this.workerModel = resolveModelId(ctx.modelRegistry, this.savedWorkerRef);
|
|
80
|
+
const model = ctx.model ?? cheapestModel(ctx.modelRegistry);
|
|
81
|
+
if (!model) return undefined;
|
|
82
|
+
const worker = this.workerModel ?? cheapestModel(ctx.modelRegistry) ?? model;
|
|
83
|
+
return { model, worker };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
start(ctx: ExtensionContext, input: StartInput, emitter?: RlmEmitter, interactive?: InteractiveDeps): RunHandle {
|
|
87
|
+
const models = this.resolveModels(ctx);
|
|
88
|
+
if (!models) throw new Error("no model with configured auth is available");
|
|
89
|
+
if (this.active) throw new Error("RLM run already in progress"); // QC: mutual-exclusion guard
|
|
90
|
+
|
|
91
|
+
const abortController = new AbortController();
|
|
92
|
+
this.active = abortController;
|
|
93
|
+
|
|
94
|
+
const runState = this.config.runLog?.enabled !== false
|
|
95
|
+
? { cwd: ctx.cwd ?? process.cwd(), dir: this.config.runLog?.dir ?? DEFAULT_RUN_DIR, snapshot: this.config.runLog?.snapshot !== false }
|
|
96
|
+
: undefined;
|
|
97
|
+
|
|
98
|
+
const done = (async () => {
|
|
99
|
+
let engineInput: RlmInput;
|
|
100
|
+
if (input.kind === "fresh") {
|
|
101
|
+
// Auto-pack empty/undefined context via repomix; pass explicit context through.
|
|
102
|
+
let contextValue: unknown = input.context;
|
|
103
|
+
if (contextValue === undefined || contextValue === "" || (typeof contextValue === "string" && contextValue.trim() === "")) {
|
|
104
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
105
|
+
const result = await packRepository(cwd, abortController.signal);
|
|
106
|
+
if (result.ok) {
|
|
107
|
+
contextValue = serializeForSandbox(result.value);
|
|
108
|
+
} else {
|
|
109
|
+
contextValue = formatError(`failed to pack repository — ${result.error}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
engineInput = {
|
|
113
|
+
rootPrompt: input.rootPrompt,
|
|
114
|
+
context: contextValue,
|
|
115
|
+
depth: 0,
|
|
116
|
+
};
|
|
117
|
+
} else {
|
|
118
|
+
engineInput = {
|
|
119
|
+
rootPrompt: input.resume.header.rootPrompt,
|
|
120
|
+
context: input.context, // B5: load the actual context from the sidecar, not ""
|
|
121
|
+
depth: 0,
|
|
122
|
+
resume: input.resume,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const engine = createEngine({
|
|
126
|
+
model: models.model,
|
|
127
|
+
workerModel: models.worker,
|
|
128
|
+
registry: ctx.modelRegistry,
|
|
129
|
+
config: this.config,
|
|
130
|
+
signal: abortController.signal,
|
|
131
|
+
emitter: emitter ?? new RlmEmitter(),
|
|
132
|
+
runState,
|
|
133
|
+
onAskUserQuestion: interactive?.onAskUserQuestion,
|
|
134
|
+
onTodo: interactive?.onTodo,
|
|
135
|
+
limits: {
|
|
136
|
+
maxBudgetUsd: this.config.maxBudgetUsd,
|
|
137
|
+
maxTimeoutMs: this.config.maxTimeoutMs,
|
|
138
|
+
maxTokens: this.config.maxTokens,
|
|
139
|
+
maxErrors: this.config.maxErrors,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return await engine(engineInput);
|
|
143
|
+
})().finally(() => {
|
|
144
|
+
if (this.active === abortController) this.active = null;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return { abort: () => abortController.abort(), done };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
}
|