@earendil-works/pi-durable 1.0.4 → 1.2.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 (196) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +149 -11
  3. package/dist/codemode/codemode.d.ts +173 -0
  4. package/dist/codemode/codemode.d.ts.map +1 -0
  5. package/dist/codemode/codemode.js +886 -0
  6. package/dist/codemode/codemode.js.map +1 -0
  7. package/dist/codemode/describe.d.ts +38 -0
  8. package/dist/codemode/describe.d.ts.map +1 -0
  9. package/dist/codemode/describe.js +119 -0
  10. package/dist/codemode/describe.js.map +1 -0
  11. package/dist/codemode/index.d.ts +3 -0
  12. package/dist/codemode/index.d.ts.map +1 -0
  13. package/dist/codemode/index.js +3 -0
  14. package/dist/codemode/index.js.map +1 -0
  15. package/dist/codemode/models.d.ts +28 -0
  16. package/dist/codemode/models.d.ts.map +1 -0
  17. package/dist/codemode/models.js +231 -0
  18. package/dist/codemode/models.js.map +1 -0
  19. package/dist/codemode/output.d.ts +41 -0
  20. package/dist/codemode/output.d.ts.map +1 -0
  21. package/dist/codemode/output.js +167 -0
  22. package/dist/codemode/output.js.map +1 -0
  23. package/dist/env/index.d.ts +42 -0
  24. package/dist/env/index.d.ts.map +1 -1
  25. package/dist/env/index.js.map +1 -1
  26. package/dist/errors.d.ts +20 -3
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +34 -5
  29. package/dist/errors.js.map +1 -1
  30. package/dist/harness/agent.d.ts +5 -2
  31. package/dist/harness/agent.d.ts.map +1 -1
  32. package/dist/harness/agent.js +72 -25
  33. package/dist/harness/agent.js.map +1 -1
  34. package/dist/harness/compaction.d.ts +6 -1
  35. package/dist/harness/compaction.d.ts.map +1 -1
  36. package/dist/harness/compaction.js +11 -8
  37. package/dist/harness/compaction.js.map +1 -1
  38. package/dist/harness/context.d.ts +29 -1
  39. package/dist/harness/context.d.ts.map +1 -1
  40. package/dist/harness/context.js +125 -24
  41. package/dist/harness/context.js.map +1 -1
  42. package/dist/harness/events.d.ts +35 -15
  43. package/dist/harness/events.d.ts.map +1 -1
  44. package/dist/harness/events.js +77 -23
  45. package/dist/harness/events.js.map +1 -1
  46. package/dist/harness/generation.d.ts +2 -0
  47. package/dist/harness/generation.d.ts.map +1 -1
  48. package/dist/harness/generation.js +34 -11
  49. package/dist/harness/generation.js.map +1 -1
  50. package/dist/harness/harness.d.ts +7 -0
  51. package/dist/harness/harness.d.ts.map +1 -1
  52. package/dist/harness/harness.js +56 -28
  53. package/dist/harness/harness.js.map +1 -1
  54. package/dist/harness/inbox.d.ts +16 -4
  55. package/dist/harness/inbox.d.ts.map +1 -1
  56. package/dist/harness/inbox.js +26 -3
  57. package/dist/harness/inbox.js.map +1 -1
  58. package/dist/harness/live.d.ts +74 -17
  59. package/dist/harness/live.d.ts.map +1 -1
  60. package/dist/harness/live.js +66 -14
  61. package/dist/harness/live.js.map +1 -1
  62. package/dist/harness/scheduler.d.ts +18 -5
  63. package/dist/harness/scheduler.d.ts.map +1 -1
  64. package/dist/harness/scheduler.js +794 -168
  65. package/dist/harness/scheduler.js.map +1 -1
  66. package/dist/harness/submissions.d.ts +7 -4
  67. package/dist/harness/submissions.d.ts.map +1 -1
  68. package/dist/harness/submissions.js +32 -13
  69. package/dist/harness/submissions.js.map +1 -1
  70. package/dist/harness/task-graph.d.ts +1 -1
  71. package/dist/harness/task-graph.d.ts.map +1 -1
  72. package/dist/harness/task-graph.js +15 -10
  73. package/dist/harness/task-graph.js.map +1 -1
  74. package/dist/harness/tool.d.ts +45 -5
  75. package/dist/harness/tool.d.ts.map +1 -1
  76. package/dist/harness/tool.js +388 -63
  77. package/dist/harness/tool.js.map +1 -1
  78. package/dist/harness/types.d.ts +171 -15
  79. package/dist/harness/types.d.ts.map +1 -1
  80. package/dist/harness/types.js.map +1 -1
  81. package/dist/harness/util.d.ts +6 -1
  82. package/dist/harness/util.d.ts.map +1 -1
  83. package/dist/harness/util.js +5 -2
  84. package/dist/harness/util.js.map +1 -1
  85. package/dist/harness/view.d.ts +6 -2
  86. package/dist/harness/view.d.ts.map +1 -1
  87. package/dist/harness/view.js +22 -12
  88. package/dist/harness/view.js.map +1 -1
  89. package/dist/images/cloudflare.d.ts +4 -0
  90. package/dist/images/cloudflare.d.ts.map +1 -0
  91. package/dist/images/cloudflare.js +9 -0
  92. package/dist/images/cloudflare.js.map +1 -0
  93. package/dist/images/exif.d.ts +3 -0
  94. package/dist/images/exif.d.ts.map +1 -0
  95. package/dist/images/exif.js +71 -0
  96. package/dist/images/exif.js.map +1 -0
  97. package/dist/images/index.d.ts +18 -0
  98. package/dist/images/index.d.ts.map +1 -0
  99. package/dist/images/index.js +125 -0
  100. package/dist/images/index.js.map +1 -0
  101. package/dist/images/node-worker.d.ts +17 -0
  102. package/dist/images/node-worker.d.ts.map +1 -0
  103. package/dist/images/node-worker.js +16 -0
  104. package/dist/images/node-worker.js.map +1 -0
  105. package/dist/images/node.d.ts +21 -0
  106. package/dist/images/node.d.ts.map +1 -0
  107. package/dist/images/node.js +121 -0
  108. package/dist/images/node.js.map +1 -0
  109. package/dist/index.d.ts +6 -6
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +3 -3
  112. package/dist/index.js.map +1 -1
  113. package/dist/search.d.ts +13 -0
  114. package/dist/search.d.ts.map +1 -0
  115. package/dist/search.js +85 -0
  116. package/dist/search.js.map +1 -0
  117. package/dist/session/observation.d.ts +10 -3
  118. package/dist/session/observation.d.ts.map +1 -1
  119. package/dist/session/observation.js +24 -34
  120. package/dist/session/observation.js.map +1 -1
  121. package/dist/session/session.d.ts +71 -6
  122. package/dist/session/session.d.ts.map +1 -1
  123. package/dist/session/session.js +463 -50
  124. package/dist/session/session.js.map +1 -1
  125. package/dist/session/transaction.d.ts +2 -0
  126. package/dist/session/transaction.d.ts.map +1 -1
  127. package/dist/session/transaction.js +19 -1
  128. package/dist/session/transaction.js.map +1 -1
  129. package/dist/storage/jsonl/storage.d.ts +8 -0
  130. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  131. package/dist/storage/jsonl/storage.js +2 -1
  132. package/dist/storage/jsonl/storage.js.map +1 -1
  133. package/dist/storage/memory.d.ts +2 -0
  134. package/dist/storage/memory.d.ts.map +1 -1
  135. package/dist/storage/memory.js +79 -27
  136. package/dist/storage/memory.js.map +1 -1
  137. package/dist/storage/scan.d.ts +18 -0
  138. package/dist/storage/scan.d.ts.map +1 -0
  139. package/dist/storage/scan.js +29 -0
  140. package/dist/storage/scan.js.map +1 -0
  141. package/dist/storage/sqlite/cloudflare.d.ts +46 -0
  142. package/dist/storage/sqlite/cloudflare.d.ts.map +1 -0
  143. package/dist/storage/sqlite/cloudflare.js +106 -0
  144. package/dist/storage/sqlite/cloudflare.js.map +1 -0
  145. package/dist/storage/sqlite/storage.d.ts +2 -0
  146. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  147. package/dist/storage/sqlite/storage.js +77 -27
  148. package/dist/storage/sqlite/storage.js.map +1 -1
  149. package/dist/testing/env-conformance.d.ts.map +1 -1
  150. package/dist/testing/env-conformance.js +100 -0
  151. package/dist/testing/env-conformance.js.map +1 -1
  152. package/dist/testing/runner.d.ts +1 -1
  153. package/dist/testing/runner.d.ts.map +1 -1
  154. package/dist/testing/runner.js.map +1 -1
  155. package/dist/testing/storage-conformance.d.ts.map +1 -1
  156. package/dist/testing/storage-conformance.js +61 -2
  157. package/dist/testing/storage-conformance.js.map +1 -1
  158. package/dist/testing/types.d.ts +2 -0
  159. package/dist/testing/types.d.ts.map +1 -1
  160. package/dist/testing/types.js.map +1 -1
  161. package/dist/tool-search/index.d.ts +2 -0
  162. package/dist/tool-search/index.d.ts.map +1 -0
  163. package/dist/tool-search/index.js +2 -0
  164. package/dist/tool-search/index.js.map +1 -0
  165. package/dist/tool-search/tool-search.d.ts +23 -0
  166. package/dist/tool-search/tool-search.d.ts.map +1 -0
  167. package/dist/tool-search/tool-search.js +84 -0
  168. package/dist/tool-search/tool-search.js.map +1 -0
  169. package/dist/tools/bash.d.ts +10 -2
  170. package/dist/tools/bash.d.ts.map +1 -1
  171. package/dist/tools/bash.js +27 -9
  172. package/dist/tools/bash.js.map +1 -1
  173. package/dist/tools/edit.js +1 -1
  174. package/dist/tools/edit.js.map +1 -1
  175. package/dist/tools/image-processor.d.ts +47 -0
  176. package/dist/tools/image-processor.d.ts.map +1 -0
  177. package/dist/tools/image-processor.js +30 -0
  178. package/dist/tools/image-processor.js.map +1 -0
  179. package/dist/tools/image.d.ts +8 -0
  180. package/dist/tools/image.d.ts.map +1 -1
  181. package/dist/tools/image.js +47 -0
  182. package/dist/tools/image.js.map +1 -1
  183. package/dist/tools/index.d.ts +25 -2
  184. package/dist/tools/index.d.ts.map +1 -1
  185. package/dist/tools/index.js +13 -5
  186. package/dist/tools/index.js.map +1 -1
  187. package/dist/tools/read.d.ts +10 -2
  188. package/dist/tools/read.d.ts.map +1 -1
  189. package/dist/tools/read.js +87 -28
  190. package/dist/tools/read.js.map +1 -1
  191. package/dist/tools/write.js +1 -1
  192. package/dist/tools/write.js.map +1 -1
  193. package/dist/types.d.ts +89 -12
  194. package/dist/types.d.ts.map +1 -1
  195. package/dist/types.js.map +1 -1
  196. package/package.json +50 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.0] - 2026-10-12
