@hicaru/pi-rlm 0.2.1 → 0.2.2

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 (61) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  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 +6 -17
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +55 -335
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +23 -12
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -407
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +8 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/{worker.py → py/worker.py} +76 -696
  30. package/src/sandbox/sandbox-manager.ts +13 -0
  31. package/src/sandbox/sandbox.ts +99 -193
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/repl-details.ts +2 -2
  34. package/src/tool/repl-render.ts +58 -0
  35. package/src/tool/repl-result.ts +70 -0
  36. package/src/tool/repl-tool.ts +37 -159
  37. package/src/tool/rlm-aggregator.ts +2 -10
  38. package/src/tool/rlm-details.ts +0 -2
  39. package/src/tool/rlm-events.ts +0 -14
  40. package/src/tool/rlm-tool.ts +1 -12
  41. package/src/ui/config-panel.ts +4 -16
  42. package/src/ui/intro.ts +1 -2
  43. package/src/ui/model-picker.ts +34 -10
  44. package/src/ui/status.ts +3 -7
  45. package/src/util/concurrency.ts +9 -5
  46. package/src/bridge/fallback-todo.ts +0 -148
  47. package/src/bridge/interactive.ts +0 -65
  48. package/src/bridge/pi-interactive.ts +0 -41
  49. package/src/core/artifacts.ts +0 -89
  50. package/src/core/critique.ts +0 -92
  51. package/src/core/gates.ts +0 -301
  52. package/src/core/pipeline-handlers.ts +0 -319
  53. package/src/core/pipeline.ts +0 -268
  54. package/src/prompts/phases.ts +0 -104
  55. package/src/state/index.ts +0 -24
  56. package/src/state/internal.ts +0 -46
  57. package/src/state/paths.ts +0 -44
  58. package/src/state/reads.ts +0 -133
  59. package/src/state/resume.ts +0 -173
  60. package/src/state/rows.ts +0 -123
  61. 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 { mergeLibraryIntoContext } from "../context/library-context.ts";
9
10
 
10
11
  /** Static configuration for sandbox creation — set once, reused across getOrCreate calls. */
11
12
  export interface SandboxManagerConfig {
@@ -34,6 +35,18 @@ export class SandboxManager {
34
35
 
35
36
  constructor(private readonly config: SandboxManagerConfig) {}
36
37
 
38
+ /**
39
+ * Append a library payload to the context this manager replays on death-recreate.
40
+ *
41
+ * The live worker has ALREADY appended it in-process (worker.py `_append_library`), so this
42
+ * deliberately does not reload — it only keeps the host's replay copy truthful. Without it a
43
+ * recreate silently rolls the sandbox back to a repo-only context, and any child inheriting
44
+ * this payload would never see the library. Dedups by `lib/<id>/` prefix.
45
+ */
46
+ appendLibrary(payload: unknown): void {
47
+ this.contextPayload = mergeLibraryIntoContext(this.contextPayload, payload);
48
+ }
49
+
37
50
  /**
38
51
  * Lazy get-or-create the sandbox. On first call, spawns PythonSandbox with the
39
52
  * given handlers. Subsequent calls return the existing sandbox immediately.
@@ -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 { LibraryLoadResult, 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/library-context.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. */
@@ -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, load_library) 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
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Model-visible text assembly for a repl() result, plus the advisory diagnostics derived from
3
+ * its sub-calls. Split out of repl-tool.ts: this is pure string/array work with no sandbox,
4
+ * emitter, or TUI dependency, and both halves are asserted directly by test/phase-guards.ts.
5
+ */
6
+
7
+ import type { RlmSubcall } from "./rlm-details.ts";
8
+ import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
9
+
10
+ /** Model-visible text assembled from a repl() result. */
11
+ export interface ReplResultText {
12
+ readonly text: string;
13
+ }
14
+
15
+ /**
16
+ * Assemble the model-visible text for a repl() result: cap stdout, append a zero-subcall
17
+ * delegation nudge when a bulk read went undelegated, and report tasks still running.
18
+ *
19
+ * The pending line is the model's only signal that `spawn()`ed work is outstanding — without
20
+ * it a model that spawned and moved on has no way to know it should still collect.
21
+ *
22
+ * `varNames` covers the opposite failure: a block that stores its results in `answers` and
23
+ * prints nothing reads as a bare "(no output)", so the model concludes the block did nothing
24
+ * and re-runs it — paying twice for the same sub-calls. The headless engine already answers
25
+ * this with the same hint (core/answer.ts); native mode was the only path missing it.
26
+ */
27
+ export function buildReplResultText(
28
+ stdout: string,
29
+ finalAnswer: string | undefined,
30
+ subcalls: readonly RlmSubcall[],
31
+ backgroundPending = 0,
32
+ varNames: readonly string[] = [],
33
+ ): ReplResultText {
34
+ const answerSubmitted = finalAnswer !== undefined;
35
+ const noOutput = !answerSubmitted && !stdout;
36
+ const varsHint = noOutput && varNames.length > 0
37
+ ? ` — the block ran fine and these REPL vars are defined: ${varNames.join(", ")}. `
38
+ + "Do NOT re-run it; read them in the next block."
39
+ : "";
40
+ const rawText = answerSubmitted
41
+ ? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
42
+ : stdout || `(no output)${varsHint}`;
43
+ // Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
44
+ const cappedText = capReplResultText(rawText) ?? rawText;
45
+ const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
46
+ const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
47
+ const failedBg = subcalls.filter((s) => s.id.startsWith("bg") && s.status === "error").length;
48
+ const pendingLine = backgroundPending > 0
49
+ ? `\n\n[rlm] ${backgroundPending} background task(s) still running — rlm_await_all(tasks) to collect.`
50
+ : "";
51
+ const failedLine = failedBg > 0
52
+ ? `\n[rlm] ${failedBg} background sub-call(s) FAILED — their rlm_await value is an "Error: …" string, not data.`
53
+ : "";
54
+ return { text: cappedText + (nudge ?? "") + pendingLine + failedLine };
55
+ }
56
+
57
+ /** Advisory diagnostics derived from a completed invocation's sub-calls. */
58
+ export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly string[] | undefined {
59
+ let failed = 0;
60
+ let total = 0;
61
+ for (let i = 0; i < subcalls.length; i++) {
62
+ const call = subcalls[i];
63
+ if (call.status !== "error") continue;
64
+ // A batch subcall stands for many prompts; a single call stands for one.
65
+ failed += call.failedCount ?? 1;
66
+ total += call.totalCount ?? 1;
67
+ }
68
+ if (failed === 0) return undefined;
69
+ return Object.freeze([`${failed}/${total} sub-call(s) failed — results may be incomplete`]);
70
+ }