mixdog 1.1.2 → 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 (288) hide show
  1. package/README.md +1 -0
  2. package/package.json +2 -1
  3. package/scripts/lib/run-node-tests.mjs +9 -0
  4. package/scripts/prune-desktop-runtime.mjs +3 -0
  5. package/scripts/test-direct.mjs +4 -2
  6. package/src/defaults/shipped-history.json +6 -0
  7. package/src/defaults/skills/setup/references/actions.md +5 -2
  8. package/src/defaults/skills/visualize/SKILL.md +157 -0
  9. package/src/defaults/skills/visualize/references/craft.md +88 -0
  10. package/src/headless-command.mjs +56 -3
  11. package/src/headless-exec.mjs +17 -1
  12. package/src/help.mjs +2 -0
  13. package/src/rules/shared/70-delivery.md +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +1 -0
  16. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -0
  17. package/src/runtime/agent/orchestrator/config-presets.mjs +2 -0
  18. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +17 -11
  19. package/src/runtime/agent/orchestrator/config-storage.mjs +1 -0
  20. package/src/runtime/agent/orchestrator/config.mjs +9 -1
  21. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +47 -7
  22. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +51 -8
  23. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +25 -5
  24. package/src/runtime/agent/orchestrator/mcp/client.mjs +348 -122
  25. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +4 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +13 -1
  29. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -0
  30. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +2 -0
  31. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +2 -1
  32. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +12 -4
  33. package/src/runtime/agent/orchestrator/providers/effort-rejection.mjs +244 -0
  34. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  36. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +8 -40
  37. package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +2 -0
  38. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +51 -12
  39. package/src/runtime/agent/orchestrator/providers/model-cache.mjs +37 -2
  40. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +67 -0
  41. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +521 -0
  42. package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +251 -0
  43. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +2 -0
  44. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +69 -102
  45. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +5 -2
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +3 -0
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +15 -1
  51. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +10 -0
  52. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +36 -3
  53. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +12 -11
  54. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +16 -1
  55. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +11 -0
  56. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +10 -3
  57. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +10 -0
  58. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +9 -5
  59. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +18 -8
  60. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +10 -3
  61. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +18 -11
  62. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +13 -5
  63. package/src/runtime/agent/orchestrator/session/compaction-archive-capture.mjs +362 -0
  64. package/src/runtime/agent/orchestrator/session/compaction-archive.mjs +253 -0
  65. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +1 -1
  66. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/diagnostic.mjs +1 -7
  67. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/refusal.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +3 -2
  69. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +2 -1
  70. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +1 -0
  71. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -2
  74. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +2 -0
  75. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +5 -3
  76. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +5 -2
  77. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -16
  78. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +4 -0
  79. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +90 -0
  80. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -12
  81. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +32 -3
  82. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +27 -0
  83. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +16 -1
  84. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -0
  85. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  86. package/src/runtime/agent/orchestrator/session/message-ids.mjs +123 -0
  87. package/src/runtime/agent/orchestrator/session/message-ids.test-support.mjs +29 -0
  88. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +29 -19
  89. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +3 -0
  90. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +10 -3
  91. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +11 -0
  92. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +29 -0
  93. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +5 -1
  94. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +23 -4
  95. package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +20 -3
  96. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +28 -8
  97. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +140 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +52 -2
  99. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -22
  100. package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.mjs +10 -3
  101. package/src/runtime/agent/orchestrator/tools/builtin/read-cache-policy.mjs +8 -0
  102. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +31 -12
  103. package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +32 -35
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +11 -12
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +9 -6
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -66
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +8 -3
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -1
  109. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +16 -7
  110. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
  111. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +137 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +11 -10
  113. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +21 -11
  114. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +12 -1
  115. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +56 -13
  116. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +58 -0
  117. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +25 -1
  118. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +78 -5
  119. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +2 -0
  120. package/src/runtime/attachments/store.mjs +38 -0
  121. package/src/runtime/shared/atomic-file.mjs +8 -2
  122. package/src/runtime/shared/best-effort-stderr.mjs +8 -0
  123. package/src/runtime/shared/code-graph-state.mjs +12 -0
  124. package/src/runtime/shared/defaults-separation.mjs +2 -0
  125. package/src/runtime/shared/git-runner-worker.mjs +3 -2
  126. package/src/runtime/shared/git-runner.mjs +4 -3
  127. package/src/runtime/shared/llm/default-context-window.mjs +6 -0
  128. package/src/runtime/shared/llm/model-catalog-projection.mjs +5 -4
  129. package/src/runtime/shared/llm/model-catalog.mjs +240 -118
  130. package/src/runtime/shared/llm/provider-catalog-cache.mjs +36 -28
  131. package/src/runtime/shared/llm/provider-catalog-registry.mjs +156 -0
  132. package/src/runtime/shared/llm/provider-seed-rosters.mjs +130 -0
  133. package/src/runtime/shared/model-proxy-mode.d.mts +3 -0
  134. package/src/runtime/shared/model-proxy-mode.mjs +19 -0
  135. package/src/runtime/shared/provider-accounts.mjs +2 -2
  136. package/src/runtime/shared/provider-api-key.mjs +21 -1
  137. package/src/runtime/shared/schedule-model-ref.mjs +11 -2
  138. package/src/runtime/shared/session-usage-totals.mjs +19 -0
  139. package/src/runtime/shared/sharp-electron-linux.mjs +25 -0
  140. package/src/runtime/shared/singleton-owner.mjs +36 -6
  141. package/src/runtime/shared/statusline/statusline-segments.mjs +68 -14
  142. package/src/runtime/shared/tool-surface.mjs +7 -0
  143. package/src/runtime/shared/tool-work-units.mjs +14 -0
  144. package/src/runtime/shared/transcript-metadata.mjs +17 -1
  145. package/src/runtime/shared/turn-changes.mjs +791 -0
  146. package/src/runtime/shared/turn-snapshot-store.mjs +346 -12
  147. package/src/runtime/shared/turn-snapshot.mjs +690 -81
  148. package/src/runtime/shared/turn-worktree-snapshot.mjs +161 -11
  149. package/src/runtime/shared/worktrees/cache-cleanups.mjs +52 -0
  150. package/src/runtime/shared/worktrees/contract.mjs +551 -0
  151. package/src/runtime/shared/worktrees/disk.mjs +57 -0
  152. package/src/runtime/shared/worktrees/git.mjs +247 -0
  153. package/src/runtime/shared/worktrees/include.mjs +480 -0
  154. package/src/runtime/shared/worktrees/lock.mjs +46 -0
  155. package/src/runtime/shared/worktrees/maintenance.mjs +96 -0
  156. package/src/runtime/shared/worktrees/manager.mjs +1215 -0
  157. package/src/runtime/shared/worktrees/merge.mjs +182 -0
  158. package/src/runtime/shared/worktrees/owner.mjs +46 -0
  159. package/src/runtime/shared/worktrees/paths.mjs +62 -0
  160. package/src/runtime/shared/worktrees/registry.mjs +110 -0
  161. package/src/runtime/shared/worktrees/settings.mjs +84 -0
  162. package/src/runtime/shared/worktrees/slug.mjs +62 -0
  163. package/src/runtime/shared/worktrees/test-support.mjs +96 -0
  164. package/src/runtime/visualize-bridge/client.mjs +181 -0
  165. package/src/runtime/visualize-bridge/tool-defs.mjs +97 -0
  166. package/src/session-runtime/boot/apis.mjs +35 -0
  167. package/src/session-runtime/boot/config.mjs +2 -0
  168. package/src/session-runtime/boot/facade.mjs +20 -0
  169. package/src/session-runtime/boot/infrastructure.mjs +1 -0
  170. package/src/session-runtime/boot/session.mjs +1 -0
  171. package/src/session-runtime/boot/tools.mjs +4 -0
  172. package/src/session-runtime/config-lifecycle.mjs +10 -1
  173. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +11 -1
  174. package/src/session-runtime/global-extensions.mjs +2 -2
  175. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +27 -2
  176. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +32 -1
  177. package/src/session-runtime/internal-tool-executor.mjs +9 -1
  178. package/src/session-runtime/lifecycle/session-catalog.mjs +8 -1
  179. package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +5 -0
  180. package/src/session-runtime/lifecycle/session-switch/resume.mjs +21 -3
  181. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +16 -0
  182. package/src/session-runtime/mcp-glue/connect.mjs +11 -6
  183. package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -3
  184. package/src/session-runtime/mcp-glue/server-input.mjs +29 -0
  185. package/src/session-runtime/model-route/route-persist.mjs +3 -0
  186. package/src/session-runtime/model-route/set-route.mjs +15 -2
  187. package/src/session-runtime/notification-bus.mjs +9 -1
  188. package/src/session-runtime/provider-models/row-hydration.mjs +2 -0
  189. package/src/session-runtime/resource-extension-sync.mjs +30 -7
  190. package/src/session-runtime/resource-mcp-api.mjs +20 -7
  191. package/src/session-runtime/route-resolution.mjs +10 -1
  192. package/src/session-runtime/route-state.mjs +16 -2
  193. package/src/session-runtime/runtime-core.mjs +5 -0
  194. package/src/session-runtime/runtime-facade.mjs +8 -0
  195. package/src/session-runtime/runtime-review-api.mjs +9 -0
  196. package/src/session-runtime/services/agent-tool/helpers.mjs +5 -0
  197. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +3 -0
  198. package/src/session-runtime/services/agent-tool/job-views/job-lookup.mjs +1 -0
  199. package/src/session-runtime/services/agent-tool/job-views/spawn-meta.mjs +1 -0
  200. package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +15 -0
  201. package/src/session-runtime/services/agent-tool/send-flow.mjs +1 -0
  202. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +10 -2
  203. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +19 -7
  204. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +3 -0
  205. package/src/session-runtime/services/agent-tool/tag-registry.mjs +2 -1
  206. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +5 -0
  207. package/src/session-runtime/services/agent-tool/worker-rows.mjs +1 -0
  208. package/src/session-runtime/services/agent-tool.mjs +1 -0
  209. package/src/session-runtime/services/daemon-candidate.mjs +3 -2
  210. package/src/session-runtime/services/daemon-crash-capture/spawn-capture.mjs +55 -0
  211. package/src/session-runtime/session-create/session-options.mjs +10 -1
  212. package/src/session-runtime/session-turn-api.mjs +1 -0
  213. package/src/session-runtime/settings-api.mjs +4 -2
  214. package/src/session-runtime/settings-builtin-tools-api.mjs +9 -2
  215. package/src/session-runtime/settings-worktree-api.mjs +17 -0
  216. package/src/session-runtime/setup-tool/executor.mjs +11 -4
  217. package/src/session-runtime/setup-tool/settings-contract.mjs +3 -1
  218. package/src/session-runtime/setup-tool/tool-defs.mjs +31 -3
  219. package/src/session-runtime/turn/agent-ops.mjs +12 -1
  220. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +13 -3
  221. package/src/session-runtime/turn/turn-run/snapshot-tracker.mjs +20 -15
  222. package/src/session-runtime/turn/turn-run/turn-open.mjs +4 -5
  223. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +29 -1
  224. package/src/session-runtime/turn/turn-run/turn-settle.mjs +6 -1
  225. package/src/session-runtime/turn/turn-run.mjs +6 -7
  226. package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -3
  227. package/src/session-runtime/workspace/admission-gate.mjs +197 -0
  228. package/src/session-runtime/workspace/project-branches.mjs +136 -0
  229. package/src/session-runtime/workspace/transition-steps.mjs +364 -0
  230. package/src/session-runtime/workspace/transitions.mjs +651 -0
  231. package/src/session-runtime/workspace/workspace-state.mjs +112 -0
  232. package/src/standalone/channel-transport/http-routes.mjs +17 -0
  233. package/src/standalone/channel-transport.mjs +13 -1
  234. package/src/standalone/daemon-desktop-runtime.mjs +1 -0
  235. package/src/standalone/daemon-owner.mjs +372 -0
  236. package/src/standalone/daemon-stored-session-views.mjs +4 -1
  237. package/src/standalone/daemon.mjs +139 -5
  238. package/src/standalone/desktop-service-registry.mjs +20 -0
  239. package/src/standalone/local-session-runtime.mjs +11 -0
  240. package/src/standalone/session-client/attachment-calls.mjs +3 -1
  241. package/src/standalone/session-client/daemon-unavailable.mjs +62 -0
  242. package/src/standalone/session-client/lease-heartbeat.mjs +47 -0
  243. package/src/standalone/session-client.mjs +104 -13
  244. package/src/standalone/session-protocol.mjs +16 -0
  245. package/src/standalone/session-proxy.mjs +3 -0
  246. package/src/standalone/session-service/agent-tree/agent-child.mjs +4 -0
  247. package/src/standalone/session-service/agent-tree/agent-rehydrate.mjs +1 -0
  248. package/src/standalone/session-service/entries/lifecycle.mjs +6 -0
  249. package/src/standalone/session-service/projection/revisions.mjs +2 -0
  250. package/src/standalone/session-service/projection/transcript-window.mjs +149 -19
  251. package/src/standalone/session-service/projection/workspace.mjs +14 -0
  252. package/src/standalone/session-service/session-calls/session-action.mjs +11 -3
  253. package/src/standalone/session-transport/call-route.mjs +13 -0
  254. package/src/standalone/session-transport/client-registry/client-grace.mjs +45 -3
  255. package/src/standalone/session-transport/client-registry/registration-replays.mjs +1 -0
  256. package/src/standalone/session-transport/client-registry.mjs +20 -3
  257. package/src/standalone/session-transport/routes.mjs +79 -2
  258. package/src/standalone/session-transport/stack.mjs +2 -0
  259. package/src/standalone/session-transport.mjs +24 -1
  260. package/src/tui/app/message-selector.mjs +2 -1
  261. package/src/tui/app/model-options.mjs +35 -5
  262. package/src/tui/app/model-picker/provider-models-list.mjs +2 -2
  263. package/src/tui/app/model-picker.mjs +4 -2
  264. package/src/tui/dist/index.mjs +332 -36
  265. package/src/tui/index.jsx +10 -2
  266. package/src/tui/session/compaction-view.mjs +147 -0
  267. package/src/tui/session/context-state.mjs +6 -0
  268. package/src/tui/session/initial-state.mjs +3 -0
  269. package/src/tui/session/quota-resume.mjs +2 -1
  270. package/src/tui/session/session-api/commands.mjs +10 -2
  271. package/src/tui/session/session-api/controls.mjs +4 -0
  272. package/src/tui/session/session-api/intake/rewind.mjs +2 -1
  273. package/src/tui/session/session-api/integrations.mjs +17 -0
  274. package/src/tui/session/session-api/lifecycle/dispose.mjs +6 -1
  275. package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -0
  276. package/src/tui/session/session-api/lifecycle/resume.mjs +3 -0
  277. package/src/tui/session/session-api/routes.mjs +10 -1
  278. package/src/tui/session/session-api/settings.mjs +2 -0
  279. package/src/tui/session/session-api/worktree.mjs +60 -0
  280. package/src/tui/session/session-api.mjs +2 -0
  281. package/src/tui/session/session-flow/auto-clear.mjs +31 -12
  282. package/src/tui/session/session-flow/drain.mjs +15 -1
  283. package/src/tui/session/session-flow/session-reset.mjs +4 -3
  284. package/src/tui/session/session-flow.mjs +2 -0
  285. package/src/tui/session/transcript-intake.mjs +14 -5
  286. package/src/tui/session/turn-stream.mjs +32 -4
  287. package/src/tui/session/turn-tool-cards.mjs +10 -2
  288. package/src/tui/session/turn.mjs +39 -10
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Capture side of the display-only compaction archive (compaction-archive.mjs).
3
+ *
4
+ * A committed compaction hands over the transcript it replaced and the one
5
+ * that replaced it. What left the model context — the messages whose stable
6
+ * id (message-ids.mjs) the result no longer holds, and the lost parts of kept
7
+ * messages (compactionArchiveProjection) — is projected to transcript items
8
+ * exactly like stored history
9
+ * (restoreTranscriptItems over media-sanitized messages, so tool cards and
10
+ * diffs survive), namespaced as `arch<seq>_…` with `archived: true`, and
11
+ * appended as ONE segment to the session's sidecar, followed by the
12
+ * compaction divider.
13
+ *
14
+ * The commit only selects the dropped messages and allocates the segment
15
+ * number (`session.compaction.archive.nextSeq`, saved with the commit); the
16
+ * projection and the write run on a later event-loop turn, serialized per
17
+ * session and fenced against a delete. Nothing here touches
18
+ * `session.messages`.
19
+ */
20
+ import { randomBytes } from 'node:crypto';
21
+ import { existsSync, unlinkSync } from 'node:fs';
22
+ import { open, readFile } from 'node:fs/promises';
23
+ import { basename, join } from 'node:path';
24
+ import { renameWithRetrySync } from '../../../shared/atomic-file.mjs';
25
+ import { writeStderrBestEffort } from '../../../shared/best-effort-stderr.mjs';
26
+
27
+ import { sanitizeContentForStoredHistory } from '../providers/media-normalization.mjs';
28
+ import { getStoreDir } from './store/paths-heartbeat.mjs';
29
+ import { forgetSessionSaveBaseline } from './store/save-worker.mjs';
30
+ import { messageIdOf, stampMessageIds } from './message-ids.mjs';
31
+ import { restoreTranscriptItems } from './transcript-restore/restore.mjs';
32
+ import {
33
+ COMPACTION_ARCHIVE_MAX_BYTES,
34
+ COMPACTION_DIVIDER_LABEL,
35
+ compactionArchiveDeleteEpoch,
36
+ compactionArchivePath,
37
+ compactionSegmentHead,
38
+ compactionSegmentLine,
39
+ } from './compaction-archive.mjs';
40
+
41
+ export { COMPACTION_DIVIDER_LABEL };
42
+
43
+ const pendingWrites = new Map();
44
+
45
+ // ── Dropped messages: by stable id only ──────────────────────────────────────
46
+
47
+ function toolCallsOf(message) {
48
+ const calls = Array.isArray(message.toolCalls) ? message.toolCalls : message.tool_calls;
49
+ return Array.isArray(calls) ? calls : [];
50
+ }
51
+
52
+ function callIdOf(call) {
53
+ return String(call?.id || '');
54
+ }
55
+
56
+ function resultCallIdOf(message) {
57
+ return message.role === 'tool' ? String(message.tool_call_id || message.toolCallId || '') : '';
58
+ }
59
+
60
+ function hasText(content) {
61
+ if (typeof content === 'string') return content.trim().length > 0;
62
+ return Array.isArray(content) && content.some((part) => typeof part?.text === 'string' && part.text.trim());
63
+ }
64
+
65
+ /** The stable ids `messages` carries (stampMessageIds / carryMessageId). */
66
+ function idsOf(messages) {
67
+ const ids = new Set();
68
+ for (const message of messages) {
69
+ const id = messageIdOf(message);
70
+ if (id) ids.add(id);
71
+ }
72
+ return ids;
73
+ }
74
+
75
+ /**
76
+ * Messages of `before` whose stable id the compacted `after` no longer holds,
77
+ * in their original order. Identity is the id alone — never an object
78
+ * reference, a position or content. A message without an id (a string-meta
79
+ * context marker, see message-ids.mjs) has no display identity and is never
80
+ * reported.
81
+ */
82
+ export function droppedByCompaction(before, after) {
83
+ const kept = idsOf(Array.isArray(after) ? after : []);
84
+ return (Array.isArray(before) ? before : []).filter((message) => {
85
+ const id = messageIdOf(message);
86
+ return id && !kept.has(id);
87
+ });
88
+ }
89
+
90
+ /**
91
+ * What a compaction took out of the model context, in `before` order, for the
92
+ * display archive: every dropped message, plus — for a kept message whose
93
+ * rewrite lost part of it — the lost part (tool calls no copy still carries,
94
+ * text when no copy has any) as a message of the same id. The tool results of
95
+ * those calls are dropped messages, so their cards restore whole.
96
+ * `textIds` are the ids whose text rows left the context: the dropped
97
+ * messages and the kept ones that lost all their text.
98
+ */
99
+ export function compactionArchiveProjection(before, after) {
100
+ const copiesById = new Map();
101
+ for (const message of Array.isArray(after) ? after : []) {
102
+ const id = messageIdOf(message);
103
+ if (!id) continue;
104
+ if (!copiesById.has(id)) copiesById.set(id, []);
105
+ copiesById.get(id).push(message);
106
+ }
107
+ const messages = [];
108
+ const textIds = [];
109
+ for (const message of Array.isArray(before) ? before : []) {
110
+ const id = messageIdOf(message);
111
+ if (!id) continue;
112
+ const copies = copiesById.get(id);
113
+ if (!copies) {
114
+ messages.push(message);
115
+ textIds.push(id);
116
+ continue;
117
+ }
118
+ const keptCalls = new Set(copies.flatMap((copy) => toolCallsOf(copy).map(callIdOf)));
119
+ const lostCalls = toolCallsOf(message).filter((call) => callIdOf(call) && !keptCalls.has(callIdOf(call)));
120
+ const lostText = hasText(message.content) && !copies.some((copy) => hasText(copy.content));
121
+ if (!lostCalls.length && !lostText) continue;
122
+ if (lostText) textIds.push(id);
123
+ const { toolCalls: _calls, tool_calls: _wireCalls, ...rest } = message;
124
+ messages.push({
125
+ ...rest,
126
+ content: lostText ? message.content : '',
127
+ ...(lostCalls.length ? { toolCalls: lostCalls } : {}),
128
+ });
129
+ }
130
+ return { messages, textIds };
131
+ }
132
+
133
+ /** What a live view needs to find the rows of what left the context: the
134
+ * stable ids whose text rows went (projection `textIds`) and the tool calls
135
+ * that went (dropped results and calls no kept copy carries). */
136
+ export function droppedIdentitySummary(projection) {
137
+ const callIds = new Set();
138
+ for (const message of projection.messages) {
139
+ for (const call of toolCallsOf(message)) if (callIdOf(call)) callIds.add(callIdOf(call));
140
+ const resultId = resultCallIdOf(message);
141
+ if (resultId) callIds.add(resultId);
142
+ }
143
+ return { messageIds: [...new Set(projection.textIds)], callIds: [...callIds] };
144
+ }
145
+
146
+ /** Stamp stable ids on the transcript a compaction is about to run on (or a
147
+ * resumed session restores rows from) and make sure they are persisted: the
148
+ * next save goes out whole instead of as a delta that would leave the
149
+ * stamped, already-saved messages unchanged on disk. Returns the count. */
150
+ export function stampCompactionMessageIds(messages, sessionId) {
151
+ const stamped = stampMessageIds(messages, { sessionId });
152
+ if (stamped && sessionId) forgetSessionSaveBaseline(String(sessionId));
153
+ return stamped;
154
+ }
155
+ // ── Segment projection and publication ───────────────────────────────────────
156
+
157
+ function storedHistoryMessage(message) {
158
+ const content = sanitizeContentForStoredHistory(message.content);
159
+ return content === message.content ? message : { ...message, content };
160
+ }
161
+
162
+ /** The display items of one segment: what left the context
163
+ * (compactionArchiveProjection messages) as stored history restores it,
164
+ * namespaced and marked archived, then the divider. Empty when nothing
165
+ * visible left. */
166
+ export function compactionSegmentItems(messages, { sessionId, seq, at }) {
167
+ const restored = restoreTranscriptItems(messages.map(storedHistoryMessage), { sessionId });
168
+ if (!restored.length) return [];
169
+ const prefix = `hist_${sessionId}_`;
170
+ const items = restored.map((item) => {
171
+ const id = String(item?.id ?? '');
172
+ return { ...item, id: `arch${seq}_${id.startsWith(prefix) ? id.slice(prefix.length) : id}`, archived: true };
173
+ });
174
+ items.push({
175
+ kind: 'statusdone',
176
+ id: `arch${seq}_divider`,
177
+ status: 'compacted',
178
+ label: COMPACTION_DIVIDER_LABEL,
179
+ at,
180
+ });
181
+ return items;
182
+ }
183
+
184
+ function itemBytes(item) {
185
+ return Buffer.byteLength(JSON.stringify(item));
186
+ }
187
+
188
+ /** A segment line whose bytes plus its trailing newline fit `maxBytes`: a
189
+ * segment that alone exceeds the cap keeps its newest items (and the
190
+ * divider). `bytes` includes the newline. */
191
+ function cappedSegmentLine({ seq, prev, at, items }, maxBytes) {
192
+ let line = compactionSegmentLine({ seq, prev, at, items });
193
+ let bytes = Buffer.byteLength(line) + 1;
194
+ if (bytes <= maxBytes) return { line, bytes };
195
+ const sizes = items.map(itemBytes);
196
+ let start = 0;
197
+ while (bytes > maxBytes && start < items.length - 1) {
198
+ bytes -= sizes[start] + 1;
199
+ start += 1;
200
+ }
201
+ line = compactionSegmentLine({ seq, prev, at, items: items.slice(start) });
202
+ return { line, bytes: Buffer.byteLength(line) + 1 };
203
+ }
204
+
205
+ let publishHook = null;
206
+ /** Test seam: runs after a segment's temp file is written, right before the
207
+ * fenced publication. */
208
+ export function _setCompactionArchivePublishHook(hook) {
209
+ publishHook = typeof hook === 'function' ? hook : null;
210
+ }
211
+
212
+ let syncFile = (handle) => handle.sync();
213
+ /** Test seam: replaces the temp file's fsync (null restores it). */
214
+ export function _setCompactionArchiveFsync(sync) {
215
+ syncFile = typeof sync === 'function' ? sync : (handle) => handle.sync();
216
+ }
217
+
218
+ const recordExists = (dir, sessionId) => existsSync(join(dir, `${sessionId}.json`));
219
+
220
+ /** Write `text` to a private temp file and fsync it — a failed write or
221
+ * fsync fails the publication — then publish it with a synchronous fence
222
+ * check + rename: a delete that bumped the epoch (or removed the record)
223
+ * since `epoch` was captured wins. Any outcome but a rename removes the temp
224
+ * file. */
225
+ async function publishArchive({ dir, path, sessionId, text, epoch }) {
226
+ const tmp = join(dir, `.${basename(path)}.${randomBytes(8).toString('hex')}.tmp`);
227
+ const removeTmp = () => {
228
+ try {
229
+ unlinkSync(tmp);
230
+ } catch {
231
+ /* already gone */
232
+ }
233
+ };
234
+ try {
235
+ const handle = await open(tmp, 'wx', 0o600);
236
+ try {
237
+ await handle.writeFile(text, 'utf8');
238
+ await syncFile(handle);
239
+ } finally {
240
+ await handle.close();
241
+ }
242
+ if (publishHook) await publishHook({ sessionId, path, tmp });
243
+ } catch (error) {
244
+ removeTmp();
245
+ throw error;
246
+ }
247
+ // No await between the fence and the rename.
248
+ if (compactionArchiveDeleteEpoch(sessionId) !== epoch || !recordExists(dir, sessionId)) {
249
+ removeTmp();
250
+ return false;
251
+ }
252
+ try {
253
+ renameWithRetrySync(tmp, path);
254
+ } catch (error) {
255
+ removeTmp();
256
+ throw error;
257
+ }
258
+ return true;
259
+ }
260
+
261
+ async function appendSegment({ sessionId, seq, at, items, clearedThrough, maxBytes, epoch }) {
262
+ const dir = getStoreDir();
263
+ const path = compactionArchivePath(dir, sessionId);
264
+ // A deleted session must not regrow its archive.
265
+ if (!path || compactionArchiveDeleteEpoch(sessionId) !== epoch || !recordExists(dir, sessionId)) return null;
266
+ let text = '';
267
+ try {
268
+ text = await readFile(path, 'utf8');
269
+ } catch {
270
+ text = '';
271
+ }
272
+ const existing = [];
273
+ for (const line of text.split('\n')) {
274
+ const head = compactionSegmentHead(line);
275
+ // Segments a clear hid are dropped on the next write.
276
+ if (head && head.seq > clearedThrough && head.seq !== seq) existing.push({ line, head });
277
+ }
278
+ const prev = existing.length ? existing.at(-1).head.seq : null;
279
+ const segment = cappedSegmentLine({ seq, prev, at, items }, maxBytes);
280
+ let total = segment.bytes;
281
+ for (const entry of existing) total += Buffer.byteLength(entry.line) + 1;
282
+ while (existing.length && total > maxBytes) {
283
+ total -= Buffer.byteLength(existing.shift().line) + 1;
284
+ }
285
+ const lines = [...existing.map((entry) => entry.line), segment.line];
286
+ if (!(await publishArchive({ dir, path, sessionId, text: `${lines.join('\n')}\n`, epoch }))) return null;
287
+ return lines.map((line) => compactionSegmentHead(line)).filter(Boolean);
288
+ }
289
+
290
+ /**
291
+ * Archive what a committed compaction dropped. Synchronous part: select the
292
+ * dropped messages, capture the delete fence and allocate the segment number
293
+ * on `session.compaction.archive`; the rest runs later. Returns
294
+ * `{ dropped, identities, written }` — the dropped message count, their
295
+ * identities for a live view (droppedIdentitySummary) and the pending write
296
+ * (resolves to the retained segment list) — or null when nothing visible was
297
+ * dropped (a rewrite-only compaction).
298
+ */
299
+ export function scheduleCompactionArchive({
300
+ session,
301
+ sessionId = null,
302
+ before,
303
+ after,
304
+ at = Date.now(),
305
+ maxBytes = COMPACTION_ARCHIVE_MAX_BYTES,
306
+ }) {
307
+ const id = String(sessionId || session?.id || '');
308
+ if (!session || typeof session !== 'object' || !compactionArchivePath('', id)) return null;
309
+ const projection = compactionArchiveProjection(before, after);
310
+ // Only what the transcript shows is archived; a pass that took out nothing
311
+ // visible (a rewrite, control rows, hidden context) leaves the view live.
312
+ // Short-circuits on the first visible message.
313
+ if (!projection.messages.some((message) => restoreTranscriptItems([message], { sessionId: id }).length > 0)) {
314
+ return null;
315
+ }
316
+ const dropped = droppedByCompaction(before, after);
317
+ if (!session.compaction || typeof session.compaction !== 'object') session.compaction = {};
318
+ const previous =
319
+ session.compaction.archive && typeof session.compaction.archive === 'object' ? session.compaction.archive : {};
320
+ const seq = Math.max(1, Math.floor(Number(previous.nextSeq) || 1));
321
+ const clearedThrough = Math.max(0, Math.floor(Number(previous.clearedThrough) || 0));
322
+ const epoch = compactionArchiveDeleteEpoch(id);
323
+ session.compaction.archive = { ...previous, nextSeq: seq + 1 };
324
+ const run = async () => {
325
+ await new Promise((resolve) => setImmediate(resolve));
326
+ const items = compactionSegmentItems(projection.messages, { sessionId: id, seq, at });
327
+ if (!items.length) return null;
328
+ const segments = await appendSegment({ sessionId: id, seq, at, items, clearedThrough, maxBytes, epoch });
329
+ const archive = session.compaction?.archive;
330
+ // Small metadata only; a later clear may have hidden this segment.
331
+ if (segments && archive && typeof archive === 'object' && !(Number(archive.clearedThrough) >= seq)) {
332
+ archive.segments = segments;
333
+ }
334
+ return segments;
335
+ };
336
+ const task = (pendingWrites.get(id) || Promise.resolve()).then(run).catch((error) => {
337
+ writeStderrBestEffort(`[session] compaction archive write failed (sess=${id}): ${error?.message || error}\n`);
338
+ return null;
339
+ });
340
+ pendingWrites.set(id, task);
341
+ void task.finally(() => {
342
+ if (pendingWrites.get(id) === task) pendingWrites.delete(id);
343
+ });
344
+ return { dropped: dropped.length, identities: droppedIdentitySummary(projection), written: task };
345
+ }
346
+
347
+ /** Hide everything archived so far (a clear): later reads and the next write
348
+ * skip those segments. */
349
+ export function hideCompactionArchive(session) {
350
+ const archive = session?.compaction?.archive;
351
+ if (!archive || typeof archive !== 'object') return;
352
+ session.compaction.archive = {
353
+ ...archive,
354
+ clearedThrough: Math.max(0, Math.floor(Number(archive.nextSeq) || 1) - 1),
355
+ segments: [],
356
+ };
357
+ }
358
+
359
+ /** Settles once every archive write scheduled so far has finished. */
360
+ export async function flushCompactionArchiveWrites() {
361
+ await Promise.all([...pendingWrites.values()]);
362
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Display-only archive of the conversation a compaction dropped.
3
+ *
4
+ * One JSONL sidecar per session next to its record
5
+ * (`<id>.compaction-archive.jsonl`), one line per compaction segment, oldest
6
+ * first. A segment holds the transcript ITEMS (the pane's display shape, never
7
+ * model messages) its compaction removed from the model context, followed by
8
+ * the "Conversation compacted" divider. Each line opens with a fixed header
9
+ * (`{"v":1,"seq":N,"id":"segN","prev":…,"at":…,"count":…,"bytes":…,"items":[…]}`)
10
+ * so a reader can pick segments by number without parsing their items.
11
+ *
12
+ * Nothing here ever reaches `session.messages` or a provider payload: the
13
+ * archive is read only by the stored-transcript projection and the paged
14
+ * history loader. Leaf module (no store.mjs, no config) so the cold catalog
15
+ * path can read it; the writer lives in compaction-archive-capture.mjs.
16
+ */
17
+ import { unlinkSync } from 'node:fs';
18
+ import { open } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+
21
+ export const COMPACTION_ARCHIVE_SUFFIX = '.compaction-archive.jsonl';
22
+ /** The one label every compaction boundary row carries (persisted and live),
23
+ * translated by the desktop as the app's compaction wording. */
24
+ export const COMPACTION_DIVIDER_LABEL = 'Conversation compacted';
25
+ /** Per-session cap; the oldest segments are dropped past it. */
26
+ export const COMPACTION_ARCHIVE_MAX_BYTES = 32 * 1024 * 1024;
27
+
28
+ const SESSION_ID = /^[A-Za-z0-9_-]+$/;
29
+ const SEGMENT_HEAD = /^\{"v":1,"seq":(\d+),"id":"[^"]*","prev":(null|"[^"]*"),"at":(\d+),"count":(\d+),"bytes":(\d+),/;
30
+
31
+ /** The archive sidecar of `sessionId` inside a sessions directory, or null
32
+ * for an id that is not a stored session id. */
33
+ export function compactionArchivePath(storeDir, sessionId) {
34
+ const id = String(sessionId || '');
35
+ if (!SESSION_ID.test(id)) return null;
36
+ return join(storeDir, `${id}${COMPACTION_ARCHIVE_SUFFIX}`);
37
+ }
38
+
39
+ /** The visible archive a session record names: segments numbered in
40
+ * (after, before). A clear hides everything archived before it. Null when
41
+ * nothing is visible. */
42
+ export function compactionArchiveRange(session) {
43
+ const archive = session?.compaction?.archive;
44
+ if (!archive || typeof archive !== 'object') return null;
45
+ const before = Number(archive.nextSeq);
46
+ const after = Math.max(0, Math.floor(Number(archive.clearedThrough) || 0));
47
+ if (!Number.isInteger(before) || before - 1 <= after) return null;
48
+ return { after, before };
49
+ }
50
+
51
+ /** A segment line's header fields, or null for a line that is not one. */
52
+ export function compactionSegmentHead(line) {
53
+ const match = SEGMENT_HEAD.exec(String(line || ''));
54
+ if (!match) return null;
55
+ return {
56
+ seq: Number(match[1]),
57
+ id: `seg${match[1]}`,
58
+ prev: match[2] === 'null' ? null : match[2].slice(1, -1),
59
+ at: Number(match[3]),
60
+ count: Number(match[4]),
61
+ bytes: Number(match[5]),
62
+ };
63
+ }
64
+
65
+ /** One segment as its JSONL line (no trailing newline). */
66
+ export function compactionSegmentLine({ seq, prev, at, items }) {
67
+ const body = JSON.stringify(items);
68
+ return `{"v":1,"seq":${seq},"id":"seg${seq}","prev":${prev == null ? 'null' : JSON.stringify(`seg${prev}`)},"at":${Math.max(0, Math.floor(Number(at) || 0))},"count":${items.length},"bytes":${Buffer.byteLength(body)},"items":${body}}`;
69
+ }
70
+
71
+ function segmentItems(text) {
72
+ try {
73
+ const items = JSON.parse(text)?.items;
74
+ return Array.isArray(items) ? items : [];
75
+ } catch {
76
+ return [];
77
+ }
78
+ }
79
+
80
+ /**
81
+ * One page from a segment list (oldest → newest heads; `load(entry)` yields a
82
+ * segment's items): segments numbered in (after, before), newest first,
83
+ * skipping the `skip` newest items a reader already holds and returning at
84
+ * most `limit` items above them, in display order. Item counts come from
85
+ * the segment headers: a segment wholly inside the skipped rows or past the
86
+ * page is never read or parsed. `hasOlder` says whether older items remain.
87
+ */
88
+ async function pageFromSegments(
89
+ entries,
90
+ load,
91
+ { after = 0, before = Number.POSITIVE_INFINITY, skip = 0, limit = 0 } = {}
92
+ ) {
93
+ const held = Math.max(0, Math.floor(Number(skip) || 0));
94
+ const wanted = Math.max(0, Math.floor(Number(limit) || 0));
95
+ const parts = [];
96
+ // Items newer than this entry, counted from the newest.
97
+ let newer = 0;
98
+ let more = false;
99
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
100
+ const { head } = entries[index];
101
+ if (head.seq <= after || head.seq >= before || head.count < 1) continue;
102
+ if (newer >= held + wanted) {
103
+ more = true;
104
+ break;
105
+ }
106
+ const count = head.count;
107
+ if (newer + count > held) {
108
+ // This segment holds rows of the page: newest-first offsets
109
+ // [max(held, newer), min(held + wanted, newer + count)) of it.
110
+ const items = await load(entries[index]);
111
+ const fromEnd = Math.max(0, held - newer);
112
+ const take = Math.min(held + wanted, newer + count) - Math.max(held, newer);
113
+ const end = items.length - fromEnd;
114
+ parts.push(items.slice(Math.max(0, end - take), Math.max(0, end)));
115
+ if (newer + count > held + wanted) more = true;
116
+ }
117
+ newer += count;
118
+ }
119
+ return { items: parts.reverse().flat(), hasOlder: more };
120
+ }
121
+
122
+ /** pageFromSegments over archive text (tests and in-memory callers). */
123
+ export function compactionArchivePageFromText(text, range = {}) {
124
+ const entries = [];
125
+ for (const line of String(text || '').split('\n')) {
126
+ const head = compactionSegmentHead(line);
127
+ if (head) entries.push({ head, line });
128
+ }
129
+ return pageFromSegments(entries, (entry) => segmentItems(entry.line), range);
130
+ }
131
+
132
+ // Per-sidecar segment index (byte offsets + headers), keyed by the file
133
+ // identity it was built from: a page reads only the segments it reaches, and
134
+ // the headers are indexed once per change rather than once per page.
135
+ const INDEX_CACHE_LIMIT = 16;
136
+ const archiveIndexes = new Map();
137
+
138
+ const HEAD_PROBE_BYTES = 512;
139
+ const ITEMS_KEY = '"items":';
140
+ const SCAN_CHUNK_BYTES = 64 * 1024;
141
+
142
+ /** Offset of the first newline at or after `from` (or `size`), scanning. */
143
+ async function nextNewline(handle, from, size) {
144
+ const chunk = Buffer.alloc(SCAN_CHUNK_BYTES);
145
+ for (let at = from; at < size; at += SCAN_CHUNK_BYTES) {
146
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(SCAN_CHUNK_BYTES, size - at), at);
147
+ const hit = chunk.subarray(0, bytesRead).indexOf(0x0a);
148
+ if (hit >= 0) return at + hit;
149
+ }
150
+ return size;
151
+ }
152
+
153
+ /** Segment offsets of an archive file. Each header names its items' byte
154
+ * length, so the index reads only the headers and jumps over the bodies; a
155
+ * line whose header does not account for its length is scanned past. */
156
+ async function indexArchive(handle, size) {
157
+ const entries = [];
158
+ const probe = Buffer.alloc(HEAD_PROBE_BYTES);
159
+ let start = 0;
160
+ while (start < size) {
161
+ const { bytesRead } = await handle.read(probe, 0, Math.min(HEAD_PROBE_BYTES, size - start), start);
162
+ const text = probe.toString('latin1', 0, bytesRead);
163
+ const match = SEGMENT_HEAD.exec(text);
164
+ let end = -1;
165
+ if (match && text.startsWith(ITEMS_KEY, match[0].length)) {
166
+ // `…"items":<body>}` — the header is ASCII, so chars are bytes.
167
+ const candidate = start + match[0].length + ITEMS_KEY.length + Number(match[5]) + 1;
168
+ if (candidate === size) end = candidate;
169
+ else if (candidate < size) {
170
+ const tail = Buffer.alloc(1);
171
+ await handle.read(tail, 0, 1, candidate);
172
+ if (tail[0] === 0x0a) end = candidate;
173
+ }
174
+ }
175
+ if (end >= 0) entries.push({ head: compactionSegmentHead(text), start, length: end - start });
176
+ else end = await nextNewline(handle, start, size);
177
+ start = end + 1;
178
+ }
179
+ return entries;
180
+ }
181
+
182
+ async function archiveIndexFor(path, handle) {
183
+ const info = await handle.stat();
184
+ const stamp = `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}`;
185
+ const cached = archiveIndexes.get(path);
186
+ if (cached?.stamp === stamp) {
187
+ archiveIndexes.delete(path);
188
+ archiveIndexes.set(path, cached);
189
+ return cached.entries;
190
+ }
191
+ const entries = await indexArchive(handle, info.size);
192
+ archiveIndexes.set(path, { stamp, entries });
193
+ while (archiveIndexes.size > INDEX_CACHE_LIMIT) archiveIndexes.delete(archiveIndexes.keys().next().value);
194
+ return entries;
195
+ }
196
+
197
+ /** One page of a session's archive sidecar (see pageFromSegments). The
198
+ * open handle pins the file a concurrent atomic replace may swap out, so
199
+ * the index and the segment reads describe the same bytes. */
200
+ export async function readCompactionArchivePage(storeDir, sessionId, range = {}) {
201
+ const path = compactionArchivePath(storeDir, sessionId);
202
+ if (!path) return { items: [], hasOlder: false };
203
+ let handle;
204
+ try {
205
+ handle = await open(path, 'r');
206
+ } catch {
207
+ // Display-only history: an absent or unreadable archive shows nothing.
208
+ archiveIndexes.delete(path);
209
+ return { items: [], hasOlder: false };
210
+ }
211
+ try {
212
+ const entries = await archiveIndexFor(path, handle);
213
+ return await pageFromSegments(
214
+ entries,
215
+ async (entry) => {
216
+ const buffer = Buffer.alloc(entry.length);
217
+ await handle.read(buffer, 0, entry.length, entry.start);
218
+ return segmentItems(buffer.toString('utf8'));
219
+ },
220
+ range
221
+ );
222
+ } catch {
223
+ return { items: [], hasOlder: false };
224
+ } finally {
225
+ await handle.close().catch(() => {});
226
+ }
227
+ }
228
+
229
+ // Delete fence: every deletion bumps the session's epoch. A writer captures
230
+ // the epoch before it starts and publishes only if it is unchanged at the
231
+ // (synchronous) rename, so a delete that lands while a segment is being
232
+ // written is never undone by that write.
233
+ const deleteEpochs = new Map();
234
+
235
+ export function compactionArchiveDeleteEpoch(sessionId) {
236
+ return deleteEpochs.get(String(sessionId || '')) || 0;
237
+ }
238
+
239
+ /** Remove a session's archive (session delete) and fence every write that
240
+ * started before it. Best-effort: true when the sidecar is gone afterwards. */
241
+ export function deleteCompactionArchive(storeDir, sessionId) {
242
+ const path = compactionArchivePath(storeDir, sessionId);
243
+ if (!path) return false;
244
+ const id = String(sessionId);
245
+ deleteEpochs.set(id, compactionArchiveDeleteEpoch(id) + 1);
246
+ archiveIndexes.delete(path);
247
+ try {
248
+ unlinkSync(path);
249
+ return true;
250
+ } catch (error) {
251
+ return error?.code === 'ENOENT';
252
+ }
253
+ }
@@ -34,7 +34,7 @@ export function resumeContinuation(
34
34
  return false;
35
35
  }
