@hicaru/pi-rlm 0.3.6 → 0.3.9

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 (63) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/bridge/handlers/emitting.ts +5 -23
  4. package/src/bridge/handlers/index.ts +1 -1
  5. package/src/bridge/handlers/llm-query.ts +84 -29
  6. package/src/bridge/handlers/rlm-query.ts +133 -33
  7. package/src/bridge/handlers/types.ts +12 -0
  8. package/src/commands/pins.ts +51 -0
  9. package/src/commands/rlm-config.ts +4 -88
  10. package/src/commands/rlm-llm.ts +59 -0
  11. package/src/commands/rlm-rlm.ts +58 -0
  12. package/src/commands/rlm.ts +2 -2
  13. package/src/config/defaults.ts +17 -0
  14. package/src/config/settings.ts +58 -5
  15. package/src/core/answer.ts +7 -10
  16. package/src/core/budget.ts +182 -0
  17. package/src/core/compaction.ts +46 -0
  18. package/src/core/engine.ts +185 -5
  19. package/src/core/iteration.ts +5 -0
  20. package/src/core/ledger.ts +343 -0
  21. package/src/core/memory.ts +589 -0
  22. package/src/core/model-registry.ts +88 -0
  23. package/src/core/types.ts +44 -3
  24. package/src/index.ts +107 -12
  25. package/src/mode/rlm-mode.ts +58 -10
  26. package/src/prompts/glossary.ts +147 -57
  27. package/src/prompts/native.ts +12 -7
  28. package/src/prompts/system.ts +22 -7
  29. package/src/prompts/user.ts +6 -3
  30. package/src/sandbox/interrupts.ts +24 -0
  31. package/src/sandbox/protocol.ts +69 -5
  32. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  33. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  34. package/src/sandbox/py/guards.py +11 -6
  35. package/src/sandbox/py/scaffold.py +615 -0
  36. package/src/sandbox/py/worker.py +53 -506
  37. package/src/sandbox/sandbox.ts +21 -3
  38. package/src/text/repl-output.ts +15 -0
  39. package/src/tool/repl-render.ts +4 -10
  40. package/src/tool/repl-result.ts +54 -10
  41. package/src/tool/repl-tool.ts +50 -3
  42. package/src/tool/rlm-aggregator.ts +16 -3
  43. package/src/tool/rlm-details.ts +7 -0
  44. package/src/tool/rlm-events.ts +17 -1
  45. package/src/tool/rlm-tool.ts +25 -14
  46. package/src/tool/subcall-render.ts +14 -129
  47. package/src/tool/subcall-store.ts +11 -1
  48. package/src/ui/intro.ts +13 -4
  49. package/src/ui/modal/agent-modal.ts +104 -0
  50. package/src/ui/modal/modal-view.ts +132 -0
  51. package/src/ui/modal/timeline-store.ts +85 -0
  52. package/src/ui/model-picker/drilldown.ts +173 -0
  53. package/src/ui/model-picker/grouping.ts +81 -0
  54. package/src/ui/model-picker/levels.ts +63 -0
  55. package/src/ui/model-picker.ts +7 -197
  56. package/src/ui/panel/run-registry.ts +135 -0
  57. package/src/ui/panel/tree-panel.ts +46 -0
  58. package/src/ui/status.ts +26 -10
  59. package/src/ui/theme.ts +0 -4
  60. package/src/ui/tree/tree-model.ts +221 -0
  61. package/src/ui/tree/tree-rows.ts +73 -0
  62. package/src/ui/tree/tree-widget.ts +186 -0
  63. package/src/util/concurrency.ts +47 -0
@@ -16,19 +16,24 @@ import {
16
16
  createTaskRegistry,
17
17
  type Invocation,
18
18
  } from "../bridge/handlers/index.ts";
19
+ import { TaskLedger, contextSig, taskKey } from "./ledger.ts";
20
+ import { type MemoryStore, rootContextPaths } from "./memory.ts";
19
21
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
20
22
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
21
23
  import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
22
24
  import type { RlmEmitter } from "../tool/rlm-events.ts";
23
- import { PythonSandbox } from "../sandbox/sandbox.ts";
25
+ import type { SubcallPhase } from "../tool/rlm-details.ts";
26
+ import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox.ts";
24
27
  import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
25
28
  import { previewStdout, previewText } from "../text/preview.ts";
26
29
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
27
30
  import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
28
- import { compactHistory, shouldCompact } from "./compaction.ts";
31
+ import { compactHistory, elideOldToolPayloads, shouldCompact } from "./compaction.ts";
29
32
  import { appendUserMessage } from "./history.ts";
