@hicaru/pi-rlm 0.2.1 → 0.3.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 (79) hide show
  1. package/README.md +28 -47
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +22 -19
  5. package/src/bridge/add-context.ts +322 -0
  6. package/src/bridge/subcall-handlers.ts +63 -17
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +8 -18
  10. package/src/config/settings.ts +13 -34
  11. package/src/context/anydoc.ts +67 -0
  12. package/src/context/listing.ts +70 -0
  13. package/src/context/md-cache.ts +112 -0
  14. package/src/context/merge.ts +97 -0
  15. package/src/context/namespace.ts +180 -0
  16. package/src/context/resolve.ts +122 -0
  17. package/src/context/source-dir.ts +166 -0
  18. package/src/context/source-doc.ts +71 -0
  19. package/src/context/source-git.ts +51 -0
  20. package/src/context/source-text.ts +45 -0
  21. package/src/context/types.ts +88 -0
  22. package/src/context/walk.ts +250 -0
  23. package/src/core/engine.ts +61 -345
  24. package/src/core/history.ts +1 -1
  25. package/src/core/limits.ts +5 -12
  26. package/src/core/resource-limits.ts +0 -2
  27. package/src/core/types.ts +10 -38
  28. package/src/index.ts +92 -54
  29. package/src/mode/llm-model.ts +54 -0
  30. package/src/mode/rlm-mode.ts +28 -58
  31. package/src/prompts/glossary.ts +290 -0
  32. package/src/prompts/native.ts +127 -0
  33. package/src/prompts/system.ts +15 -408
  34. package/src/sandbox/context-file.ts +154 -0
  35. package/src/sandbox/interrupts.ts +160 -0
  36. package/src/sandbox/protocol.ts +20 -75
  37. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  38. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  39. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  40. package/src/sandbox/py/guards.py +150 -0
  41. package/src/sandbox/py/retrieval.py +265 -0
  42. package/src/sandbox/py/tasks.py +129 -0
  43. package/src/sandbox/py/worker.py +856 -0
  44. package/src/sandbox/sandbox-manager.ts +24 -9
  45. package/src/sandbox/sandbox.ts +99 -193
  46. package/src/text/tokens.ts +31 -5
  47. package/src/tool/repl-details.ts +2 -2
  48. package/src/tool/repl-render.ts +58 -0
  49. package/src/tool/repl-result.ts +70 -0
  50. package/src/tool/repl-tool.ts +60 -170
  51. package/src/tool/rlm-aggregator.ts +2 -10
  52. package/src/tool/rlm-details.ts +0 -2
  53. package/src/tool/rlm-events.ts +0 -14
  54. package/src/tool/rlm-tool.ts +2 -13
  55. package/src/ui/config-panel.ts +12 -20
  56. package/src/ui/intro.ts +1 -2
  57. package/src/ui/model-picker.ts +34 -10
  58. package/src/ui/status.ts +3 -7
  59. package/src/util/concurrency.ts +9 -5
  60. package/src/bridge/fallback-todo.ts +0 -148
  61. package/src/bridge/interactive.ts +0 -65
  62. package/src/bridge/library.ts +0 -155
  63. package/src/bridge/pi-interactive.ts +0 -41
  64. package/src/context/library-context.ts +0 -266
  65. package/src/context/repomix-context.ts +0 -204
  66. package/src/core/artifacts.ts +0 -89
  67. package/src/core/critique.ts +0 -92
  68. package/src/core/gates.ts +0 -301
  69. package/src/core/pipeline-handlers.ts +0 -319
  70. package/src/core/pipeline.ts +0 -268
  71. package/src/prompts/phases.ts +0 -104
  72. package/src/sandbox/worker.py +0 -1456
  73. package/src/state/index.ts +0 -24
  74. package/src/state/internal.ts +0 -46
  75. package/src/state/paths.ts +0 -44
  76. package/src/state/reads.ts +0 -133
  77. package/src/state/resume.ts +0 -173
  78. package/src/state/rows.ts +0 -123
  79. package/src/state/writes.ts +0 -58
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { PythonSandbox, type SubLlmHandlers } from "./sandbox.ts";
8
8
  import type { ReplResult } from "./protocol.ts";
9
+ import { mergeIntoContext } from "../context/merge.ts";
9
10
 
10
11
  /** Static configuration for sandbox creation — set once, reused across getOrCreate calls. */