36
36
  if (!segments.commitIntermediate(response)) return false;
37
- if (hasContent) segments.record(response.content);
37
+ if (hasContent) segments.record(response.content, { committed: true });
38
38
  state.providerContinuationCount += 1;
39
39
  state.continuationsSinceToolBatch += 1;
40
40
  state.emptyNudgeStreak = 0;
@@ -1,8 +1,2 @@
1
1
  // stderr diagnostics for the no-tool recovery ladders; never throws.
2
- export function writeLoopDiagnostic(line) {
3
- try {
4
- process.stderr.write(line);
5
- } catch {
6
- /* diagnostics only */
7
- }
8
- }
2
+ export { writeStderrBestEffort as writeLoopDiagnostic } from '../../../../../shared/best-effort-stderr.mjs';
@@ -15,7 +15,7 @@ export function resolveRefusal(response, hasContent, { state, segments, messages
15
15
  // A provider may emit harmless narration before its safety classifier
16
16
  // terminates the turn. Preserve that partial turn and its stop reason,
17
17
  // but never mistake the non-empty text for a successful completion.
18
- if (hasContent && segments.commitIntermediate(response)) segments.record(response.content);
18
+ if (hasContent && segments.commitIntermediate(response)) segments.record(response.content, { committed: true });
19
19
  messages.push({
20
20
  role: 'user',
21
21
  content: REFUSAL_RECOVERY_PROMPT,
@@ -21,14 +21,15 @@ export function createTurnSegments({ messages, opts, suppressMidTurnText }) {
21
21
  // live. A suppressed sub-agent session neither surfaces nor accumulates
22
22
  // text — except on the max-output ladder, whose parts must still rebuild
23
23
  // the complete answer the caller receives (keepWhenSuppressed).
24
- record(text, { keepWhenSuppressed = false } = {}) {
24
+ // `committed: true` marks text that is the just-committed message's own.
25
+ record(text, { keepWhenSuppressed = false, committed = false } = {}) {
25
26
  if (suppressMidTurnText) {
26
27
  if (keepWhenSuppressed) parts.push(text);
27
28
  return;
28
29
  }
29
30
  parts.push(text);
30
31
  try {
31
- opts.onAssistantText?.(text);
32
+ opts.onAssistantText?.(text, { committed });
32
33
  } catch {
33
34
  /* best-effort */
34
35
  }
@@ -108,7 +108,8 @@ export function createNoToolTurnResolver({
108
108
  drainSteering('terminal', {
109
109
  maxPriority: 'next',
110
110
  beforeAppend: () => {
111
- if (segments.commitIntermediate(response) && hasContent) segments.record(response.content);
111
+ if (segments.commitIntermediate(response) && hasContent)
112
+ segments.record(response.content, { committed: true });
112
113
  },
113
114
  })
114
115
  ) {
@@ -375,6 +375,7 @@ export function settleSendResult(state, round, sent) {
375
375
  model: response.model || model,
376
376
  usage: reportedUsage,
377
377
  sendTools: round.sendTools,
378
+ toolCalls: response.toolCalls?.length || 0,
378
379
  })
379
380
  )
380
381
  );