30
33
  import { runTurn } from "./iteration.ts";
31
34
  import { type Limits, LimitError, LimitGuard } from "./limits.ts";
35
+ import { continuationPrompt, distillTrajectory, resolveBudget, WRAP_UP_BUDGET } from "./budget.ts";
36
+ import { ModelContextRegistry, modelsCachePath } from "./model-registry.ts";
32
37
  import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
33
38
  import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
34
39
 
@@ -38,6 +43,9 @@ import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
38
43
  * hold a finished run open on work whose result nobody can receive.
39
44
  */
40
45
  const DETACHED_SETTLE_MS = 5_000;
46
+ /** H6 (audit): root episodes snapshot at most this many real files — replay invalidation for
47
+ * the disk-backed slice of the context without hashing an unbounded repository. */
48
+ const ROOT_HASH_MAX = 64;
41
49
 
42
50
 
43
51
  export interface EngineDeps {
@@ -55,6 +63,8 @@ export interface EngineDeps {
55
63
  readonly onUsage?: (usage: Usage, role: "root" | "sub") => void;
56
64
  /** Test-only: override model completion (scripted multi-turn responses). */
57
65
  readonly complete?: import("./iteration.ts").CompleteFn;
66
+ /** v5: session-wide durable memory store (`.rlm/`); omitted → memory off for this engine. */
67
+ readonly memory?: MemoryStore;
58
68
  }
59
69
 
60
70
  /** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
@@ -81,6 +91,17 @@ export function createEngine(deps: EngineDeps): RunRlm {
81
91
  maxTokens: deps.limits?.maxTokens,
82
92
  });
83
93
 
94
+ // v5 token budget: the primary run-length control. A continuation run carries its own
95
+ // budget in `input.budget`; a fresh run resolves one from the model's context window.
96
+ // ONE registry per run (audit M1): shared by budget resolution, and observed when the
97
+ // model metadata already knows the window so the disk cache populates for other callers.
98
+ const modelCtxRegistry = new ModelContextRegistry(modelsCachePath(runCwd));
99
+ const budget =
100
+ input.budget ??
101
+ (deps.config.enableTokenBudget
102
+ ? resolveBudget(contextWindowOrFallback(model, modelCtxRegistry), deps.config)
103
+ : undefined);
104
+
84
105
  // One Invocation for the whole run: this engine owns exactly one sandbox at one depth,
85
106
  // and its emitter and LimitGuard outlive every sub-call it services — including
86
107
  // detached ones, which is why the headless path needs no session registry.
@@ -99,12 +120,66 @@ export function createEngine(deps: EngineDeps): RunRlm {
99
120
  },
100
121
  },
101
122
  };
123
+ // Live activity phase for the tree UI: child engines report on their own subcall
124
+ // node; the root engine has no node, so it reports via the root-phase channel.
125
+ const reportPhase = (phase: SubcallPhase): void => {
126
+ if (selfReportId !== undefined) emitter.emitSubcallUpdated({ id: selfReportId, phase });
127
+ else emitter.emitRootPhase(phase);
128
+ };
102
129
  // Detached work must not outlive the sandbox we dispose in `finally`: track it so the
103
130
  // run can settle or abort it first (a child engine left running would keep spending).
104
131
  let detachedInFlight = 0;
105
132
  let detachedIdle: (() => void) | undefined;
106
133
  // One registry per run — unawaited task reminders share the same map as await handlers.
107
134
  const taskRegistry = createTaskRegistry();
135
+ // v5 TaskLedger: one blackboard per root run; children inherit the same instance via
136
+ // childRun (RlmInput.ledger — the one construction seam, DRY #6).
137
+ const runLedger = input.ledger ?? new TaskLedger();
138
+ if (deps.config.enableLedger) runLedger.beginRun(input.rootPrompt);
139
+
140
+ // v5 durable memory: read-only root replay — an identical prompt over an identical
141
+ // context answers for zero API calls (measured 10,051 → 0 tok in rlm_test).
142
+ const rootMemory =
143
+ deps.memory !== undefined && deps.config.enableMemory ? deps.memory : undefined;
144
+ const modelRefStr = `${model.provider}/${model.id}`;
145
+ const rootKey = taskKey("root", input.rootPrompt, [], modelRefStr, contextSig(input.context));
146
+ if (rootMemory !== undefined && input.depth === 0 && input.budget === undefined) {
147
+ const hit = rootMemory.replay(rootKey);
148
+ if (hit !== undefined) {
149
+ emitter.emitStatus("done");
150
+ return {
151
+ answer: hit.result,
152
+ iterations: 0,
153
+ costUsd: 0,
154
+ inputTokens: 0,
155
+ outputTokens: 0,
156
+ durationMs: 0,
157
+ };
158
+ }
159
+ }
160
+ const persistRoot = (
161
+ answer: string,
162
+ spend?: { readonly inputTokens: number; readonly outputTokens: number },
163
+ ): void => {
164
+ if (rootMemory === undefined || input.depth !== 0) return;
165
+ // H2 (audit): only clean root runs persist — a continuation leaf carries the ORIGINAL
166
+ // run's key (it persists the chain itself), and stopped/aborted partials must never
167
+ // replay as if they were real answers.
168
+ if (input.budget !== undefined) return;
169
+ if (answer === "" || answer === "(aborted)" || answer.startsWith("(stopped")) return;
170
+ const u = spend ?? limits.usage();
171
+ rootMemory.recordEpisode({
172
+ key: rootKey,
173
+ kind: "root",
174
+ model: modelRefStr,
175
+ prompt: input.rootPrompt,
176
+ paths: rootContextPaths(input.context, ROOT_HASH_MAX),
177
+ result: answer,
178
+ tokensIn: u.inputTokens,
179
+ tokensOut: u.outputTokens,
180
+ });
181
+ };
182
+
108
183
  const subcalls = createSubcallHandlers({
109
184
  resolve: () => invocation,
110
185
  gates: deps.gates
@@ -119,6 +194,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
119
194
  // despite being wired before liveContext is assigned — children can only spawn from an
120
195
  // interrupt during runTurn, which is strictly after loadContext below.
121
196
  getChildContext: () => liveContext,
197
+ ledger: runLedger,
198
+ memory: rootMemory,
122
199
  trackDetached: async (task) => {
123
200
  detachedInFlight += 1;
124
201
  try {
@@ -139,6 +216,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
139
216
  detachedIdle = undefined;
140
217
  };
141
218
  let sandbox: PythonSandbox | undefined;
219
+ const watchdogHeartbeat = setInterval(() => {
220
+ if (detachedInFlight > 0) sandbox?.refreshWatchdog();
221
+ }, SANDBOX_WATCHDOG_HEARTBEAT_MS);
222
+ watchdogHeartbeat.unref?.();
142
223
  /**
143
224
  * This run's live context: whatever was seeded plus every source added so far. Children
144
225
  * inherit it, so it must grow when add_context appends (see the handler's onLoaded below).
@@ -163,6 +244,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
163
244
  let compactions = 0;
164
245
  let completedTurns = 0;
165
246
  let nodeStatus: "done" | "error" = "done";
247
+ // v5 budget cascade state: the wrap-up note fires for exactly ONE turn after crossing soft.
248
+ let softFired = false;
249
+ let softNoteTurn = -1;
166
250
 
167
251
  try {
168
252
  const meta = {
@@ -177,10 +261,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
177
261
  maxPromptChars: deps.config.maxPromptChars,
178
262
  contextLoader: deps.config.contextLoader,
179
263
  child: input.depth > 0,
264
+ delegation: input.depth > 0 && deps.config.childSurface === "delegation",
180
265
  depth: input.depth,
181
266
  });
182
267
 
183
- const contextHandlers = deps.config.contextLoader
268
+ // v5 (audit M5): a delegation child does not grow the world — add_context stays root-only.
269
+ const contextHandlers =
270
+ deps.config.contextLoader && (input.depth === 0 || deps.config.childSurface !== "delegation")
184
271
  ? buildAddContextHandler({
185
272
  cwd: runCwd,
186
273
  emitter,
@@ -196,8 +283,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
196
283
  }).handlers
197
284
  : {};
198
285
 
286
+ // v5 doctrine: one condition feeds BOTH the python surface and the memory scope —
287
+ // delegation children keep llm/memory-read/ledger, never repo retrieval or memory.add.
288
+ const surface = input.depth > 0 && deps.config.childSurface === "delegation" ? "child" : "root";
199
289
  sandbox = await PythonSandbox.spawn({
200
290
  depth: input.depth,
291
+ surface,
201
292
  execTimeoutS: deps.config.execTimeoutS,
202
293
  requestTimeoutMs: deps.config.requestTimeoutMs,
203
294
  python: deps.config.python,
@@ -205,7 +296,15 @@ export function createEngine(deps: EngineDeps): RunRlm {
205
296
  initTimeoutMs: deps.config.sandboxInitTimeoutMs,
206
297
  maxPromptChars: deps.config.maxPromptChars,
207
298
  awaitTimeoutS: Math.round(deps.config.requestTimeoutMs / 1000),
208
- handlers: { ...subcalls, ...contextHandlers },
299
+ handlers: {
300
+ ...subcalls,
301
+ ...contextHandlers,
302
+ ledgerClaims: () => Promise.resolve(runLedger.listClaims()),
303
+ memoryOp: (op, args) =>
304
+ Promise.resolve(
305
+ rootMemory === undefined ? "memory off" : rootMemory.serviceOp(op, args, surface === "child" ? "child" : "root"),
306
+ ),
307
+ },
209
308
  });
210
309
 
211
310
  let history: ChatMsg[] = [{ role: "system", content: system }];
@@ -221,6 +320,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
221
320
  else emitter.emitTurn(i + 1, deps.config.maxIterations);
222
321
 
223
322
  if (deps.config.compaction) {
323
+ // v5 G1 first: elide old tool payloads head+tail — often avoids the summary entirely.
324
+ history = elideOldToolPayloads(history);
224
325
  const compactionDeps = {
225
326
  // Summarisation is done by the cheap worker model; the threshold stays on the
226
327
  // root model's context window (that is the window the history fills each turn).
@@ -249,7 +350,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
249
350
  );
250
351
  }
251
352
 
252
- appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations));
353
+ // v5 [ledger] blackboard + [memory] notes — each silent ("") when it has nothing to say.
354
+ const ledgerBlock = deps.config.enableLedger ? runLedger.injectBlock() : "";
355
+ const memoryBlock = rootMemory !== undefined ? rootMemory.injectBlock(input.rootPrompt) : "";
356
+ const notes =
357
+ [
358
+ i === softNoteTurn ? WRAP_UP_BUDGET : undefined,
359
+ ledgerBlock === "" ? undefined : ledgerBlock,
360
+ memoryBlock === "" ? undefined : memoryBlock,
361
+ ]
362
+ .filter((s): s is string => s !== undefined)
363
+ .join("\n\n") || undefined;
364
+ appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, notes));
253
365
 
254
366
  // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
255
367
  const rootSampling: Sampling = {
@@ -262,6 +374,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
262
374
  sampling: rootSampling,
263
375
  signal: deps.signal,
264
376
  complete: deps.complete,
377
+ onPhase: reportPhase,
265
378
  });
266
379
  const allBlocks = turn.blocks.length > 0
267
380
  ? turn.blocks.map((b) => previewText(b, 400)).join("\n")
@@ -280,6 +393,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
280
393
  const final = finalAnswerOf(turn.results);
281
394
  if (final != null) {
282
395
  const done = result(final, i + 1, limits);
396
+ persistRoot(done.answer);
283
397
  lastAnswer = done.answer;
284
398
  return done;
285
399
  }
@@ -287,9 +401,62 @@ export function createEngine(deps: EngineDeps): RunRlm {
287
401
  limits.observe(turnHadError(turn.results));
288
402
  history.push({ role: "assistant", content: turn.response });
289
403
  pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
404
+
405
+ // ── v5 budget cascade ─────────────────────────────────────────────────────
406
+ // Content control lives here; wall-clock timeouts stay hang backstops. Whole-tree
407
+ // tokens (root + sub-LLM) reach `limits` through the invocation's addUsage/addRaw seams.
408
+ if (budget !== undefined) {
409
+ const u = limits.usage();
410
+ budget.observeTotal(u.inputTokens, u.outputTokens);
411
+ const bstate = budget.state();
412
+ if (bstate === "soft" && !softFired) {
413
+ softFired = true;
414
+ softNoteTurn = i + 1;
415
+ if (selfReportId) {
416
+ emitter.emitSubcallUpdated({ id: selfReportId, detail: `budget soft @ ${u.inputTokens + u.outputTokens}/${budget.soft} tok` });
417
+ }
418
+ }
419
+ if (bstate === "hard") {
420
+ if (budget.canContinue()) {
421
+ // Distill the trajectory and chain a fresh run with a fresh spend window —
422
+ // the v4 "finalize NOW" flaw fix: never abort mid-task, restructure-and-resume.
423
+ const handoff = distillTrajectory(history, input.rootPrompt, deps.config.budgetHandoffChars);
424
+ const cont = budget.nextContinuation();
425
+ if (selfReportId) {
426
+ emitter.emitSubcallUpdated({ id: selfReportId, detail: `budget hard → continuation ${cont.continuations}` });
427
+ }
428
+ const inner = await run({
429
+ ...input,
430
+ rootPrompt: continuationPrompt(cont.continuations, handoff),
431
+ context: liveContext, // H9: sources added mid-run reach the leaf
432
+ budget: cont,
433
+ remainingTimeoutMs: limits.remainingTimeoutMs(),
434
+ });
435
+ // H9: report the CHAIN's spend, not just the leaf's fresh guard.
436
+ const u = limits.usage();
437
+ const chained: RlmResult = {
438
+ ...inner,
439
+ iterations: inner.iterations + completedTurns,
440
+ inputTokens: inner.inputTokens + u.inputTokens,
441
+ outputTokens: inner.outputTokens + u.outputTokens,
442
+ costUsd: inner.costUsd + u.costUsd,
443
+ };
444
+ // H2: the ORIGINAL run persists the chain's answer under the ORIGINAL key —
445
+ // the next identical prompt must replay the full result, not miss.
446
+ // R2: lastAnswer must be set before return — `finally` emitAnswer reads it,
447
+ // and persist must store the CHAIN totals, not just the parent window.
448
+ lastAnswer = chained.answer;
449
+ persistRoot(chained.answer, chained);
450
+ return chained;
451
+ }
452
+ // Chain cap reached — finalize with the best partial (a budget never throws).
453
+ break;
454
+ }
455
+ }
290
456
  }
291
457
  if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
292
458
  const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
459
+ persistRoot(finalized.answer);
293
460
  lastAnswer = finalized.answer;
294
461
  return finalized;
295
462
  } catch (err) {
@@ -308,6 +475,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
308
475
  nodeStatus = "error";
309
476
  throw err;
310
477
  } finally {
478
+ if (deps.config.enableLedger) runLedger.endRun();
311
479
  if (selfReportId) {
312
480
  emitter.emitSubcallUpdated({
313
481
  id: selfReportId,
@@ -319,6 +487,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
319
487
  if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
320
488
  emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
321
489
  }
490
+ clearInterval(watchdogHeartbeat);
322
491
  // Settle detached work FIRST: a child still running may be about to pin this same payload.
323
492
  await settleDetached();
324
493
  await contextPin?.release();
@@ -333,6 +502,17 @@ function result(answer: string, iterations: number, limits: LimitGuard): RlmResu
333
502
  return { answer, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
334
503
  }
335
504
 
505
+ /** Model metadata window, else the offline registry fallback (disk cache → table → 32k). */
506
+ /** Model metadata window, else the offline registry fallback (disk cache → table → 32k).
507
+ * When metadata provides the window it is observed into the cache (fail-soft, audit M1). */
508
+ function contextWindowOrFallback(model: Model<Api>, registry: ModelContextRegistry): number {
509
+ if (model.contextWindow !== undefined && model.contextWindow > 0) {
510
+ registry.observe(`${model.provider}/${model.id}`, model.contextWindow);
511
+ return model.contextWindow;
512
+ }
513
+ return registry.limitFor(`${model.provider}/${model.id}`);
514
+ }
515
+
336
516
  /** Out of turns: ask the model for its best final answer (plain text). */
337
517
  async function finalize(history: ChatMsg[], model: Model<Api>, deps: EngineDeps, limits: LimitGuard): Promise<string> {
338
518
  const finalHistory = [...history];
@@ -10,6 +10,7 @@ import { type ChatMsg, type CompleteOptions, type CompleteResult, modelComplete
10
10
  import type { ReplResult } from "../sandbox/protocol.ts";
11
11
  import type { PythonSandbox } from "../sandbox/sandbox.ts";
12
12
  import { findReplBlocks } from "../text/parsing.ts";
13
+ import type { SubcallPhase } from "../tool/rlm-details.ts";
13
14
  import type { Sampling } from "./types.ts";
14
15
 
15
16
  export interface Turn {
@@ -30,10 +31,13 @@ export interface TurnDeps {
30
31
  readonly signal?: AbortSignal;
31
32
  /** Test-only override for model completion (scripted responses). */
32
33
  readonly complete?: CompleteFn;
34
+ /** Live activity reporting for the tree UI (thinking → repl/texting per turn). */
35
+ readonly onPhase?: (phase: SubcallPhase) => void;
33
36
  }
34
37
 
35
38
  export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbox, deps: TurnDeps): Promise<Turn> {
36
39
  const complete = deps.complete ?? modelComplete;
40
+ deps.onPhase?.("thinking");
37
41
  const { text, usage } = await complete(history, {
38
42
  model: deps.model,
39
43
  registry: deps.registry,
@@ -44,6 +48,7 @@ export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbo
44
48
  });
45
49
 
46
50
  const blocks = findReplBlocks(text);
51
+ deps.onPhase?.(blocks.length > 0 ? "repl" : "texting");
47
52
  const results = new Array<ReplResult>(blocks.length);
48
53
  let executed = 0;
49
54
  for (let i = 0; i < blocks.length; i++) {