@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,362 @@
1
+ /**
2
+ * PythonSandbox — owns one `python3 worker.py` subprocess and the JSONL stdio pump.
3
+ *
4
+ * The pump multiplexes two concerns on one pipe:
5
+ * 1. request/response (exec, load_context, shutdown), keyed by `id`;
6
+ * 2. mid-exec sub-LLM interrupts (llm_query/rlm_query), serviced in-process by handlers
7
+ * the engine/bridge installs — the worker never sees API keys.
8
+ */
9
+
10
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
11
+ import { writeFile, unlink } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import {
16
+ isInterrupt,
17
+ isWorkerMessage,
18
+ type AskAnswer,
19
+ type AskQuestion,
20
+ type ParentMessage,
21
+ type ReplResult,
22
+ type WorkerInterrupt,
23
+ type WorkerMessage,
24
+ type WorkerRequest,
25
+ type WorkerResponse,
26
+ } from "./protocol.ts";
27
+ import { formatError } from "../util/errors.ts";
28
+
29
+ /** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
30
+ export interface SubLlmHandlers {
31
+ llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
32
+ llmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
33
+ rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
34
+ rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
35
+ advancePhase(phase: string, summary: string | undefined, depth: number): Promise<string>;
36
+ askUserQuestion(questions: readonly AskQuestion[], depth: number): Promise<AskAnswer[]>;
37
+ todo(action: string, params: Record<string, unknown>, depth: number): Promise<string>;
38
+ }
39
+
40
+ export interface SandboxOptions {
41
+ /** Sandbox recursion depth label (passed to the worker, used in interrupt routing). */
42
+ readonly depth?: number;
43
+ /** Per-`repl`-block wall-clock timeout inside the worker (seconds). */
44
+ readonly execTimeoutS?: number;
45
+ /** Parent-side watchdog per request (ms); on breach the worker is SIGKILLed. */
46
+ readonly requestTimeoutMs?: number;
47
+ /** Python executable. */
48
+ readonly python?: string;
49
+ /** Handlers for sub-LLM interrupts. Defaults reject (Phase 1 has no bridge yet). */
50
+ readonly handlers?: Partial<SubLlmHandlers>;
51
+ /** AbortSignal — immediate SIGKILL on abort, bypassing the shutdown handshake. */
52
+ readonly signal?: AbortSignal;
53
+ /** Worker startup wait before init failure (ms). */
54
+ readonly initTimeoutMs?: number;
55
+ }
56
+
57
+ const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.py");
58
+ const TODO_PROTO_KEYS = new Set(["type", "rid", "depth", "action"]);
59
+
60
+ // The sandbox runs untrusted model-authored code; it must never inherit provider secrets.
61
+ const SENSITIVE_ENV = /API[_-]?KEY|ACCESS[_-]?KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|ANTHROPIC|OPENAI|_KEY$/i;
62
+
63
+ function sanitizedEnv(): NodeJS.ProcessEnv {
64
+ const env: NodeJS.ProcessEnv = {};
65
+ for (const [k, v] of Object.entries(process.env)) {
66
+ if (v !== undefined && !SENSITIVE_ENV.test(k)) env[k] = v;
67
+ }
68
+ return env;
69
+ }
70
+
71
+ const REJECT: SubLlmHandlers = {
72
+ llmQuery: async () => formatError("sub-LLM bridge not configured"),
73
+ llmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
74
+ rlmQuery: async () => formatError("sub-LLM bridge not configured"),
75
+ rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
76
+ advancePhase: async () => formatError("phase advancement not available"),
77
+ askUserQuestion: async (questions) => questions.map((q) => ({
78
+ question: q.question,
79
+ selected: [],
80
+ custom: formatError("ask_user_question not configured"),
81
+ })),
82
+ todo: async () => formatError("todo not configured"),
83
+ };
84
+
85
+ /** Distributive omit so each union member keeps its own fields (plain Omit collapses to shared keys). */
86
+ type RequestBody = WorkerRequest extends infer T ? (T extends { id: string } ? Omit<T, "id"> : never) : never;
87
+
88
+ type Pending = {
89
+ readonly resolve: (res: WorkerResponse) => void;
90
+ readonly reject: (err: Error) => void;
91
+ timer: ReturnType<typeof setTimeout>;
92
+ readonly requestType: string;
93
+ };
94
+
95
+ export class PythonSandbox {
96
+ private proc: ChildProcessWithoutNullStreams;
97
+ private buf = "";
98
+ private scanOffset = 0;
99
+ private seq = 0;
100
+ private readonly pending = new Map<string, Pending>();
101
+ private readonly handlers: SubLlmHandlers;
102
+ private readonly requestTimeoutMs: number;
103
+ private readonly initTimeoutMs: number;
104
+ private stderr = "";
105
+ private disposed = false;
106
+ private ready: Promise<void>;
107
+
108
+ private constructor(opts: SandboxOptions) {
109
+ this.handlers = { ...REJECT, ...opts.handlers };
110
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? 20 * 60_000;
111
+ this.initTimeoutMs = opts.initTimeoutMs ?? 30_000;
112
+ const python = opts.python ?? "python3";
113
+ this.proc = spawn(
114
+ python,
115
+ ["-u", WORKER_PATH, "--depth", String(opts.depth ?? 1), "--timeout", String(opts.execTimeoutS ?? 600)],
116
+ { stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv() },
117
+ ) as ChildProcessWithoutNullStreams;
118
+
119
+ this.proc.stdout.setEncoding("utf8");
120
+ this.proc.stdout.on("data", (chunk: string) => this.onData(chunk));
121
+ this.proc.stderr.setEncoding("utf8");
122
+ this.proc.stderr.on("data", (chunk: string) => {
123
+ this.stderr = (this.stderr + chunk).slice(-8192);
124
+ });
125
+ this.proc.on("error", (err: NodeJS.ErrnoException) => {
126
+ const hint = err.code === "ENOENT" ? ` ('${python}' not found — is Python installed and on PATH?)` : "";
127
+ this.failAll(new Error(`failed to start sandbox${hint}: ${err.message}`));
128
+ });
129
+ this.proc.on("exit", () => this.failAll(new Error(`worker exited; stderr=${this.stderr.trim()}`)));
130
+
131
+ this.ready = this.waitForInit();
132
+
133
+ // Immediate SIGKILL on abort — no shutdown handshake, no 50ms wait.
134
+ if (opts.signal) {
135
+ if (opts.signal.aborted) {
136
+ this.disposed = true;
137
+ this.proc.kill("SIGKILL");
138
+ this.failAll(new Error("sandbox aborted"));
139
+ } else {
140
+ opts.signal.addEventListener("abort", () => {
141
+ if (!this.disposed) {
142
+ this.disposed = true;
143
+ try { this.proc.kill("SIGKILL"); } catch { /* already dead */ }
144
+ this.failAll(new Error("sandbox aborted"));
145
+ }
146
+ }, { once: true });
147
+ }
148
+ }
149
+ }
150
+
151
+ /** Spawn a sandbox and wait until the worker reports it is initialized. */
152
+ static async spawn(opts: SandboxOptions = {}): Promise<PythonSandbox> {
153
+ const sandbox = new PythonSandbox(opts);
154
+ await sandbox.ready;
155
+ return sandbox;
156
+ }
157
+
158
+ async loadContext(payload: unknown, index?: number): Promise<number> {
159
+ const isJson = typeof payload !== "string";
160
+ let path: string | undefined;
161
+ try {
162
+ path = await this.writeContextFile(payload, isJson);
163
+ const res = await this.request({ type: "load_context", path, index, json: isJson });
164
+ return res.index ?? 0;
165
+ } finally {
166
+ if (path) await unlink(path).catch(() => {});
167
+ }
168
+ }
169
+
170
+ private async writeContextFile(payload: unknown, isJson: boolean): Promise<string> {
171
+ const file = join(
172
+ tmpdir(),
173
+ `rlm-ctx-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${isJson ? "json" : "txt"}`,
174
+ );
175
+ try {
176
+ await writeFile(file, isJson ? JSON.stringify(payload) : (payload as string));
177
+ } catch (e) {
178
+ await unlink(file).catch(() => {});
179
+ throw e;
180
+ }
181
+ return file;
182
+ }
183
+
184
+ async exec(code: string): Promise<ReplResult> {
185
+ const res = await this.request({ type: "exec", code });
186
+ return {
187
+ stdout: res.stdout ?? "",
188
+ stderr: res.stderr ?? "",
189
+ finalAnswer: res.final_answer ?? null,
190
+ answerContent: res.answer_content ?? "",
191
+ edits: res.edits ?? [],
192
+ diffs: res.diffs ?? [],
193
+ raised: res.raised ?? false,
194
+ executionTimeMs: Math.round((res.execution_time ?? 0) * 1000),
195
+ varNames: res.var_names ?? [],
196
+ };
197
+ }
198
+
199
+ async dispose(): Promise<void> {
200
+ if (this.disposed) return;
201
+ this.disposed = true;
202
+ try {
203
+ this.send({ id: "_shutdown", type: "shutdown" });
204
+ } catch {
205
+ /* pipe may already be gone */
206
+ }
207
+ await new Promise((r) => setTimeout(r, 50));
208
+ if (this.proc.exitCode === null) this.proc.kill("SIGKILL");
209
+ this.failAll(new Error("sandbox disposed"));
210
+ }
211
+
212
+ /** Pickle the worker's user namespace atomically to path (rename .tmp \u2192 final inside worker). */
213
+ async snapshot(path: string, nonce: string): Promise<boolean> {
214
+ try {
215
+ const res = await this.request({ type: "snapshot", path, nonce });
216
+ return res.ok;
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+
222
+ /** Restore user variables. Worker verifies session nonce before deserializing. */
223
+ async restore(path: string, nonce: string): Promise<boolean> {
224
+ try {
225
+ const res = await this.request({ type: "restore", path, nonce });
226
+ return res.ok;
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ // ---- internals ------------------------------------------------------------------------
233
+
234
+ private waitForInit(): Promise<void> {
235
+ return new Promise((resolve, reject) => {
236
+ const timer = setTimeout(() => reject(new Error("worker did not start in time")), this.initTimeoutMs);
237
+ this.pending.set("_init", {
238
+ resolve: (res) => {
239
+ clearTimeout(timer);
240
+ res.ok ? resolve() : reject(new Error(res.error ?? "worker init failed"));
241
+ },
242
+ reject,
243
+ timer,
244
+ requestType: "init",
245
+ });
246
+ });
247
+ }
248
+
249
+ private request(payload: RequestBody): Promise<WorkerResponse> {
250
+ if (this.disposed) return Promise.reject(new Error("sandbox disposed"));
251
+ const id = `r${++this.seq}`;
252
+ return new Promise<WorkerResponse>((resolve, reject) => {
253
+ const timer = this.createWatchdog(id, payload.type, reject);
254
+ this.pending.set(id, { resolve, reject, timer, requestType: payload.type });
255
+ this.send({ id, ...payload } as ParentMessage);
256
+ });
257
+ }
258
+
259
+ private createWatchdog(id: string, requestType: string, reject: (err: Error) => void): ReturnType<typeof setTimeout> {
260
+ return setTimeout(() => {
261
+ this.pending.delete(id);
262
+ this.proc.kill("SIGKILL");
263
+ reject(new Error(`request '${requestType}' exceeded ${this.requestTimeoutMs}ms with no progress; worker killed`));
264
+ }, this.requestTimeoutMs);
265
+ }
266
+
267
+ private touchPending(): void {
268
+ for (const [id, p] of this.pending) {
269
+ if (id === "_init") continue;
270
+ clearTimeout(p.timer);
271
+ p.timer = this.createWatchdog(id, p.requestType, p.reject);
272
+ }
273
+ }
274
+
275
+ private send(msg: ParentMessage): void {
276
+ this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
277
+ }
278
+
279
+ private onData(chunk: string): void {
280
+ this.buf += chunk;
281
+ let nl: number;
282
+ while ((nl = this.buf.indexOf("\n", this.scanOffset)) >= 0) {
283
+ const line = this.buf.slice(this.scanOffset, nl).trim();
284
+ this.scanOffset = nl + 1;
285
+ if (line) {
286
+ try {
287
+ const message = JSON.parse(line) as unknown;
288
+ if (isWorkerMessage(message)) this.dispatch(message);
289
+ else this.stderr = `${this.stderr}\n[protocol] skipped invalid stdout message: ${line.slice(0, 200)}`.slice(-8192);
290
+ } catch {
291
+ // Non-JSON line on the protocol stream — likely a subprocess writing to fd 1.
292
+ // Skip it so a rogue write doesn't kill the pump, but retain a breadcrumb for watchdog errors.
293
+ this.stderr = `${this.stderr}\n[protocol] skipped non-JSON stdout line: ${line.slice(0, 200)}`.slice(-8192);
294
+ }
295
+ }
296
+ }
297
+ // Drop the processed prefix to avoid O(n²) growth across chunks.
298
+ if (this.scanOffset > 0) {
299
+ this.buf = this.buf.slice(this.scanOffset);
300
+ this.scanOffset = 0;
301
+ }
302
+ }
303
+
304
+ private dispatch(msg: WorkerMessage): void {
305
+ if (isInterrupt(msg)) {
306
+ this.touchPending();
307
+ void this.serviceInterrupt(msg);
308
+ return;
309
+ }
310
+ const p = this.pending.get(msg.id);
311
+ if (!p) return;
312
+ this.pending.delete(msg.id);
313
+ clearTimeout(p.timer);
314
+ p.resolve(msg);
315
+ }
316
+
317
+ private async serviceInterrupt(msg: WorkerInterrupt): Promise<void> {
318
+ const h = this.handlers;
319
+ const d = msg.depth;
320
+ try {
321
+ if (msg.type === "llm_query") {
322
+ const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d);
323
+ this.reply(msg.rid, { response });
324
+ } else if (msg.type === "rlm_query") {
325
+ const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d);
326
+ this.reply(msg.rid, { response });
327
+ } else if (msg.type === "llm_query_batched") {
328
+ const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d);
329
+ this.reply(msg.rid, { responses });
330
+ } else if (msg.type === "rlm_query_batched") {
331
+ const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d);
332
+ this.reply(msg.rid, { responses });
333
+ } else if (msg.type === "advance_phase") {
334
+ const response = await h.advancePhase(msg.phase ?? "", msg.summary, d);
335
+ this.reply(msg.rid, { response });
336
+ } else if (msg.type === "ask_user_question") {
337
+ const answers = await h.askUserQuestion(msg.questions ?? [], d);
338
+ this.reply(msg.rid, { answers });
339
+ } else if (msg.type === "todo") {
340
+ const params = Object.fromEntries(
341
+ Object.entries(msg).filter(([key]) => !TODO_PROTO_KEYS.has(key)),
342
+ );
343
+ const response = await h.todo(msg.action ?? "list", params, d);
344
+ this.reply(msg.rid, { response });
345
+ }
346
+ } catch (err) {
347
+ this.reply(msg.rid, { error: err instanceof Error ? err.message : String(err) });
348
+ }
349
+ }
350
+
351
+ private reply(rid: string, body: { response?: string; responses?: string[]; answers?: AskAnswer[]; error?: string }): void {
352
+ if (!this.disposed) this.send({ type: "llm_reply", rid, ...body });
353
+ }
354
+
355
+ private failAll(err: Error): void {
356
+ for (const [, p] of this.pending) {
357
+ clearTimeout(p.timer);
358
+ p.reject(err);
359
+ }
360
+ this.pending.clear();
361
+ }
362
+ }