4
+
5
+ ### Breaking Changes
6
+
7
+ - `ToolTaskInput` and `ToolTaskResult` are tagged unions: a model-issued call is `{ kind: "model", ... }` with its previous fields, a nested call `{ kind: "nested", ... }`. `pi.tool` is version 2 and migrates live version 1 tasks.
8
+ - `ToolExecutionResult.content` is now `output`, next to the new `structuredOutput`.
9
+ - `bash` and `powershell` answer a nonzero exit with an error result carrying an `exit_code` diagnostic, instead of throwing (`tool_error`).
10
+ - Any error a `Storage` method throws, a read included, now fails the Session, which closes itself: every later call and every pending wait rejects with `SessionFailed`, whose `cause` is that error, and nothing is retried. `StorageRejected` is removed; a storage retries its own transient failures and throws the new `StorageRequestError` for an invalid request, which fails only that call. A throw in the scheduler's own commits, or in the commit listeners of the Harness's components, fails the Session too.
11
+ - `subscribeCommits()` and `subscribeClose()` listeners that throw, or return a rejected promise, are reported through `onReport` instead of failing the operation that ran them.
12
+ - A display-only write submission, one of only `kind` and `data` (no `model`, `head`, or `edits`), is appended and settled `done` at once, even while the conversation is busy or has queued items, instead of waiting in `pi.inbox` for the next boundary; only a queued reset still holds it back, so the reset does not hide it. It changes nothing the model sees, so a chat message shows as soon as it is sent, ahead of earlier submissions still queued, and it no longer places items left queued after a failed run ([#10407](https://github.com/earendil-works/pi/issues/10407)).
13
+
14
+ ### Added
15
+
16
+ - Optional `ExecutionEnv.listFiles(path, options, context)` for bounded, gitignore-aware tree listings (`ListFilesOptions`, `FileListing`, `FileListEntry`); `registerEnvConformance` takes a `listFiles` option to cover it.
17
+ - Input submissions take `entry: { kind, data }` (`InputEntry`): the input is placed as an entry of that kind carrying that data, with its content as the user message, instead of `pi.user`, and behaves as any other input; the kind must not start with `pi.`. Queued inputs carry it in their `pi.inbox` item ([#10407](https://github.com/earendil-works/pi/issues/10407)).
18
+ - `Session.closed` settles once the Session has closed, Storage included: `{ reason: "closed" }` after `close()`, or `{ reason: "failed", error }` after a storage error. `WatchEnd` gains `{ reason: "session_failed", error }`, and the README has an Errors section.
19
+ - `ToolExecutionApi.executeTool(name, args, context, { key?, progress? })`: a tool calls another tool as a nested call. The nested call is its own `pi.tool` task owned by the caller, runs validation, the `ToolTask` hooks (whose `ToolHookCall` carries `parent`), output limits, and its replay policy, and returns a `NestedToolExecutionResult` (its `taskId`, `structuredOutput`, `details`, `diagnostics`, `usage`, `isError`, `durationMs`, but not the model's `output`) to the caller instead of the transcript. Each nested call is a member of `NestedCallDoc`, a task-scoped document family of the caller keyed by the call's key, holding its `taskId` and, once it ends, its `result` as returned, so it is gone from storage once the caller settles; its task's receipt stays small. Keys, by default the call's position, let a replay-safe caller's rerun reattach to its nested calls; a caller that is not replay-safe has its unfinished nested calls abandoned after a restart. Nested calls show in `pi.live.nestedTools`, with the arguments they run with and a `summary` (error, duration, usage) once done (`progress: false` keeps their running output out), and as tool events with `parentToolCallId` and `parentTaskId`; every tool event also carries its call's `taskId`. A caller aborts the nested calls it left running before it settles.
20
+ - Structured tool output: a tool declares `structuredOutputSchema` and returns `structuredOutput`, which the Harness validates and gives to programs that call the tool; a tool without a schema gives them its bounded output, one text item as a string, one image as itself, and otherwise the content list, for errors too. `structuredOutput` is always the tool's own value: a schema tool's error result keeps the value it returned, and the text of one without becomes a `tool_error` diagnostic of the nested result, as a thrown error's message does. A nested call with invalid structured output gets an error result; a model-issued one only loses it, and the host gets a report. `api.retainedOutput()` reads the bounded output the model will see. `bash` and `powershell` return `{ output, truncated, fullOutputPath?, exitCode }`.
21
+ - `ToolRegistration.callers` (`model`, `tools`, or both, the default) and `pi.agent` `modelTools`: who may call a tool, and which of its tools a conversation offers the model; `Agent.callable` lists the tools nested calls resolve among. `addTools` also extends `modelTools`.
22
+ - Deferred tools: `ToolRegistration.deferred` keeps a tool from the model until `modelTools` names it, in its array or its new `add` (`{ remove?, add? }`); `control.addTools` writes names to `add`, and loaded tools follow the others in load order, so a load only appends declarations. `Agent.deferred` lists the deferred tools not loaded yet. `@earendil-works/pi-durable/tool-search` provides the coding agent's `tool_search` tool, which searches them with BM25 and loads the matches. The `codemode` section leaves deferred tools out.
23
+ - `TaskOptions.abandonOnRestart`: a task its creator awaits only in memory is aborted, with its owned work, when a later Harness starts scheduling, before any of it runs again. Its mark carries `abortReason: "restart"`, which cascades; such a task waits for a missing definition instead of becoming `orphaned`. Nested calls and `api.createTask()` children of tools that are not replay-safe get it by default. No task below an owner with cancellation intent starts a run phase any more, even before the cascade marks it.
24
+ - `TaskRuntime.abortOwned(id, context)`: abort a task this task owns and wait until it is terminal.
25
+ - `TaskRuntime.ownedTasks(context)`: the live tasks a task owns directly, whichever invocation created them, as copies. A tool call settling finds the nested calls it left running with it.
26
+ - `@earendil-works/pi-durable/codemode`: a `codemode` tool (`createCodemodeExtension({ sandbox })`) whose scripts call the conversation's tools as nested calls and survive a restart: the script is replayed deterministically from a journal of the results it got, written before each delivery, reattaching to its nested calls, and then continues. Divergence, a changed sandbox fingerprint or module version, and a passed deadline end the call with an error. As in the coding agent, a `codemode` prompt section lists the tools scripts can call (`namespaces`, an inline token budget), scripts find tools with `searchTools()`, `describeTool()`, and `describeNamespace()`, and the result is laid out with a header, text item markers, a console block, truncation to `max_output_tokens` with a spill file, and saved images. Scripts get `models.*` (the catalog, `models.classify()`, `models.generateImages()`; the last two as nested calls of internal tools, at most four at once), and the tool declares the Lark grammar of its source for models that support grammar-constrained sampling. A failed call rejects in the script with its text, except an error result of a schema tool with its own value, which resolves: `tools.bash` gives `{ output, exitCode, ... }` for a nonzero exit. Scripts keep `store()`/`load()` values per conversation, forked as of the fork's entry. The result's `details.calls` list the script's tool calls with their status; live progress is in `pi.live.nestedTools` (`npm run bench:codemode`).
27
+ - `read` returns images as one image block instead of an `unsupported_image` error: PNG, JPEG, GIF, and WebP within the model's image limits (by default 2000x2000 pixels and 4.5 MB of base64) as they are, or, with `createCodingTools({ images })`, oriented, converted, and shrunk to fit. `@earendil-works/pi-durable/images` provides a Photon (WebAssembly) image processor, with `/images/node` and `/images/cloudflare` loaders; `/images/node` prepares images in a worker thread, so a large photo never blocks the event loop, and the thread exits after 30 s without images; nothing loads it unless imported. A model without image input gets a diagnostic saying it sees a placeholder.
28
+ - `pi.live.compactionFailure` keeps the newest compaction failure (task, reason, error) until a compaction produces a summary; the agent events' snapshot carries it as `compactionFailure`, and `compaction_end` carries `error` for a compaction that failed. A failed background or manual compaction was invisible once its status was removed.
29
+
30
+ ### Fixed
31
+
32
+ - A wait that closes a cycle through waits and held owners is rejected by `runtime.commit()`, naming the cycle, instead of leaving every task on it waiting forever; waiting on itself or on an owner chain were the only cycles caught. A background task may now wait on the owner of its conversation, which is no cycle ([#10411](https://github.com/earendil-works/pi/issues/10411); tests and approach from [#10533](https://github.com/earendil-works/pi/pull/10533) by [@odysseus0](https://github.com/odysseus0))
33
+ - Appending to an array in a document no longer costs a walk over the whole array per commit: Chord's delta tracker plans inserts and removals that keep the remaining elements in order from the changed ranges alone, and builds an appended array in one copy instead of two. 32,000 one-item appends, one commit each, take 1.4 s instead of 11.3 s; what remains is the copy each immutable value needs (`npm run bench:document-append`).
34
+ - A generation whose blocking threshold compaction failed, faulted, was orphaned, or was aborted directly now fails with `model_error` (`Compaction failed: <error>`) instead of sending its request anyway. The context was still above the blocking threshold, so the request at best left no room for the answer: a session at 996K of a 1M window got an empty `length` answer on every turn, with no visible error.
35
+
36
+ - Compaction no longer includes thinking in the summarization request. Replayed thinking is often not in the live context, so a request that included it could be larger than the context it summarized and fail with "prompt is too long" on every attempt.
37
+ - A throwing `onReport` no longer replaces the error being reported or becomes an unhandled rejection, a throwing `now()` is reported once and `Date.now` used, and a watch listener's error is reported as well as ending the watch with `listener_error`.
38
+ - A task whose fault write failed no longer runs again in the same process.
39
+ - The scheduler no longer visits every live task on each pass: abort cascades start only from tasks with cancellation intent, owned live work and idle scopes are walks down an in-memory ownership index, and reservation, finalization, and failFast checks keep their own candidate sets. A chain of 500 owned tasks that took 12 s to settle takes 50 ms, 20,000 take 1.5 s; a tool making 8000 parallel nested calls finishes in 2.7 s instead of 18.8 s, with one document per nested call instead of one growing index (`npm run bench:scheduler-fanout`).
40
+ - The scheduler's ownership indexes are bounded by live work: it keeps a conversation's owner and an ended task's ownership only while live work is below them, instead of every conversation and subagent chain it ever saw. Work that later appears in an old conversation loads its chain from storage again. After 10,000 ended subagent calls under a live parent it kept 10,000 ended tasks and 10,001 conversation edges; now none of either beyond the parent's conversation (`npm run bench:scheduler-memory`). A queued input no longer makes the scheduler scan every queued submission when nothing is being cancelled.
41
+ - The Session's document cache is bounded by use: a loaded document that no watch or document state observes leaves memory once it was not read or written for `documentRetentionMs` (new in `HarnessSettings` and `createSession()` options; default ten minutes, `0` drops it right after use) and loads from Storage again on its next access. It held every conversation and session document ever touched, built-in and third-party, for the life of the process: 5,000 conversations kept 26,000 documents and about 3.8 KB each; with retention 0 they keep none (`npm run bench:document-cache`).
42
+
43
+ ## [1.1.0] - 2026-10-07
44
+
45
+ ### Breaking Changes
46
+
47
+ - `Storage` implementations must honor the new `order` field of `ConversationQuery`, `EntryQuery`, `TaskQuery`, and `SubmissionQuery`, and continue a cursor in the order it was returned with. A storage that ignores it returns pages in the wrong direction; the conformance suite covers both orders ([#10546](https://github.com/earendil-works/pi/issues/10546)).
48
+ - `TaskRuntime.context()` takes the cutoff as an options object, matching `Conversation.context()`: `runtime.context(conversationId, context, { at })` replaces `runtime.context(conversationId, context, at)`.
49
+
50
+ ### Added
51
+
52
+ - `ToolExecutionApi.models` and `HookApi.models`: the Harness's `models`, so tools and hooks can resolve models and make requests with the same catalog, credentials, and request transforms as generation ([#10395](https://github.com/earendil-works/pi/issues/10395)).
53
+ - `Conversation.context(context, { at })` returns the model context as of a visible earlier entry, the same view `fork(at)` would start with, without creating a conversation ([#10512](https://github.com/earendil-works/pi/issues/10512)).
54
+ - `ScanOrder` (`ascending` or `descending`) as `order` on conversation, entry, task, and submission scans, including `Tx.scanTasks()` and `Conversation.entries()`, so hosts can page the newest tasks first. Defaults are unchanged: entries newest first, everything else oldest first. A cursor continues in its scan's order, so the query may omit `order`; asking for the other order with it throws ([#10546](https://github.com/earendil-works/pi/issues/10546)).
55
+ - Task records carry `startedAt` and `endedAt`, wall-clock times the Session stamps at the first change to `running` and at the change to `terminal`, with `HarnessOptions.now` or the new `createSession(storage, { now })` option. `startedAt` survives waits and reopen. `inspect()` shows them on each live task's record. Records written by earlier versions have neither ([#10549](https://github.com/earendil-works/pi/issues/10549)).
56
+ - `pi.tool-result` messages carry `durationMs`: how long `execute()` took in that attempt, measured with a monotonic clock. Calls that did not execute, and interrupted or aborted calls, have none ([#10549](https://github.com/earendil-works/pi/issues/10549)).
57
+ - Assistant messages carry the `durationMs` pi-ai measures for each response ([#10549](https://github.com/earendil-works/pi/issues/10549)).
58
+ - `openDurableObjectSqliteStorage(ctx.storage)` in `@earendil-works/pi-durable/storage/sqlite/cloudflare`: SQLite storage on a SQLite-backed Cloudflare Durable Object.
59
+ - `settings.contextRetentionMs` (default ten minutes): how long an idle conversation keeps its last context read in memory; `0` drops it once idle.
60
+
61
+ ### Changed
62
+
63
+ - `runtime.context()` keeps each conversation's last read range and derived view in memory: a later read by any of its tasks with the same head marker scans and derives only the entries committed since. A generation no longer rereads and re-derives the whole transcript for each model request. Busy conversations keep it; idle ones for `settings.contextRetentionMs`.
64
+
65
+ ### Fixed
66
+
67
+ - A conversation's first system message, and the baseline after a compaction or reset, now leads the model context instead of following the input that started the run. Providers with native mid-conversation tool changes, such as Anthropic, keep the prompt cache across later tool changes instead of rewriting it in full ([#10542](https://github.com/earendil-works/pi/issues/10542)).
68
+
3
69
  ## [1.0.4] - 2026-10-05
4
70
 
5
71
  ### Breaking Changes
package/README.md CHANGED
@@ -14,6 +14,7 @@ Built on [`@earendil-works/pi-ai`](../ai/README.md) for model access and `@earen
14
14
  - [Persist and Resume](#persist-and-resume)
15
15
  - [Extensions](#extensions)
16
16
  - [Tools](#tools)
17
+ - [Code Mode](#code-mode)
17
18
  - [System Prompt](#system-prompt)
18
19
  - [Per-Conversation Agent](#per-conversation-agent)
19
20
  - [Settings](#settings)
@@ -32,6 +33,7 @@ Built on [`@earendil-works/pi-ai`](../ai/README.md) for model access and `@earen
32
33
  - [Your Own State](#your-own-state)
33
34
  - [Usage and Cost](#usage-and-cost)
34
35
  - [Storage](#storage)
36
+ - [Errors](#errors)
35
37
  - [Examples](#examples)
36
38
  - [Design Documents](#design-documents)
37
39
 
@@ -146,9 +148,20 @@ An extension may bring `tools`, `sections`, `hooks`, `wraps` (decorators of a to
146
148
 
147
149
  ## Tools
148
150
 
149
- `@earendil-works/pi-durable/tools` provides `read`, `write`, `edit`, and `bash`, and the `CodingTools` extension with all four. They touch files and processes only through the call's environment (see [Environment](#environment)). Reading images is not supported yet.
151
+ `@earendil-works/pi-durable/tools` provides `read`, `write`, `edit`, and `bash`, and the `CodingTools` extension with all four. They touch files and processes only through the call's environment (see [Environment](#environment)).
150
152
 
151
- Define your own tool with a TypeBox schema. `defineTool()` types `args` from `parameters`, which the Harness validates before `execute()`. `api.output()` streams running output, which becomes the result when `execute()` returns no `content`:
153
+ `read` returns an image file as one image block, which programs calling it get as an `ImageContent`. Limits are the model's `inputLimits.images.resize`, by default 2000x2000 pixels and 4.5 MB of base64. Without an image processor it sends PNG, JPEG, GIF, and WebP files within them (dimensions read from the file's header) as they are, without decoding them, and refuses larger ones and other formats. With one, it decodes every image, turns it upright by its EXIF orientation, converts BMP to PNG, and shrinks it to fit. The Photon-based processor runs on WebAssembly and is only loaded when you import it:
154
+
155
+ ```typescript
156
+ import { createNodePhotonImages } from "@earendil-works/pi-durable/images/node"; // or /images/cloudflare, or createPhotonImages(wasm) from /images
157
+ import { createCodingTools } from "@earendil-works/pi-durable/tools";
158
+
159
+ registry.install(createCodingTools({ images: await createNodePhotonImages() }));
160
+ ```
161
+
162
+ A model without image input sees a placeholder instead of the image, and `read` says so in a diagnostic. `/images/node` prepares images in a worker thread, so a large photo (a second or two of work) never blocks the event loop; the worker exits after 30 s without images, freeing Photon's WebAssembly memory (`createNodePhotonImages({ worker: false, idleMs })`). Elsewhere, `createPhotonImages()` and `/images/cloudflare` work on the calling thread, and decoding takes the image's full size in WebAssembly memory, which does not shrink again; leave the processor out on memory-constrained hosts such as Cloudflare Workers unless images stay small.
163
+
164
+ Define your own tool with a TypeBox schema. `defineTool()` types `args` from `parameters`, which the Harness validates before `execute()`. `api.output()` streams running output, which becomes the result when `execute()` returns no `output`:
152
165
 
153
166
  ```typescript
154
167
  import { Type } from "@earendil-works/pi-ai";
@@ -167,6 +180,24 @@ registry.install(defineExtension({ name: "count", tools: [count] }));
167
180
 
168
181
  Each call runs as its own durable task. Its intent is committed before `execute()` runs. If the process dies mid-call, the tool reruns on reopen only when it is declared `replay: "safe"`; otherwise the model gets an `interrupted` error result with the output committed so far. Throwing from `execute()` gives the model an error result. A result can also return `usage`, which is added to the conversation's [usage](#usage-and-cost). It can also return `control: { terminate: true }`: when every result of the round asks for it, the run ends without another model request.
169
182
 
183
+ A result has three channels: `output` for the model, `structuredOutput` for programs that call the tool (see [Calling tools from tools](#calling-tools-from-tools)), and `details` for UIs. A tool that declares `structuredOutputSchema` returns a matching `structuredOutput`, which the Harness validates. Programs calling a tool without one get its bounded output: one text item as a string, one image as its `ImageContent`, nothing as `""`, and anything else as the content list, for errors too. `structuredOutput` is always the tool's own value: a schema tool's error result keeps the value it returned, and the text of one without is in a `tool_error` diagnostic, as a thrown error's message is. The transcript stores only `output` and `details`:
184
+
185
+ ```typescript
186
+ const lines = defineTool({
187
+ name: "lines",
188
+ description: "Count the lines of a file",
189
+ parameters: Type.Object({ path: Type.String() }),
190
+ structuredOutputSchema: Type.Object({ lines: Type.Number() }),
191
+ execute: async (args, api, context) => {
192
+ const text = await readText(api, args.path, context);
193
+ const count = text.split("\n").length;
194
+ return { output: [{ type: "text", text: `${count} lines` }], structuredOutput: { lines: count } };
195
+ },
196
+ });
197
+ ```
198
+
199
+ A missing, undeclared, or invalid `structuredOutput` turns a nested call's result into an error result with an `invalid_structured_output` diagnostic; for a call the model made, which never sees it, it is dropped and reported through `onReport`. An error result may omit it, and programs then find its text in a `tool_error` diagnostic; error results the Harness writes itself (blocked, invalid, interrupted, aborted) carry none, and their `diagnostics` say what went wrong. An `afterTool` hook that redacts a schema tool's `output` must redact its `structuredOutput` too. The built-in `bash` returns `{ output, truncated, fullOutputPath?, exitCode }`, where `output` is the retained tail the model sees, read with `api.retainedOutput()`, and answers a nonzero exit with an error result that still carries it.
200
+
170
201
  A later extension's tool with the same name replaces an earlier one where both are selected, and `wrapTool()` decorates whichever tool won:
171
202
 
172
203
  ```typescript
@@ -177,6 +208,69 @@ const Timing = defineExtension({
177
208
  });
178
209
  ```
179
210
 
211
+ ### Calling tools from tools
212
+
213
+ A tool calls another tool with `api.executeTool()`, as a code mode script or an MCP bridge does. The nested call is its own tool task, owned by the calling one: it is validated, runs the `ToolTask` hooks, keeps its own output limits and replay policy, and returns its result to the caller instead of the transcript:
214
+
215
+ ```typescript
216
+ const testsPass = defineTool({
217
+ name: "tests_pass",
218
+ description: "Run the test suite and say whether it passed",
219
+ parameters: Type.Object({}),
220
+ execute: async (_args, api, context) => {
221
+ const run = await api.executeTool("bash", { command: "npm test" }, context, { progress: false });
222
+ // Absent when bash did not finish, for example on a timeout.
223
+ const exitCode = (run.structuredOutput as { exitCode: number } | undefined)?.exitCode;
224
+ return { output: [{ type: "text", text: exitCode === 0 ? "passed" : "failed" }] };
225
+ },
226
+ });
227
+ ```
228
+
229
+ - The caller gets the nested call's `taskId`, `structuredOutput`, `details`, `diagnostics`, `usage`, and `isError`, not the `output` meant for the model. A blocked, invalid, failed, or aborted nested call returns an `isError` result. `executeTool()` itself rejects once `execute()` has returned, when the caller is aborted, or when the Harness closes; cancelling its `context` stops only the wait.
230
+ - Hooks see a nested call's `call.parent`, so a guard can treat calls from scripts differently.
231
+ - While the caller runs, its nested calls show in `docs["pi.live"].nestedTools`, with the arguments they run with and a `summary` (error, duration, usage) once done, and as tool events with `parentToolCallId` and `parentTaskId`. `progress: false` keeps a nested call's running output out of `pi.live`. Before the caller settles, nested calls it left running are aborted, also those an earlier attempt made. The caller's result message does not list its nested calls; their task records keep them.
232
+ - Each nested call has a key, its position among the caller's nested calls unless the caller passes `{ key }`. A replay-safe caller that reruns after a crash and calls in the same order, or passes the same keys, gets the nested calls it already made back, finished or still running, instead of starting them again. A nested call the crash interrupted before its result was committed follows its own `replay` policy, so a replay-safe tool with external effects still needs its own idempotency. A caller that is not replay-safe never reruns, so after a restart its unfinished nested calls are abandoned, with everything they own, before they run again.
233
+ - A nested call's `usage` is counted under its own tool; passing it on in the caller's result counts it twice.
234
+ - Nested results are not kept: they live in the caller's task documents (`NestedCallDoc`, one per nested call, keyed by its key, holding `{ taskId, result? }`) only until the caller settles. The nested call's task record keeps only a small receipt. A stored result is exactly what `executeTool()` returns, without the model's `output`, so an image or a `bash` tail is stored once.
235
+ - Nothing bounds how deeply calls nest; a tool with the default `callers` may even call itself. Restrict `callers` where that matters (below).
236
+
237
+ ### Who may call a tool
238
+
239
+ `callers` on a tool says who may call it: the model, other tools through `executeTool()`, or both, the default. A conversation's `modelTools` narrows which of its tools the model is offered, without taking them away from tools:
240
+
241
+ ```typescript
242
+ const codemode = defineTool({ name: "codemode", callers: ["model"], ... }); // scripts cannot start scripts
243
+ const search = defineTool({ name: "mcp__github__search", callers: ["tools"], ... }); // never offered to the model
244
+
245
+ // The model is offered only codemode; its scripts call read, bash, and the MCP tools.
246
+ await root.configure({ modelTools: [codemode] }, context);
247
+ // The model is offered everything but bash, which scripts may still call.
248
+ await root.configure({ modelTools: { remove: [bash] } }, context);
249
+ ```
250
+
251
+ `conversation.agent()` resolves both lists: `tools`, offered to the model, and `callable`, which nested calls resolve among. `tools` still decides what is enabled at all; `modelTools` cannot add to it.
252
+
253
+ A `deferred: true` tool is not offered to the model until it is loaded, though other tools can call it any time. The `tool_search` tool from `@earendil-works/pi-durable/tool-search` (`createToolSearchExtension()`) lets the model search the deferred tools and loads the matches with `control.addTools`, which records them in `modelTools`; loaded tools stay offered on that branch, also after a compaction, and the `codemode` section leaves deferred tools out. Use it for large tool sets, such as an MCP server's.
254
+
255
+ ### Tasks a restart abandons
256
+
257
+ A task that its creator awaits only in memory, and that the creator never resumes after a restart, can be created with `abandonOnRestart: true`. When a later Harness starts scheduling and finds it still live, it is aborted with `abortReason: "restart"`, together with everything it owns, before any of it runs again. Nested calls of tools that are not replay-safe get this automatically, and so do the child tasks such a tool creates with `api.createTask()`. A task abandoned this way whose definition is missing waits until its extension is installed, so its abort handler still cleans up. Its caller, and the conversation, wait with it; `harness.inspect()` shows it as blocked. Aborting it then orphans it, as any abort does.
258
+
259
+ ## Code Mode
260
+
261
+ `@earendil-works/pi-durable/codemode` adds a `codemode` tool: the model writes JavaScript that calls the conversation's tools as `await tools.<name>(args)`, in a QuickJS sandbox (`@earendil-works/pi-codemode`). Each call is a nested tool call, and only the script's output reaches the model. The script survives a restart: it resumes where it was, without running its finished calls again.
262
+
263
+ ```typescript
264
+ import { CodemodeSandbox } from "@earendil-works/pi-codemode";
265
+ import { createCodemodeExtension } from "@earendil-works/pi-durable/codemode";
266
+
267
+ registry.install(createCodemodeExtension({ sandbox: new CodemodeSandbox({ memoryLimitBytes: 256 * 1024 * 1024 }) }));
268
+ ```
269
+
270
+ The extension also adds a `codemode` prompt section listing the tools scripts can call, as TypeScript declarations within a token budget; scripts find the others with `searchTools()`, `describeTool()`, and `describeNamespace()`. Group tools by name prefix with `namespaces: [{ name, description, prefix }]`, for example an MCP server's `mcp__github__`. The result reads like the coding agent's: a header, the script's output, and on failure the calls already made; text past `max_output_tokens` is cut, with the full text in a temp file. Scripts also get `models.*`: the model catalog, `models.classify()`, and `models.generateImages()`, which run as nested calls of internal tools (`models: false` turns them off). On Cloudflare, pass a `RemoteCodemodeSandbox` instead. Closing the sandbox while a script runs (for example when shutting down) is not an outcome: the script resumes with the next process's sandbox. A failed nested call rejects in the script with its error text, unless the tool declares a schema and returned its own value with the error: `tools.bash` resolves with `{ output, exitCode, ... }` also for a nonzero exit, so scripts check `exitCode`. `store(key, value)` and `load(key)` keep JSON values per conversation; a fork sees them as they were at its entry.
271
+
272
+ How it survives a restart: the sandbox runs the script deterministically (a virtual clock, seeded randomness, UTC), and the tool writes each result the script gets to a journal before handing it over. After a restart, the tool runs the script again and hands it the journaled results in the same order, so it makes the same calls with the same arguments; these reattach to their nested calls by key, and then the script continues. A rerun ends the call with an error instead when the script takes another path than before, when the sandbox's fingerprint or the code mode version changed, or when its `timeout_ms` passed meanwhile. A nested call running at the restart follows its own replay policy.
273
+
180
274
  ## System Prompt
181
275
 
182
276
  The system prompt is built from the selected extensions' sections, rendered in order before each request. A section sees the resolved agent, the environment built for the request, and committed documents:
@@ -197,7 +291,8 @@ await root.configure(
197
291
  model: { provider: "openai", modelId: "gpt-6-sol" },
198
292
  thinkingLevel: "high",
199
293
  extensions: { remove: [Coding] }, // edits the host default; an array selects exactly these, in order
200
- tools: [readTool, bashTool], // an array offers exactly these; { remove: [...] } drops some
294
+ tools: [readTool, bashTool], // an array enables exactly these; { remove: [...] } drops some
295
+ modelTools: [readTool], // of those, offer the model only read; tools may still call bash
201
296
  instructions: "Only read; never edit files.",
202
297
  cwd: "/work/repo",
203
298
  },
@@ -224,6 +319,8 @@ const harness = await Harness.open(storage, {
224
319
  compaction: { reserveTokens: 16384 },
225
320
  progress: { partialIntervalMs: 100, outputIntervalMs: 100 },
226
321
  toolExecution: "parallel",
322
+ contextRetentionMs: 600_000, // idle conversations keep their context in memory this long (checked lazily where timers cannot be unreferenced)
323
+ documentRetentionMs: 600_000, // loaded documents nothing observes stay in memory this long after their last use; observed ones always
227
324
  get followUpMode() {
228
325
  return userSettings.followUpMode;
229
326
  },
@@ -260,6 +357,12 @@ await env.exec(["git", "status", "--porcelain=v2", "-z"], {
260
357
 
261
358
  Abort the context to stop one call; `cleanup()` is for shutting the environment down. A custom environment can check itself with `registerEnvConformance()` from `@earendil-works/pi-durable/testing`, like storage below.
262
359
 
360
+ Some environments also implement the optional `listFiles()`: the files and directories below a directory at any depth, as `fd` lists them, skipping what git ignores and stopping at bounds with `truncated`. `NodeExecutionEnv` does not; pi-env's `RemoteExecutionEnv` and `LocalExecutionEnv` do. Callers fall back to their own walk when the method is missing or fails with `not_supported`:
361
+
362
+ ```typescript
363
+ const listing = await env.listFiles?.(".", { exclude: ["node_modules"], maxEntries: 50_000 }, context);
364
+ ```
365
+
263
366
  ## Reload
264
367
 
265
368
  Installing an extension with an installed name replaces it in place, in one step:
@@ -315,11 +418,27 @@ await root.submit({ type: "write", entry: { kind: "app.note", data: "user opened
315
418
 
316
419
  - **Steers** are placed after the current tool round and join the running work.
317
420
  - **Follow-ups** are placed when the run answers, and start the next run.
318
- - **Writes** append an entry without asking the model anything.
421
+ - **Writes** append an entry without asking the model anything. A write of only `kind` and `data` changes nothing the model sees, so it is appended at once, even mid-run, and is never queued: a chat message shows in the transcript as soon as it is sent. It can therefore appear before items submitted earlier that are still queued, except behind a queued reset, where it waits so the reset does not hide it. A write with `model`, `head`, or `edits` waits for the next boundary like a steer.
319
422
  - `await submission.abort(context)` withdraws a queued submission.
320
423
  - The [settings](#settings) `steeringMode: "all"` and `followUpMode: "all"` place every queued item at once instead of one per turn.
321
424
 
322
- If a run fails, queued items stay in the inbox until the next submission places them, oldest first.
425
+ If a run fails, queued items stay in the inbox until the next input or queued write places them, oldest first.
426
+
427
+ An input is placed as a `pi.user` entry. To show it as something else, give it an `entry` with your kind and data; the model still sees `content` as the user message:
428
+
429
+ ```typescript
430
+ await root.submit(
431
+ {
432
+ type: "input",
433
+ content: "ann: can you check the build?",
434
+ entry: { kind: "chat.mention", data: { author: "ann", text: "can you check the build?" } },
435
+ },
436
+ context,
437
+ );
438
+ // placed as { kind: "chat.mention", data: { author: "ann", ... }, model: [{ role: "user", content: "ann: can you check the build?" }] }
439
+ ```
440
+
441
+ It behaves like any other input: it starts a run, steers, follows up, or rejects, and is answered. While queued, its inbox item carries the entry. The kind must not start with `pi.`.
323
442
 
324
443
  ## Reset and Handoff
325
444
 
@@ -362,7 +481,9 @@ settings: {
362
481
 
363
482
  When a provider rejects a request because the context is too long, generation compacts and retries once. A summary that would cut before the start of the current context settles as `stale` when it is placed, so when several are in flight, the furthest cut stays in effect. Summarization spend counts in `pi.usage`. A `beforeCompact` hook on `CompactionTask` can decline or supply its own summary.
364
483
 
365
- Running compactions are listed in `docs["pi.live"].compactions` with their reason, attempt, and retry backoff. The agent events add `compaction_start` and `compaction_end`, and a `compactions` field in the snapshot.
484
+ When a compaction that a generation waits for at the blocking threshold fails, the generation fails too, without sending a request: its inputs settle `unanswered` with `model_error` and detail `Compaction failed: <error>`. One that finds nothing to compact or is declined lets the request go ahead.
485
+
486
+ Running compactions are listed in `docs["pi.live"].compactions` with their reason, attempt, and retry backoff. The newest failure stays in `docs["pi.live"].compactionFailure` (task, reason, error) until a compaction produces a summary. The agent events add `compaction_start` and `compaction_end` (with `error` when the compaction failed), and `compactions` and `compactionFailure` fields in the snapshot.
366
487
 
367
488
  ## Agent Events (Experimental)
368
489
 
@@ -439,7 +560,7 @@ const Subagent: Extension = defineExtension({
439
560
  await api.details({ conversationId: child }, context); // lets a UI attach to the child
440
561
  const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` } as const;
441
562
  const settled = await (await (await api.conversation(child, context))!.submit(request, context)).wait(context);
442
- return { content: [{ type: "text", text: settled.status }] };
563
+ return { output: [{ type: "text", text: settled.status }] };
443
564
  },
444
565
  }),
445
566
  ],
@@ -538,7 +659,8 @@ const usage = await harness.usage(context); // { models: { "openai/gpt-6-sol": U
538
659
  |---|---|---|
539
660
  | Memory | `MemoryStorage` from the package root | Nothing is persisted. |
540
661
  | SQLite | `openNodeSqliteStorage(file)` from `@earendil-works/pi-durable/storage/sqlite/node` | One database file. WAL mode with `synchronous = NORMAL`: commits survive process crashes; the newest may be lost on power or host failure. |
541
- | JSONL | `openNodeJsonlStorage(directory, context)` from `@earendil-works/pi-durable/storage/jsonl/node` | Append-only files in one directory. Pass `{ fsync: true }` to flush before each commit marker. |
662
+ | JSONL | `openNodeJsonlStorage(directory, context)` from `@earendil-works/pi-durable/storage/jsonl/node` | Append-only files in one directory. Pass `{ fsync: true }` to flush before each commit marker. Keeps the whole history in memory too, so memory grows with it; use SQLite for long-running agents. |
663
+ | Cloudflare Durable Object | `openDurableObjectSqliteStorage(ctx.storage)` from `@earendil-works/pi-durable/storage/sqlite/cloudflare` | The SQLite storage of one SQLite-backed Durable Object; commits are its storage transactions. |
542
664
 
543
665
  One process owns a storage at a time; there is no cross-process locking. The portable SQLite and JSONL cores (`/storage/sqlite`, `/storage/jsonl`) run without Node APIs, for example on Bun or in Cloudflare Durable Objects, given an asynchronous `SqliteDatabase` facade or a `FileSystem` from `@earendil-works/pi-durable/env`.
544
666
 
@@ -569,6 +691,24 @@ registerStorageConformance({ describe, expect, it }, "My Storage", async (use) =
569
691
 
570
692
  The package root loads TypeBox, because the tool task validates arguments with pi-ai's `validateToolArguments()`. That costs about 23 MB of peak RSS unbundled, about 4 MB in a tree-shaken bundle.
571
693
 
694
+ ## Errors
695
+
696
+ Three rules decide what an error does:
697
+
698
+ 1. **A storage error ends the Harness.** The call that hit it rejects with the error. Everything after it, and every pending wait, rejects with `SessionFailed` (its `cause` is that error); watches and event streams end with `session_failed`; running tasks get their abort signal; `onReport` gets the error once. The Harness then closes itself. Nothing is retried; a custom storage retries its own transient failures. Reopen to recover: commits are atomic, so the store is consistent, and resubmitting with the same `requestId` finds a submission that was already admitted.
699
+ 2. **An invalid request fails only that call:** a storage throws `StorageRequestError` for a bad cursor, an unknown conversation, or history a document does not keep. A read you cancelled fails only itself too.
700
+ 3. **Your code fails only its own unit:** a throwing task phase faults that task, a throwing tool gets an error result, a throwing hook is reported and skipped (`beforeTool` blocks the call), and a throwing commit callback rolls back that commit. Listeners that throw, or return a rejected promise, are reported and the others still run. The exception is a host `RegistryReader` whose `snapshot()` throws: the Harness cannot schedule without it, so it fails the Harness like a storage error.
701
+
702
+ `harness.closed` settles once the Harness has closed, by `close()` or after a storage error, so a host restarts on failure like this:
703
+
704
+ ```typescript
705
+ harness.closed.then((end) => {
706
+ if (end.reason === "failed") reopen(end.error);
707
+ });
708
+ ```
709
+
710
+ A failure closes the Harness by itself; `closed` settles once that close is done, even if the storage could not close cleanly. Closing waits for running task code to return, so code that ignores its abort signal delays `closed`.
711
+
572
712
  ## Examples
573
713
 
574
714
  Runnable examples live in [`test/examples`](test/examples). Run one from this package directory with:
@@ -603,10 +743,8 @@ Examples that call OpenAI need `OPENAI_API_KEY`; most use the faux provider othe
603
743
  ## Design Documents
604
744
 
605
745
  - [`docs/spec.md`](https://github.com/earendil-works/pi/blob/main/packages/durable/docs/spec.md): the normative specification
606
- - [`docs/pico-v5-handoff.md`](https://github.com/earendil-works/pi/blob/main/packages/durable/docs/pico-v5-handoff.md): the implementation plan
607
- - [`docs/pico-v5-chord-usage.md`](https://github.com/earendil-works/pi/blob/main/packages/durable/docs/pico-v5-chord-usage.md): how the package uses Chord
608
746
 
609
- Benchmarks: `npm run bench:storage`, `npm run bench:storage:memory`, and `npm run bench:tool-output`.
747
+ Benchmarks: `npm run bench:storage`, `npm run bench:storage:memory`, `npm run bench:tool-output`, and `npm run bench:nested-tools`.
610
748
 
611
749
  ## License
612
750
 
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Durable code mode: a tool that runs model-written JavaScript in a QuickJS sandbox, whose script survives a restart.
3
+ *
4
+ * Problem: a script is in-memory VM state. A restart while
5
+ *
6
+ * const results = await Promise.all(paths.map((path) => tools.read({ path })));
7
+ *
8
+ * waits for the last of 200 reads would lose the script and every read it made.
9
+ *
10
+ * Solution: deterministic replay (`CodemodeSandboxBase.start()` in pi-codemode). The VM only runs when the host hands
11
+ * it a result; given the same start data, clock, seed, and results in the same order, it makes the same calls with
12
+ * the same arguments. The tool is replay-safe: after a restart, `execute()` runs again, starts the script again from
13
+ * the stored start data, and hands it the results it got before, in the order it got them, from a journal written
14
+ * before each delivery. Nested tool calls reattach to their tasks by key (`c<VM call id>`), so they never run twice
15
+ * unless their own replay policy reruns them; their results come from `NestedCallDoc`. Then the script continues live.
16
+ *
17
+ * Documents, scoped to the code mode call's task and gone when it settles:
18
+ * - `pi.codemode.run`: the start data, written once before the VM starts, and the outcome, written once when the call
19
+ * decides it (with the store writes, in one transaction), so a rerun after that returns it without a VM.
20
+ * - `pi.codemode.journal`: one entry per delivered result, in delivery order: the VM call ID, the clock value, the
21
+ * calls the VM had issued after processing it (once acknowledged), and a global's outcome, which nothing else stores.
22
+ *
23
+ * Each result's acknowledged call count is recorded with the next journal commit, or at once when the VM has processed
24
+ * everything delivered: a script waiting on a long call is where a restart usually finds it. A sequential script
25
+ * therefore costs one journal commit and one count commit per result, on top of its nested calls' own; that is the
26
+ * price of checking the most common crash point.
27
+ *
28
+ * A call is launched only once the count of calls issued in the drain that made it is committed. So every key an earlier
29
+ * attempt admitted is at most its last recorded count; a replay checks every recorded count, so when it releases its
30
+ * calls it has made every call an earlier attempt could have admitted, and later calls are new.
31
+ *
32
+ * A rerun refuses to replay (no new nested calls; nested calls admitted earlier may still resume on their own and are
33
+ * aborted by the call's cleanup) when this module's version or the VM's fingerprint changed, when the deadline passed,
34
+ * or when the script diverges: it makes another call, or calls in another order, than the journal says. Each ends the
35
+ * call with an error result.
36
+ */
37
+ import { type Context, type JsonValue } from "@earendil-works/chord";
38
+ import { type CodemodeSandboxBase } from "@earendil-works/pi-codemode/portable";
39
+ import { type Static, Type } from "typebox";
40
+ import type { Extension, ToolExecutionApi, ToolRegistration } from "../harness/types.ts";
41
+ import type { JsonObject } from "../types.ts";
42
+ import { type CodemodeNamespace } from "./describe.ts";
43
+ /**
44
+ * Version of everything in this module that turns stored results into what a script sees: result mapping, globals,
45
+ * start data. Bump it when that changes, so a call started before an upgrade is not replayed by different code.
46
+ */
47
+ export declare const CODEMODE_MODULE_VERSION = 2;
48
+ export declare const CODEMODE_TOOL_NAME = "codemode";
49
+ /** One tool as the script sees it: `tools.<jsName>`. */
50
+ /**
51
+ * `structured`: the tool declares `structuredOutputSchema`, so an error result with a value resolves with it.
52
+ * `hidden`: an internal tool of this extension (`pi.codemode.*`), which scripts reach only through a global such as
53
+ * `models.classify()`; never in `tools`, `ALL_TOOLS`, or tool search.
54
+ */
55
+ type DeclaredTool = {
56
+ name: string;
57
+ jsName: string;
58
+ description: string;
59
+ structured?: true;
60
+ hidden?: true;
61
+ };
62
+ /** What a call starts with; a rerun starts the VM from exactly this. */
63
+ type Start = {
64
+ version: number;
65
+ startedAt: number;
66
+ seed: number;
67
+ /** The VM's fingerprint, recorded by the first run before any call is admitted. */
68
+ fingerprint?: string;
69
+ /** `startedAt + timeout_ms` when the script set one. */
70
+ deadline?: number;
71
+ tools: DeclaredTool[];
72
+ /** The namespaces of the tools, as `describeNamespace()` and `searchTools()` see them. */
73
+ namespaces: {
74
+ name: string;
75
+ description?: string;
76
+ instructions?: string;
77
+ tools: string[];
78
+ }[];
79
+ globals: {
80
+ name: string;
81
+ spread: boolean;
82
+ }[];
83
+ /** `max_output_tokens` of the script's options line, or the default. */
84
+ maxOutputTokens: number;
85
+ store: Record<string, JsonValue>;
86
+ };
87
+ type RunState = {
88
+ start?: Start;
89
+ /** Directory of the result's files (the full output, images), created once: a rerun writes the same files again. */
90
+ outputDir?: string;
91
+ /** The outcome, decided once. */
92
+ result?: JsonObject;
93
+ };
94
+ export declare const CodemodeRunDoc: import("../types.ts").TaskDocToken<RunState>;
95
+ type JournalEntry = {
96
+ /** The VM's call ID. Its result is the entry's position + 1 (`seq`). */
97
+ id: number;
98
+ /** The VM clock while it processes this result. */
99
+ at: number;
100
+ /** Calls the VM had issued after processing this result; absent until acknowledged. */
101
+ issuedAfter?: number;
102
+ /** A global's call and outcome; a tool's result is its nested call's. */
103
+ global?: {
104
+ name: string;
105
+ args?: JsonValue;
106
+ outcome: JsonObject;
107
+ };
108
+ };
109
+ type JournalState = {
110
+ entries: JournalEntry[];
111
+ /** Calls the VM had issued after the script's first run, before any result; absent until acknowledged. */
112
+ firstIssued?: number;
113
+ };
114
+ export declare const CodemodeJournalDoc: import("../types.ts").TaskDocToken<JournalState>;
115
+ /**
116
+ * What scripts keep with `store()` and read with `load()`, per conversation. Rewindable and copied as of the fork
117
+ * point, so a fork sees the values as they were when its entry was committed.
118
+ */
119
+ export declare const CodemodeStoreDoc: import("../types.ts").RewindableConversationDocToken<{
120
+ values: Record<string, JsonValue>;
121
+ }>;
122
+ /** One tool call a script made, as the code mode call's details show it. */
123
+ export type CodemodeNestedCall = {
124
+ /** The VM's number for the call; its nested call ID is `<callId>/c<id>`. */
125
+ id: number;
126
+ name: string;
127
+ /** Compact JSON of the arguments, cut for display. */
128
+ args: string;
129
+ status: "running" | "ok" | "error" | "cancelled";
130
+ durationMs?: number;
131
+ /** Error text, cut for display. */
132
+ error?: string;
133
+ };
134
+ /**
135
+ * The code mode call's details, in its result: its tool calls in the order it made them, with their status. Calls to
136
+ * globals are not listed. While the script runs, UIs show its nested calls from `pi.live.nestedTools`, so the list is
137
+ * written once, with the result.
138
+ */
139
+ export type CodemodeToolDetails = {
140
+ calls: CodemodeNestedCall[];
141
+ /** Temp file with the complete text output, when the output was cut to its token budget. */
142
+ fullOutputPath?: string;
143
+ };
144
+ /** A function scripts call as a top-level global; its result is journaled. */
145
+ export type CodemodeGlobal = {
146
+ readonly name: string;
147
+ readonly spread?: boolean;
148
+ execute(args: unknown, api: ToolExecutionApi, context: Context): Promise<JsonValue | undefined>;
149
+ };
150
+ declare const codemodeSchema: Type.TObject<{
151
+ code: Type.TString;
152
+ }>;
153
+ export type CodemodeToolInput = Static<typeof codemodeSchema>;
154
+ export type CodemodeToolOptions = {
155
+ /** Runs the scripts: `CodemodeSandbox` on Node, `RemoteCodemodeSandbox` on Cloudflare. */
156
+ readonly sandbox: CodemodeSandboxBase;
157
+ /** Groups of tools by name prefix, such as MCP servers, for the `codemode` section and tool search. */
158
+ readonly namespaces?: readonly CodemodeNamespace[];
159
+ /** Estimated tokens the tool declarations in the `codemode` section may use. Default 3000. */
160
+ readonly inlineBudget?: number;
161
+ /** Give scripts `models.*`: the model catalog, classifiers, and image generation. Default true. */
162
+ readonly models?: boolean;
163
+ /** @internal Host functions scripts call as globals. */
164
+ readonly globals?: readonly CodemodeGlobal[];
165
+ };
166
+ /** The code mode tool. Scripts call the conversation's callable tools as `tools.<name>(args)`. */
167
+ export declare function createCodemodeTool(options: CodemodeToolOptions): ToolRegistration<typeof codemodeSchema>;
168
+ /**
169
+ * The `codemode` extension: the code mode tool, and the `codemode` prompt section listing the tools scripts can call.
170
+ */
171
+ export declare function createCodemodeExtension(options: CodemodeToolOptions): Extension;
172
+ export {};
173
+ //# sourceMappingURL=codemode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codemode.d.ts","sourceRoot":"","sources":["../../src/codemode/codemode.ts"],"names":[],"mappings":"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"}