11
12
  export interface SandboxManagerConfig {
@@ -27,27 +28,41 @@ export class SandboxManager {
27
28
  /** Serialized execution queue — concurrent repl() calls wait for predecessor. */
28
29
  private execQueue: Promise<void> = Promise.resolve();
29
30
  private pendingExecCount = 0;
30
- /** Context payload to load on first sandbox creation. Set externally before getOrCreate. */
31
- contextPayload: unknown = null;
31
+ /**
32
+ * Context payload to load on first sandbox creation. Starts as an empty list — context is
33
+ * empty by default; the first repl() seeds the cwd when autoSeedCwd is on.
34
+ */
35
+ contextPayload: unknown = [];
32
36
  /** True once contextPayload has been loaded into the sandbox (prevents reload + race fix). */
33
37
  private contextLoaded = false;
34
38
 
35
39
  constructor(private readonly config: SandboxManagerConfig) {}
36
40
 
41
+ /**
42
+ * Append a source payload to the context this manager replays on death-recreate.
43
+ *
44
+ * The live worker has ALREADY appended it in-process (worker.py `_append_context`), so this
45
+ * deliberately does not reload — it only keeps the host's replay copy truthful. Without it a
46
+ * recreate silently rolls the sandbox back to a pre-append context, and any child inheriting
47
+ * this payload would never see the source. Dedups by `ctx/<id>/` prefix.
48
+ */
49
+ appendContext(payload: unknown): void {
50
+ this.contextPayload = mergeIntoContext(this.contextPayload, payload);
51
+ }
52
+
37
53
  /**
38
54
  * Lazy get-or-create the sandbox. On first call, spawns PythonSandbox with the
39
55
  * given handlers. Subsequent calls return the existing sandbox immediately.
40
56
  * Deduplicates concurrent calls via initPromise.
41
57
  *
42
- * If contextPayload is set, it is loaded before the sandbox is returned.
58
+ * If contextPayload is defined, it is loaded before the sandbox is returned.
43
59
  */
44
60
  async getOrCreate(handlers: Partial<SubLlmHandlers>): Promise<PythonSandbox> {
45
61
  if (this.disposed) throw new Error("SandboxManager disposed");
46
62
  if (this.sandbox) {
47
- // RACE FIX: contextPayload may arrive after the sandbox was created (the
48
- // "context" event's async packRepository resolves after the first repl() call).
49
- // Load it into the live sandbox now if still pending.
50
- if (this.contextPayload !== null && !this.contextLoaded) {
63
+ // RACE FIX: contextPayload may arrive after the sandbox was created (lazy seed
64
+ // resolves after the first getOrCreate). Load it into the live sandbox now if pending.
65
+ if (this.contextPayload !== undefined && !this.contextLoaded) {
51
66
  await this.sandbox.loadContext(this.contextPayload);
52
67
  this.contextLoaded = true;
53
68
  }
@@ -66,8 +81,8 @@ export class SandboxManager {
66
81
  awaitTimeoutS: this.config.awaitTimeoutS,
67
82
  handlers,
68
83
  }).then(async (s) => {
69
- // Load context on first creation if available.
70
- if (this.contextPayload !== null) {
84
+ // Load context on first creation if available (empty list is a valid starting value).
85
+ if (this.contextPayload !== undefined) {
71
86
  await s.loadContext(this.contextPayload);
72
87
  this.contextLoaded = true;
73
88
  }
@@ -8,60 +8,23 @@
8
8
  */
9
9
 
10
10
  import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
11
- import { writeFile, unlink } from "node:fs/promises";
12
- import { tmpdir } from "node:os";
11
+ import { once } from "node:events";
13
12
  import { dirname, join } from "node:path";
14
13
  import { fileURLToPath } from "node:url";
15
14
  import {
16
15
  isInterrupt,
17
16
  isWorkerMessage,
18
- type AskAnswer,
19
- type AskQuestion,
20
17
  type ParentMessage,
21
18
  type ReplResult,
22
- type WorkerInterrupt,
23
19
  type WorkerMessage,
24
20
  type WorkerRequest,
25
21
  type WorkerResponse,
26
22
  } from "./protocol.ts";
27
- import { errorMessage, formatError } from "../util/errors.ts";
23
+ import { pinContext, type PinnedContext } from "./context-file.ts";
24
+ import { REJECT, serviceInterrupt, type ReplyBody, type SubLlmHandlers } from "./interrupts.ts";
28
25
  import { trace, traceEnabled } from "../util/trace.ts";
29
26
 
30
- /** Result of a host-side library pack requested by `load_library`. */
31
- export interface LibraryLoadResult {
32
- readonly payload: unknown; // always ContextFile[] under lib/<id>/
33
- readonly index: number; // resume-sidecar index (not a REPL var name); -1 if alreadyLoaded
34
- readonly files?: number;
35
- readonly chars: number;
36
- readonly sourceId: string;
37
- readonly pathPrefix: string;
38
- /** Host already has this library — no pack, no sidecar, empty payload. */
39
- readonly alreadyLoaded?: boolean;
40
- }
41
-
42
- /**
43
- * Per-interrupt routing context for the sub-LLM handlers.
44
- *
45
- * Only the four sub-call kinds can be spawned, so only they carry it; the interactive and
46
- * pipeline handlers are always synchronous within one exec.
47
- */
48
- export interface SubcallOpts {
49
- /** Started via `spawn()` — route to session-scoped state, not the current invocation. */
50
- readonly detached: boolean;
51
- }
52
-
53
- /** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
54
- export interface SubLlmHandlers {
55
- llmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
56
- llmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
57
- rlmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
58
- rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
59
- advancePhase(phase: string, summary: string | undefined, depth: number): Promise<string>;
60
- saveArtifact(kind: string, content: string, depth: number): Promise<string>;
61
- askUserQuestion(questions: readonly AskQuestion[], depth: number): Promise<AskAnswer[]>;
62
- todo(action: string, params: Record<string, unknown>, depth: number): Promise<string>;
63
- loadLibrary(source: string, depth: number): Promise<LibraryLoadResult>;
64
- }
27
+ export type { AddContextResult, SubcallOpts, SubLlmHandlers } from "./interrupts.ts";
65
28
 
66
29
  export interface SandboxOptions {
67
30
  /** Sandbox recursion depth label (passed to the worker, used in interrupt routing). */
@@ -80,11 +43,6 @@ export interface SandboxOptions {
80
43
  readonly initTimeoutMs?: number;
81
44
  /** Sub-LLM prompt cap (chars) — sizes llm_query_chunked chunks inside the worker. */
82
45
  readonly maxPromptChars?: number;
83
- /**
84
- * When true, the worker rejects open() write modes (pipeline read-only runs).
85
- * Native repl() data work leaves this false so scratch-file writes still work.
86
- */
87
- readonly readOnly?: boolean;
88
46
  /**
89
47
  * Max seconds the worker will wait for a host reply while parked in `_drain_until`
90
48
  * (rlm_await / sync sub-call). Defaults to the worker's own RLM_AWAIT_TIMEOUT_S (600).
@@ -92,9 +50,10 @@ export interface SandboxOptions {
92
50
  readonly awaitTimeoutS?: number;
93
51
  }
94
52
 
95
- const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.py");
53
+ const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "py", "worker.py");
96
54
  const STDERR_TAIL_CHARS = 8_192;
97
- const TODO_PROTO_KEYS = new Set(["type", "rid", "depth", "action"]);
55
+ /** How long dispose() waits for a clean worker exit before escalating to SIGKILL. */
56
+ const SHUTDOWN_GRACE_MS = 50;
98
57
 
99
58
  // The sandbox runs untrusted model-authored code; it must never inherit provider secrets.
100
59
  const SENSITIVE_ENV = /API[_-]?KEY|ACCESS[_-]?KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|ANTHROPIC|OPENAI|_KEY$/i;
@@ -107,22 +66,6 @@ function sanitizedEnv(): NodeJS.ProcessEnv {
107
66
  return env;
108
67
  }
109
68
 
110
- const REJECT: SubLlmHandlers = {
111
- llmQuery: async () => formatError("sub-LLM bridge not configured"),
112
- llmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
113
- rlmQuery: async () => formatError("sub-LLM bridge not configured"),
114
- rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
115
- advancePhase: async () => formatError("phase advancement not available"),
116
- saveArtifact: async () => formatError("save_artifact not available"),
117
- askUserQuestion: async (questions) => questions.map((q) => ({
118
- question: q.question,
119
- selected: [],
120
- custom: formatError("ask_user_question not configured"),
121
- })),
122
- todo: async () => formatError("todo not configured"),
123
- loadLibrary: async () => { throw new Error("load_library not configured"); },
124
- };
125
-
126
69
  /** Distributive omit so each union member keeps its own fields (plain Omit collapses to shared keys). */
127
70
  type RequestBody = WorkerRequest extends infer T ? (T extends { id: string } ? Omit<T, "id"> : never) : never;
128
71
 
@@ -175,9 +118,6 @@ export class PythonSandbox {
175
118
  if (opts.maxPromptChars !== undefined) {
176
119
  workerArgs.push("--max-prompt-chars", String(opts.maxPromptChars));
177
120
  }
178
- if (opts.readOnly) {
179
- workerArgs.push("--read-only");
180
- }
181
121
  if (opts.awaitTimeoutS !== undefined) {
182
122
  workerArgs.push("--await-timeout", String(opts.awaitTimeoutS));
183
123
  }
@@ -191,11 +131,30 @@ export class PythonSandbox {
191
131
  this.proc.stdout.on("data", (chunk: string) => this.onData(chunk));
192
132
  this.proc.stderr.setEncoding("utf8");
193
133
  this.proc.stderr.on("data", (chunk: string) => this.appendStderr(chunk));
134
+
135
+ // A dead worker's pipe fails the write ASYNCHRONOUSLY: node emits 'error' on the stream, and
136
+ // an EventEmitter 'error' with no listener is an uncaughtException — which no try/catch
137
+ // around send() and no `await dispose().catch()` can intercept. Without these three
138
+ // listeners a worker dying mid-exec took the entire pi process down with it (EPIPE from the
139
+ // shutdown frame dispose() writes after the watchdog SIGKILLs).
140
+ // Record and swallow: the real reason is already surfaced by failAll on 'exit'.
141
+ const swallowPipeError = (stream: string) => (err: NodeJS.ErrnoException): void => {
142
+ this.appendStderr(`[rlm] worker ${stream} ${err.code ?? "error"}: ${err.message}\n`);
143
+ };
144
+ this.proc.stdin.on("error", swallowPipeError("stdin"));
145
+ this.proc.stdout.on("error", swallowPipeError("stdout"));
146
+ this.proc.stderr.on("error", swallowPipeError("stderr"));
147
+
194
148
  this.proc.on("error", (err: NodeJS.ErrnoException) => {
195
149
  const hint = err.code === "ENOENT" ? ` ('${python}' not found — is Python installed and on PATH?)` : "";
196
150
  this.failAll(new Error(`failed to start sandbox${hint}: ${err.message}`));
197
151
  });
198
- this.proc.on("exit", () => this.failAll(new Error(`worker exited; stderr=${this.stderr.trim()}`)));
152
+ // Name the cause: a SIGKILL (watchdog, abort, OOM killer) reads very differently from a
153
+ // Python-level crash, and this message is what reaches the user as `REPL error: …`.
154
+ this.proc.on("exit", (code, signal) => this.failAll(new Error(
155
+ `worker exited (${signal !== null ? `signal ${signal}` : `code ${code}`}); `
156
+ + `stderr=${this.stderr.trim()}`,
157
+ )));
199
158
 
200
159
  this.ready = this.waitForInit();
201
160
 
@@ -224,31 +183,27 @@ export class PythonSandbox {
224
183
  return sandbox;
225
184
  }
226
185
 
186
+ /**
187
+ * Load a payload whose pin this sandbox does not own — it acquires and releases one itself.
188
+ * Used by SandboxManager and tests; the engine owns its run's pin and calls the pinned form.
189
+ */
227
190
  async loadContext(payload: unknown): Promise<number> {
228
- const isJson = typeof payload !== "string";
229
- let path: string | undefined;
191
+ const pinned = await pinContext(payload);
230
192
  try {
231
- path = await this.writeContextFile(payload, isJson);
232
- const res = await this.request({ type: "load_context", path, json: isJson });
233
- if (!res.ok) throw new Error(res.error ?? "load_context failed");
234
- return res.index ?? 0;
193
+ return await this.loadContextPinned(pinned);
235
194
  } finally {
236
- if (path) await unlink(path).catch(() => {});
195
+ await pinned.release();
237
196
  }
238
197
  }
239
198
 
240
- private async writeContextFile(payload: unknown, isJson: boolean): Promise<string> {
241
- const file = join(
242
- tmpdir(),
243
- `rlm-ctx-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${isJson ? "json" : "txt"}`,
244
- );
245
- try {
246
- await writeFile(file, isJson ? JSON.stringify(payload) : (payload as string));
247
- } catch (e) {
248
- await unlink(file).catch(() => {});
249
- throw e;
250
- }
251
- return file;
199
+ /**
200
+ * Load from a pin the CALLER owns and will release. Keeping ownership outside means a run and
201
+ * every child that inherits its payload share one serialization and one file.
202
+ */
203
+ async loadContextPinned(pinned: PinnedContext): Promise<number> {
204
+ const res = await this.request({ type: "load_context", path: pinned.path, json: pinned.json });
205
+ if (!res.ok) throw new Error(res.error ?? "load_context failed");
206
+ return res.index ?? 0;
252
207
  }
253
208
 
254
209
  async exec(code: string, signal?: AbortSignal): Promise<ReplResult> {
@@ -268,38 +223,22 @@ export class PythonSandbox {
268
223
  async dispose(): Promise<void> {
269
224
  if (this.disposed) return;
270
225
  this.disposed = true;
271
- try {
226
+ // Handshake only a live worker. The watchdog SIGKILLs before SandboxManager's catch calls
227
+ // dispose(), and writing the shutdown frame to a process that is already dead is exactly
228
+ // what produced the EPIPE that killed the host. Skipping it also drops a pointless 50ms
229
+ // wait from every dead-worker teardown.
230
+ if (this.workerAlive) {
272
231
  this.send({ id: "_shutdown", type: "shutdown" });
273
- } catch {
274
- /* pipe may already be gone */
232
+ // Wait for the worker to actually go rather than sleeping a fixed 50ms: this returns the
233
+ // instant it exits (the common case) and still gives up promptly if it never will, in
234
+ // which case the SIGKILL below finishes the job.
235
+ await once(this.proc, "exit", { signal: AbortSignal.timeout(SHUTDOWN_GRACE_MS) })
236
+ .catch(() => { /* did not exit in time — SIGKILL below */ });
275
237
  }
276
- await new Promise((r) => setTimeout(r, 50));
277
238
  if (this.proc.exitCode === null) this.proc.kill("SIGKILL");
278
239
  this.failAll(new Error("sandbox disposed"));
279
240
  }
280
241
 
281
- /** Pickle the worker's user namespace atomically to path (rename .tmp \u2192 final inside worker). */
282
- async snapshot(path: string, nonce: string): Promise<boolean> {
283
- try {
284
- const res = await this.request({ type: "snapshot", path, nonce });
285
- if (!res.ok) return false;
286
- return res.ok;
287
- } catch {
288
- return false;
289
- }
290
- }
291
-
292
- /** Restore user variables. Worker verifies session nonce before deserializing. */
293
- async restore(path: string, nonce: string): Promise<boolean> {
294
- try {
295
- const res = await this.request({ type: "restore", path, nonce });
296
- if (!res.ok) return false;
297
- return res.ok;
298
- } catch {
299
- return false;
300
- }
301
- }
302
-
303
242
  // ---- internals ------------------------------------------------------------------------
304
243
 
305
244
  private waitForInit(): Promise<void> {
@@ -320,6 +259,13 @@ export class PythonSandbox {
320
259
  private request(payload: RequestBody, signal?: AbortSignal): Promise<WorkerResponse> {
321
260
  if (this.disposed) return Promise.reject(new Error("sandbox disposed"));
322
261
  if (signal?.aborted) return Promise.reject(new Error("repl execution aborted"));
262
+ // Reject before registering the pending entry: `send` no-ops for a dead worker, so a request
263
+ // queued here would otherwise sit until the watchdog fired instead of failing now.
264
+ if (!this.workerAlive) {
265
+ return Promise.reject(new Error(
266
+ `worker is not running (${this.exitDescription()}); request '${payload.type}' not sent`,
267
+ ));
268
+ }
323
269
  const id = `r${++this.seq}`;
324
270
  return new Promise<WorkerResponse>((resolve, reject) => {
325
271
  const timer = this.createWatchdog(id, payload.type, reject);
@@ -375,9 +321,47 @@ export class PythonSandbox {
375
321
  rid: "rid" in msg ? msg.rid : undefined,
376
322
  });
377
323
  }
324
+ // Never write to a corpse. The write would fail asynchronously and, historically, take the
325
+ // host process with it; even with the stdin 'error' listener in place there is nothing to
326
+ // gain. MUST NOT throw — `reply()` calls this from serviceInterrupt's catch, where a throw
327
+ // would become an unhandled rejection, trading one crash for another.
328
+ if (!this.workerAlive) {
329
+ this.appendStderr(`[rlm] dropped '${msg.type}' frame: worker ${this.exitDescription()}\n`);
330
+ return;
331
+ }
378
332
  this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
379
333
  }
380
334
 
335
+ /**
336
+ * Listener counts on the worker's stdio pipes. Exposed for tests.
337
+ *
338
+ * The `'error'` listener on each is load-bearing: an EventEmitter `'error'` with no listener is
339
+ * an uncaughtException, so a failed write to a dead worker's stdin kills the HOST process. That
340
+ * cannot be behaviour-tested reliably — whether the write reaches the syscall depends on
341
+ * whether node has reaped the child yet — so the invariant is asserted structurally.
342
+ */
343
+ get pipeErrorListenerCounts(): Readonly<Record<"stdin" | "stdout" | "stderr", number>> {
344
+ return Object.freeze({
345
+ stdin: this.proc.stdin.listenerCount("error"),
346
+ stdout: this.proc.stdout.listenerCount("error"),
347
+ stderr: this.proc.stderr.listenerCount("error"),
348
+ });
349
+ }
350
+
351
+ /** False once the worker is gone — exited, signalled, or its stdin torn down. */
352
+ private get workerAlive(): boolean {
353
+ return this.proc.exitCode === null
354
+ && this.proc.signalCode === null
355
+ && this.proc.stdin.writable;
356
+ }
357
+
358
+ /** How the worker went away, for diagnostics. */
359
+ private exitDescription(): string {
360
+ if (this.proc.signalCode !== null) return `killed by ${this.proc.signalCode}`;
361
+ if (this.proc.exitCode !== null) return `exited with code ${this.proc.exitCode}`;
362
+ return "stdin closed";
363
+ }
364
+
381
365
  private onData(chunk: string): void {
382
366
  this.buf += chunk;
383
367
  let nl: number;
@@ -419,7 +403,7 @@ export class PythonSandbox {
419
403
  }
420
404
  if (isInterrupt(msg)) {
421
405
  this.touchPending();
422
- void this.serviceInterrupt(msg);
406
+ void serviceInterrupt(msg, this.handlers, (rid, body) => this.reply(rid, body));
423
407
  return;
424
408
  }
425
409
  const p = this.pending.get(msg.id);
@@ -429,85 +413,7 @@ export class PythonSandbox {
429
413
  p.resolve(msg);
430
414
  }
431
415
 
432
- private async serviceInterrupt(msg: WorkerInterrupt): Promise<void> {
433
- const h = this.handlers;
434
- const d = msg.depth;
435
- const opts: SubcallOpts = { detached: msg.detached === true };
436
- try {
437
- if (msg.type === "llm_query") {
438
- const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
439
- this.reply(msg.rid, { response });
440
- } else if (msg.type === "rlm_query") {
441
- const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
442
- this.reply(msg.rid, { response });
443
- } else if (msg.type === "llm_query_batched") {
444
- const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
445
- this.reply(msg.rid, { responses });
446
- } else if (msg.type === "rlm_query_batched") {
447
- const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
448
- this.reply(msg.rid, { responses });
449
- } else if (msg.type === "advance_phase") {
450
- const response = await h.advancePhase(msg.phase ?? "", msg.summary, d);
451
- this.reply(msg.rid, { response });
452
- } else if (msg.type === "save_artifact") {
453
- const response = await h.saveArtifact(msg.artifactKind ?? "", msg.content ?? "", d);
454
- this.reply(msg.rid, { response });
455
- } else if (msg.type === "ask_user_question") {
456
- const answers = await h.askUserQuestion(msg.questions ?? [], d);
457
- this.reply(msg.rid, { answers });
458
- } else if (msg.type === "todo") {
459
- const params = Object.fromEntries(
460
- Object.entries(msg).filter(([key]) => !TODO_PROTO_KEYS.has(key)),
461
- );
462
- const response = await h.todo(msg.action ?? "list", params, d);
463
- this.reply(msg.rid, { response });
464
- } else if (msg.type === "load_library") {
465
- const lib = await h.loadLibrary(msg.source ?? "", d);
466
- if (lib.alreadyLoaded) {
467
- // No temp file — worker short-circuits on already_loaded.
468
- this.reply(msg.rid, {
469
- already_loaded: true,
470
- index: lib.index,
471
- files: 0,
472
- chars: lib.chars,
473
- source_id: lib.sourceId,
474
- path_prefix: lib.pathPrefix,
475
- });
476
- } else {
477
- const isJson = typeof lib.payload !== "string";
478
- const path = await this.writeContextFile(lib.payload, isJson);
479
- // Worker reads then unlinks (worker._load_library). Host must not unlink here —
480
- // if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
481
- this.reply(msg.rid, {
482
- path,
483
- json: isJson,
484
- index: lib.index,
485
- files: lib.files,
486
- chars: lib.chars,
487
- source_id: lib.sourceId,
488
- path_prefix: lib.pathPrefix,
489
- });
490
- }
491
- }
492
- } catch (err) {
493
- this.reply(msg.rid, { error: errorMessage(err) });
494
- }
495
- }
496
-
497
- private reply(rid: string, body: {
498
- response?: string;
499
- responses?: string[];
500
- answers?: AskAnswer[];
501
- path?: string;
502
- json?: boolean;
503
- index?: number;
504
- files?: number;
505
- chars?: number;
506
- source_id?: string;
507
- path_prefix?: string;
508
- already_loaded?: boolean;
509
- error?: string;
510
- }): void {
416
+ private reply(rid: string, body: ReplyBody): void {
511
417
  if (!this.disposed) this.send({ type: "llm_reply", rid, ...body });
512
418
  }
513
419
 
@@ -21,11 +21,37 @@ export function estimateMessageTokens(messages: { content: string }[]): number {
21
21
  return estimateTokens(chars);
22
22
  }
23
23
 
24
- /** Total character length of a context payload (string or list of strings). */
24
+ /**
25
+ * Character length of one context entry. `ContextFile`-shaped entries report their content
26
+ * length; anything else falls back to its serialized form.
27
+ *
28
+ * Deliberately does NOT import `isContextFile` from context/namespace.ts: that module
29
+ * imports `estimateTokens` from here, so the reverse import would be a cycle. `in`-narrowing
30
+ * needs no type guard and no cast.
31
+ */
32
+ function entryLength(entry: unknown): number {
33
+ if (typeof entry === "string") return entry.length;
34
+ if (entry !== null && typeof entry === "object" && "content" in entry) {
35
+ const content: unknown = entry.content;
36
+ if (typeof content === "string") return content.length;
37
+ }
38
+ return JSON.stringify(entry ?? "").length;
39
+ }
40
+
41
+ /**
42
+ * Total character length of a context payload (string, file bundle, or arbitrary value).
43
+ *
44
+ * The array branch must read each entry's `content`: `String(fileEntry)` yields
45
+ * "[object Object]" (15 chars), which under-reported a packed repository by ~200x. This number
46
+ * is what buildMetadataLine tells the model to size its batches against, and it is replayed
47
+ * into the system prompt, so it has to be real.
48
+ */
25
49
  export function contextLength(context: unknown): number {
26
50
  if (typeof context === "string") return context.length;
27
- if (Array.isArray(context)) return context.reduce<number>((n, x) => n + String(x).length, 0);
28
- return JSON.stringify(context ?? "").length;
51
+ if (!Array.isArray(context)) return JSON.stringify(context ?? "").length;
52
+ let total = 0; // running sum — no intermediate array, no per-entry closure
53
+ for (let i = 0; i < context.length; i++) total += entryLength(context[i]);
54
+ return total;
29
55
  }
30
56
 
31
57
  /** Human label for a context payload's type, used in the metadata prompt. */
@@ -48,8 +74,8 @@ const isTokenizedEntry = (v: unknown): v is { readonly tokens: number } =>
48
74
  typeof v === "object" && v !== null && typeof (v as { readonly tokens?: unknown }).tokens === "number";
49
75
 
50
76
  /** Per-file token distribution for a context payload; `undefined` for plain strings or empty arrays.
51
- * Handles both serialized ContextFile[] (flat array from serializeForSandbox) and raw ContextBundle
52
- * objects ({ files: [...] }) so callers don't need to know which form they received. */
77
+ * Handles both a flat ContextFile[] and a raw bundle object ({ files: [...] }) so callers
78
+ * don't need to know which form they received. */
53
79
  export function contextSizeStats(context: unknown): ContextSizeStats | undefined {
54
80
  // Normalise to a flat entry list: accept either a direct array or an object with a .files array.
55
81
  const entries: readonly unknown[] = Array.isArray(context)
@@ -2,7 +2,7 @@
2
2
  * ReplDetails — structured payload for the repl() tool's AgentToolResult<T>.
3
3
  *
4
4
  * Mirrors RlmDetails but scoped to a single code execution. Sub-calls (llm_query,
5
- * rlm_query, todo, ask_user_question) triggered during sandbox execution are
5
+ * rlm_query, add_context) triggered during sandbox execution are
6
6
  * accumulated into the subcalls array for tree rendering.
7
7
  */
8
8
 
@@ -16,7 +16,7 @@ export interface ReplDetails {
16
16
  readonly stderr: string;
17
17
  /** Wall-clock execution time in milliseconds. */
18
18
  readonly executionTimeMs: number;
19
- /** Sub-calls triggered during this execution (llm_query, rlm_query, todo, etc.). */
19
+ /** Sub-calls triggered during this execution (llm_query, rlm_query, etc.). */
20
20
  readonly subcalls: readonly RlmSubcall[];
21
21
  /** Running totals for this repl() call (cost + tokens from sub-LLM calls). */
22
22
  readonly totals: { readonly costUsd: number; readonly tokens: number };
@@ -0,0 +1,58 @@
1
+ /** repl() tool TUI views — collapsed one-liner card and the expanded output/sub-call tree. */
2
+
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
5
+ import type { ReplDetails } from "./repl-details.ts";
6
+ import { cardHeader, cardStatsLine, renderCollapsedCard, renderExpandedSubcallTree } from "./subcall-render.ts";
7
+
8
+ /** Chars of stdout/stderr shown in the expanded view. */
9
+ const EXPANDED_STDOUT_CHARS = 2_000;
10
+ const EXPANDED_STDERR_CHARS = 500;
11
+
12
+ // ── Collapsed view ──
13
+
14
+ export function replStats(details: ReplDetails, theme: Theme): string {
15
+ const elapsed = details.executionTimeMs > 0 ? `${details.executionTimeMs}ms` : undefined;
16
+ return cardStatsLine(details.totals, theme, elapsed, details.backgroundPending);
17
+ }
18
+
19
+ export function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
20
+ return renderCollapsedCard("REPL", details.status, replStats(details, theme), details.subcalls, theme);
21
+ }
22
+
23
+ // ── Expanded view ──
24
+
25
+ export function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
26
+ const container = new Container();
27
+
28
+ container.addChild(new Text(cardHeader("REPL", details.status, replStats(details, theme), theme), 0, 0));
29
+
30
+ // Output
31
+ if (details.output) {
32
+ container.addChild(new Spacer(1));
33
+ const out = details.output.length > EXPANDED_STDOUT_CHARS
34
+ ? `${details.output.slice(0, EXPANDED_STDOUT_CHARS)}…`
35
+ : details.output;
36
+ container.addChild(new Text(out, 0, 0));
37
+ }
38
+
39
+ if (details.warnings && details.warnings.length > 0) {
40
+ container.addChild(new Spacer(1));
41
+ container.addChild(new Text(theme.fg("muted", details.warnings.join("\n")), 0, 0));
42
+ }
43
+
44
+ // Stderr
45
+ if (details.stderr) {
46
+ container.addChild(new Spacer(1));
47
+ container.addChild(new Text(theme.fg("error", details.stderr.slice(0, EXPANDED_STDERR_CHARS)), 0, 0));
48
+ }
49
+
50
+ // Sub-call tree
51
+ if (details.subcalls.length > 0) {
52
+ container.addChild(new Spacer(1));
53
+ container.addChild(new Text(theme.fg("muted", "─── Sub-calls ───"), 0, 0));
54
+ container.addChild(renderExpandedSubcallTree(details.subcalls, theme));
55
+ }
56
+
57
+ return container;
58
+ }