mixdog 0.9.1 → 0.9.3

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 (301) hide show
  1. package/package.json +9 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -4,6 +4,12 @@ import {
4
4
  dedupToolResultBodies,
5
5
  reconcileDedupStubs,
6
6
  estimateMessagesTokens,
7
+ DEFAULT_COMPACTION_BUFFER_TOKENS,
8
+ DEFAULT_COMPACTION_BUFFER_RATIO,
9
+ MAX_COMPACTION_BUFFER_RATIO,
10
+ DEFAULT_COMPACTION_KEEP_TOKENS,
11
+ normalizeCompactionBufferRatio,
12
+ compactionBufferTokensForBoundary,
7
13
  } from './context-utils.mjs';
8
14
 
9
15
  export const SUMMARY_PREFIX = 'A previous model worked on this task and produced the compacted handoff summary below. Build on the work already done and avoid duplicating it; treat the summary as authoritative context for continuing the task. You also retain the preserved recent turns that follow.';
@@ -15,10 +21,14 @@ export const SUMMARY_PREFIX = 'A previous model worked on this task and produced
15
21
  // to tune headroom. Telemetry-persisted bufferTokens/bufferRatio of zero is not
16
22
  // operator config; loop/manager strip it and reapply this default (see
17
23
  // compactBufferConfigForBoundary).
18
- export const DEFAULT_COMPACTION_BUFFER_TOKENS = 0;
19
- export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
20
- export const MAX_COMPACTION_BUFFER_RATIO = 0.25;
21
- export const DEFAULT_COMPACTION_KEEP_TOKENS = 8_000;
24
+ export {
25
+ DEFAULT_COMPACTION_BUFFER_TOKENS,
26
+ DEFAULT_COMPACTION_BUFFER_RATIO,
27
+ MAX_COMPACTION_BUFFER_RATIO,
28
+ DEFAULT_COMPACTION_KEEP_TOKENS,
29
+ normalizeCompactionBufferRatio,
30
+ compactionBufferTokensForBoundary,
31
+ };
22
32
  export const SUMMARY_OUTPUT_TOKENS = 4_096;
23
33
  // Minimum room the generated summary needs after the mandatory (system +
24
34
  // preserved tail) cost is accounted for. When the configured target budget is
@@ -221,6 +231,20 @@ function isProtectedContextUserMessage(m) {
221
231
  return m.content.trimStart().startsWith('<system-reminder>');
222
232
  }
223
233
 
234
+ // An injected Skill-body user message (the general newMessages channel carries
235
+ // the full SKILL.md body as a role:'user' message after the Skill tool_result).
236
+ // Like isSummaryMessage / isProtectedContextUserMessage, it is detected by
237
+ // content prefix (the `<skill>` envelope from buildSkillResultEnvelope) so the
238
+ // check survives even if the synthetic `meta` field is dropped during a tail
239
+ // rebuild. It is NOT the human's latest prompt and must be excluded from
240
+ // "latest human request" selection (deriveCurrentRequest /
241
+ // buildRecallFastTrackQuery). The `meta:'skill'` marker is also honoured.
242
+ function isInjectedSkillBodyMessage(m) {
243
+ if (m?.role !== 'user') return false;
244
+ if (m.meta === 'skill') return true;
245
+ return typeof m.content === 'string' && m.content.trimStart().startsWith('<skill>');
246
+ }
247
+
224
248
  function isProtectedContextAckMessage(m) {
225
249
  return m?.role === 'assistant'
226
250
  && typeof m.content === 'string'
@@ -365,29 +389,26 @@ export function redactToolCallSecretsInMessages(messages) {
365
389
  return messages.map((m) => redactMessageToolCallSecrets(m));
366
390
  }
367
391
 
368
- export function normalizeCompactionBufferRatio(value, fallback = DEFAULT_COMPACTION_BUFFER_RATIO) {
369
- const n = Number(value);
370
- if (Number.isFinite(n) && n > 0) return n > 1 ? n / 100 : n;
371
- return fallback;
372
- }
373
-
374
- export function compactionBufferTokensForBoundary(boundaryTokens, opts = {}) {
375
- const boundary = Math.max(0, Math.floor(Number(boundaryTokens) || 0));
376
- const explicit = Math.max(0, Math.floor(Number(opts.explicitTokens) || 0));
377
- if (!boundary) return explicit;
378
- const maxRatio = normalizeCompactionBufferRatio(opts.maxRatio, MAX_COMPACTION_BUFFER_RATIO);
379
- const cap = Math.max(0, Math.floor(boundary * maxRatio));
380
- if (explicit > 0) return Math.max(0, Math.min(explicit, cap));
381
- const ratio = normalizeCompactionBufferRatio(opts.ratio, DEFAULT_COMPACTION_BUFFER_RATIO);
382
- return Math.max(0, Math.min(Math.floor(boundary * ratio), cap));
383
- }
384
-
385
- function effectiveBudget(budgetTokens, opts) {
392
+ // Floor for the reserve-adjusted compact budget. When the tool-schema/request
393
+ // reserve rivals the whole budget (huge agent tool surfaces), subtracting the
394
+ // full reserve could leave a degenerate target; keep enough room to attempt a
395
+ // summary and let the final fit check decide. Logged as degraded because a
396
+ // floored budget can still overflow on the next send.
397
+ const MIN_EFFECTIVE_COMPACT_BUDGET_TOKENS = 1024;
398
+ export function effectiveBudget(budgetTokens, opts) {
386
399
  if (!(budgetTokens > 0)) throw new Error('compact: budgetTokens must be > 0');
387
400
  const reserve = Number(opts?.reserveTokens) || 0;
388
401
  if (reserve <= 0) return budgetTokens;
389
- const effectiveReserve = Math.min(reserve, Math.floor(budgetTokens * 0.5));
390
- return Math.max(1, budgetTokens - effectiveReserve);
402
+ // Subtract the FULL reserve so an accepted compact actually fits next to
403
+ // the request reserve on the following send. The previous 50%-of-budget cap
404
+ // under-reserved large tool surfaces (agent sessions): a compact could be
405
+ // "accepted" at budget/2 while the true remaining room was smaller, then
406
+ // overflow immediately on the next request.
407
+ const remaining = budgetTokens - reserve;
408
+ if (remaining >= MIN_EFFECTIVE_COMPACT_BUDGET_TOKENS) return remaining;
409
+ const floored = Math.max(1, Math.min(budgetTokens, MIN_EFFECTIVE_COMPACT_BUDGET_TOKENS));
410
+ try { process.stderr.write(`[compact] degraded budget: reserve=${reserve} leaves ${remaining} of budget=${budgetTokens}; flooring to ${floored}\n`); } catch { /* best-effort */ }
411
+ return floored;
391
412
  }
392
413
 
393
414
  const PRUNE_TOOL_OUTPUT_MAX_CHARS = 2_000;
@@ -434,6 +455,39 @@ export function compactTypeIsRecallFastTrack(value) {
434
455
  return normalizeCompactType(value) === COMPACT_TYPE_RECALL_FASTTRACK;
435
456
  }
436
457
 
458
+ function compactDebugEnabled() {
459
+ return String(process.env.MIXDOG_COMPACT_DEBUG || '').trim() === '1';
460
+ }
461
+
462
+ function compactDebugLog(scope, details = {}) {
463
+ if (!compactDebugEnabled()) return;
464
+ try {
465
+ process.stderr.write(`[compact] ${scope} ${JSON.stringify(details)}\n`);
466
+ } catch { /* best-effort diagnostics only */ }
467
+ }
468
+
469
+ function safeEstimateMessagesTokens(messages) {
470
+ try { return estimateMessagesTokens(messages); }
471
+ catch { return null; }
472
+ }
473
+
474
+ function textByteLength(text) {
475
+ try { return Buffer.byteLength(String(text || ''), 'utf8'); }
476
+ catch { return String(text || '').length; }
477
+ }
478
+
479
+ function messageContentHasMarker(m, marker) {
480
+ if (!m || !marker) return false;
481
+ if (typeof m.content === 'string') return m.content.includes(marker);
482
+ if (Array.isArray(m.content)) {
483
+ return m.content.some((part) => {
484
+ if (!part || typeof part !== 'object') return false;
485
+ return String(part.text || part.content || '').includes(marker);
486
+ });
487
+ }
488
+ return false;
489
+ }
490
+
437
491
  // Count raw (unchunked) pending rows still present in a dump_session_roots
438
492
  // payload. recall-fasttrack must keep cycle1-chunking until this reaches 0 so
439
493
  // the injected root is the chunked summary, not the raw transcript tail.
@@ -679,6 +733,39 @@ export function pruneToolOutputs(messages, budgetTokens, opts = {}) {
679
733
  return reconcileDedupStubs(result);
680
734
  }
681
735
 
736
+ // Anchor-independent tool-output prune (loop overflow safety net).
737
+ //
738
+ // pruneToolOutputs protects the most-recent tailTurns of USER-anchored history,
739
+ // so a single-turn transcript with no user boundary yields protectFrom=0 and
740
+ // prunes nothing. This variant needs no user anchor: it middle-truncates the
741
+ // OLDEST oversized tool_result bodies first, walking forward, until the
742
+ // transcript fits the budget. The newest tool_result is truncated last (and
743
+ // only if still necessary) so fresh state is preserved as long as possible.
744
+ // Structure/pairing is preserved (only string content shrinks), and the result
745
+ // is re-reconciled so tool pairing stays provider-valid.
746
+ export function pruneToolOutputsUnanchored(messages, budgetTokens, opts = {}) {
747
+ const budget = effectiveBudget(budgetTokens, opts);
748
+ let result = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
749
+ if (estimateMessagesTokens(result) <= budget) return result;
750
+
751
+ const maxChars = Math.max(256, Number(opts?.maxToolOutputChars) || PRUNE_TOOL_OUTPUT_MAX_CHARS);
752
+ // Oldest -> newest so recent tool output survives longest. No user-turn
753
+ // protection: every oversized tool_result is a candidate.
754
+ for (let i = 0; i < result.length; i += 1) {
755
+ const m = result[i];
756
+ if (m?.role !== 'tool' || typeof m.content !== 'string') continue;
757
+ if (m.content.length <= maxChars) continue;
758
+ result[i] = {
759
+ ...m,
760
+ content: pruneToolOutputText(m.content, maxChars, m.toolCallId),
761
+ compacted: true,
762
+ compactedKind: 'tool_output_prune',
763
+ };
764
+ if (estimateMessagesTokens(result) <= budget) break;
765
+ }
766
+ return reconcileDedupStubs(result);
767
+ }
768
+
682
769
  function preserveRecentBudget(budget, opts = {}) {
683
770
  const maxForBudget = Math.max(1, Math.floor(Number(budget || 0) * 0.8));
684
771
  const explicit = Number(opts.preserveRecentTokens ?? opts.keepTokens);
@@ -743,14 +830,29 @@ function splitLiveCompactionContext(messages) {
743
830
  return { system: protectedPrefix, live, previousSummary, sanitized };
744
831
  }
745
832
 
746
- function selectCompactionWindow(messages, budget, opts = {}) {
747
- const { system, live, previousSummary } = splitLiveCompactionContext(messages);
748
- if (!userIndexes(live).length) throw new Error('semanticCompactMessages: no user turn to preserve');
833
+ // A tail may begin ONLY at an index that is not a tool result: a tool result
834
+ // must stay paired with the assistant tool_call that precedes it, so it can
835
+ // never be the first message of the preserved tail. Every other role (user /
836
+ // assistant / developer / ...) is a valid tail boundary. This replaces the old
837
+ // "the tail must begin at a real user turn" rule, which threw whenever the
838
+ // recent window carried no user message (single-turn agent sessions whose tail
839
+ // is assistant/tool only).
840
+ function findValidCutIndices(live) {
841
+ const out = [];
842
+ for (let i = 0; i < live.length; i += 1) {
843
+ if (live[i]?.role === 'tool') continue;
844
+ out.push(i);
845
+ }
846
+ return out;
847
+ }
749
848
 
750
- const tailTurns = Math.max(1, Number(opts.tailTurns) || DEFAULT_TAIL_TURNS);
751
- const recentBudget = preserveRecentBudget(budget, opts);
849
+ // User-anchored path (unchanged behaviour): keep up to tailTurns recent turns
850
+ // bounded by recentBudget, splitting the newest turn's suffix when it alone is
851
+ // too large. Preserved verbatim so Lead / normal sessions with real user turns
852
+ // compact exactly as before.
853
+ function selectTailStartByTurns(live, recentBudget, tailTurns, previousSummary, opts) {
752
854
  const indexedTurns = indexLiveTurns(live);
753
- if (indexedTurns.length === 0) throw new Error('semanticCompactMessages: no user turn to preserve');
855
+ if (indexedTurns.length === 0) return live.length;
754
856
 
755
857
  let tailStartIdx = live.length;
756
858
  let keptTurns = 0;
@@ -786,20 +888,90 @@ function selectCompactionWindow(messages, budget, opts = {}) {
786
888
  break;
787
889
  }
788
890
 
891
+ if (opts.force === true && !previousSummary && tailStartIdx <= 0) {
892
+ if (indexedTurns.length >= 2) {
893
+ tailStartIdx = indexedTurns[1].start;
894
+ } else if (indexedTurns.length === 1) {
895
+ const onlyTurn = indexedTurns[0];
896
+ const splitIdx = splitTurnStartIndexForBudget(onlyTurn, recentBudget);
897
+ if (splitIdx > onlyTurn.start && splitIdx < onlyTurn.end) {
898
+ tailStartIdx = splitIdx;
899
+ } else if (onlyTurn.end > onlyTurn.start + 1) {
900
+ tailStartIdx = onlyTurn.start + 1;
901
+ }
902
+ }
903
+ }
904
+ return tailStartIdx;
905
+ }
906
+
907
+ // No-user path: pick the tail boundary from valid cut points. Walk newest ->
908
+ // oldest, growing the tail across valid cut points while its suffix still fits
909
+ // recentBudget, and stop before it overflows. Never anchors on a user turn, so
910
+ // an assistant/tool-only single-turn transcript still yields a head to
911
+ // summarize and a paired tail to keep.
912
+ function selectTailStartByCutPoint(live, recentBudget, previousSummary) {
913
+ const validCuts = findValidCutIndices(live);
914
+ if (validCuts.length === 0) return live.length; // degenerate: only tool results
915
+
916
+ let chosen = null;
917
+ for (let k = validCuts.length - 1; k >= 0; k -= 1) {
918
+ const idx = validCuts[k];
919
+ if (estimateMessagesTokens(live.slice(idx)) <= recentBudget) {
920
+ chosen = idx; // fits — try to grow the tail toward an older cut
921
+ continue;
922
+ }
923
+ break; // this cut overflows recentBudget; keep the previous (newer) choice
924
+ }
925
+
926
+ if (chosen === null) {
927
+ // Even the newest valid cut's suffix exceeds recentBudget (a single huge
928
+ // message run). Keep the minimal tail from the newest valid cut so a head
929
+ // remains to summarize; if that cut is at index 0 there is nothing to
930
+ // split off, so keep everything in the head instead. The oversized tail
931
+ // is tolerated downstream (mandatory-cost budget raise) rather than
932
+ // throwing.
933
+ const newestCut = validCuts[validCuts.length - 1];
934
+ return newestCut > 0 ? newestCut : live.length;
935
+ }
936
+
937
+ if (chosen <= 0) {
938
+ // Whole transcript would become the tail => nothing to compact. With no
939
+ // prior summary to build on, pull the tail start forward to the next
940
+ // valid cut so the leading message(s) become the compactable head.
941
+ if (!previousSummary && validCuts.length >= 2) return validCuts[1];
942
+ // Only ONE valid cut (or a leading tool run before it) and no prior
943
+ // summary: there is no older cut to pull forward to. Returning 0 would
944
+ // make the whole transcript the tail with an empty head, and
945
+ // semanticCompactMessages throws on head.length===0 && !previousSummary.
946
+ // Keep everything in the HEAD instead (empty tail) so a head remains to
947
+ // summarize; an empty tail is valid downstream (mandatory = system+tail).
948
+ if (!previousSummary && validCuts.length < 2) return live.length;
949
+ return chosen;
950
+ }
951
+ return chosen;
952
+ }
953
+
954
+ function selectCompactionWindow(messages, budget, opts = {}) {
955
+ const { system, live, previousSummary } = splitLiveCompactionContext(messages);
956
+ const tailTurns = Math.max(1, Number(opts.tailTurns) || DEFAULT_TAIL_TURNS);
957
+ const recentBudget = preserveRecentBudget(budget, opts);
958
+
959
+ const tailStartIdx = userIndexes(live).length
960
+ ? selectTailStartByTurns(live, recentBudget, tailTurns, previousSummary, opts)
961
+ : selectTailStartByCutPoint(live, recentBudget, previousSummary);
962
+
789
963
  const head = live.slice(0, tailStartIdx);
790
964
  let tail = live.slice(tailStartIdx);
965
+ // sanitizeToolPairs/dedup/reconcile repairs any orphan tool_result the cut
966
+ // may have left; because valid cut points never start on a tool result, an
967
+ // assistant tool_call and its trailing tool_results always land on the same
968
+ // side of the boundary, so pairing stays provider-valid.
791
969
  tail = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(tail)));
792
970
 
971
+ // Only a genuinely empty live window is unrecoverable. Absence of a user
972
+ // turn in the tail is no longer an error.
793
973
  if (!head.length && !tail.length) {
794
- throw new Error('semanticCompactMessages: no user turn to preserve');
795
- }
796
- if (tail.length && !tail.some((m) => m?.role === 'user')) {
797
- const lastUserIdx = live.findLastIndex
798
- ? live.findLastIndex((m) => m?.role === 'user')
799
- : (() => { for (let i = live.length - 1; i >= 0; i -= 1) { if (live[i]?.role === 'user') return i; } return -1; })();
800
- if (lastUserIdx < 0 || lastUserIdx >= tailStartIdx) {
801
- throw new Error('semanticCompactMessages: no user turn to preserve');
802
- }
974
+ throw new Error('semanticCompactMessages: nothing to compact (empty live window)');
803
975
  }
804
976
 
805
977
  const preservedFacts = extractPreservedFacts(head);
@@ -970,7 +1142,47 @@ function fitCompactionPrompt(input, targetTokens) {
970
1142
  { role: 'user', content: withFacts },
971
1143
  ]) <= targetTokens) return withFacts;
972
1144
  }
973
- return tryFit(false);
1145
+ const fitted = tryFit(false);
1146
+ if (fitted) return fitted;
1147
+
1148
+ // Emergency deterministic reduction: even at perMessageChars=0 the prompt can
1149
+ // overflow when the head carries a very large NUMBER of messages (each still
1150
+ // emits a `N. role` line). Keep only the newest K head messages and collapse
1151
+ // the rest into a single `[K older messages omitted]` stub line, binary
1152
+ // searching the largest K that fits. This bounds the head by COUNT, not just
1153
+ // per-message chars, so a huge-head transcript still yields a minimal prompt
1154
+ // instead of null (which surfaced as a hard compaction throw).
1155
+ const head = Array.isArray(input.head) ? input.head : [];
1156
+ const baseNoFacts = { ...input, preservedFacts: null };
1157
+ const buildReduced = (k) => {
1158
+ const kept = k > 0 ? head.slice(head.length - k) : [];
1159
+ const omitted = head.length - kept.length;
1160
+ const stubHead = omitted > 0
1161
+ ? [{ role: 'user', content: `[${omitted} older messages omitted]` }, ...kept]
1162
+ : kept;
1163
+ let inp = { ...baseNoFacts, head: stubHead };
1164
+ // Also shrink/drop a prior <previous-summary> (same as the normal fitAt
1165
+ // path) — a large prior summary can keep the prompt over budget even at
1166
+ // K=0. fitPreviousSummaryForCompactionPrompt is a no-op when there is no
1167
+ // previousSummary, so this is safe for the summary-less case.
1168
+ if (estimateCompactionPromptTokens(inp, 0) > targetTokens) {
1169
+ const fitted = fitPreviousSummaryForCompactionPrompt(inp, 0, targetTokens);
1170
+ if (!fitted) return null;
1171
+ inp = fitted;
1172
+ if (estimateCompactionPromptTokens(inp, 0) > targetTokens) return null;
1173
+ }
1174
+ return buildCompactionPrompt(inp, 0);
1175
+ };
1176
+ let lo = 0;
1177
+ let hi = head.length;
1178
+ let best = null;
1179
+ while (lo <= hi) {
1180
+ const mid = Math.floor((lo + hi) / 2);
1181
+ const candidate = buildReduced(mid);
1182
+ if (candidate) { best = candidate; lo = mid + 1; }
1183
+ else hi = mid - 1;
1184
+ }
1185
+ return best;
974
1186
  }
975
1187
 
976
1188
  function extractResponseText(response) {
@@ -1072,7 +1284,7 @@ function deriveRelevantFilesBullets(head) {
1072
1284
  function deriveCurrentRequest(messages) {
1073
1285
  for (let i = (Array.isArray(messages) ? messages.length : 0) - 1; i >= 0; i -= 1) {
1074
1286
  const m = messages[i];
1075
- if (m?.role === 'user' && !isProtectedContextUserMessage(m)) {
1287
+ if (m?.role === 'user' && !isProtectedContextUserMessage(m) && !isInjectedSkillBodyMessage(m)) {
1076
1288
  const text = truncateMiddle(extractText(m).trim(), 400);
1077
1289
  if (text) return text;
1078
1290
  }
@@ -1263,7 +1475,7 @@ export function buildRecallFastTrackQuery(messages, opts = {}) {
1263
1475
  const text = extractText(m).trim();
1264
1476
  if (!text) continue;
1265
1477
  if (recent.length < 6) recent.unshift(text);
1266
- if (!latestUser && m?.role === 'user' && !isProtectedContextUserMessage(m)) {
1478
+ if (!latestUser && m?.role === 'user' && !isProtectedContextUserMessage(m) && !isInjectedSkillBodyMessage(m)) {
1267
1479
  latestUser = text;
1268
1480
  }
1269
1481
  if (latestUser && recent.length >= 6) break;
@@ -1443,6 +1655,144 @@ function fitRecallFastTrackSummaryMessage(oldHistory, recallText, remainingToken
1443
1655
  return best;
1444
1656
  }
1445
1657
 
1658
+ // --- Smart-compact root-based fitting (arrival-time replacement) -----------
1659
+ //
1660
+ // dump_session_roots (memory/index.mjs dumpSessionRootChunks) renders chunks
1661
+ // TIME-ORDERED (oldest first; chunks.sort by sourceTurn/ts/id ascending),
1662
+ // each root/raw block starting with one of:
1663
+ // # chunk N root=ID[ category=X]
1664
+ // # raw_pending N id=ID
1665
+ // # raw_terminal N id=ID
1666
+ // and blocks joined by "\n\n". runRecallFastTrackForSession additionally
1667
+ // prepends a "session_id=..." / cycle1-drain-status preamble before the dump
1668
+ // text (also "\n\n"-joined) — preserved verbatim as a non-block segment.
1669
+ //
1670
+ // Unlike fitRecallFastTrackSummaryMessage (character-slice binary search,
1671
+ // used by the LLM-summary-free but still-mid-turn recall-fasttrack compact
1672
+ // path), the smart-compact arrival path must never cut a root block
1673
+ // mid-entry — losing half a root's content silently corrupts that entry.
1674
+ // This splitter finds block boundaries by the label pattern (robust to
1675
+ // blank lines inside member/raw content, since it anchors on the distinctive
1676
+ // "# chunk /raw_pending/raw_terminal" line rather than a blank-line split).
1677
+ const RECALL_ROOT_BLOCK_HEADER_RE = /^# (?:chunk \d+ root=\d+(?: category=\S+)?|raw_pending \d+ id=\d+|raw_terminal \d+ id=\d+)[ \t]*$/;
1678
+
1679
+ export function splitRecallRootBlocks(text) {
1680
+ const value = String(text || '');
1681
+ if (!value.trim()) return { preamble: '', blocks: [] };
1682
+ const re = new RegExp(RECALL_ROOT_BLOCK_HEADER_RE.source, 'gm');
1683
+ const starts = [];
1684
+ let m;
1685
+ while ((m = re.exec(value)) !== null) {
1686
+ starts.push(m.index);
1687
+ if (re.lastIndex === m.index) re.lastIndex += 1; // zero-width guard, defensive
1688
+ }
1689
+ if (starts.length === 0) return { preamble: value.trim(), blocks: [] };
1690
+ const preamble = value.slice(0, starts[0]).trim();
1691
+ const blocks = [];
1692
+ for (let i = 0; i < starts.length; i += 1) {
1693
+ const start = starts[i];
1694
+ const end = i + 1 < starts.length ? starts[i + 1] : value.length;
1695
+ const raw = value.slice(start, end).trim();
1696
+ if (raw) blocks.push(raw);
1697
+ }
1698
+ return { preamble, blocks };
1699
+ }
1700
+
1701
+ // Minimal-header summary-message wrapper for the smart-compact roots path.
1702
+ // Keeps the SUMMARY_PREFIX anchor (isSummaryMessage / selectCompactionWindow
1703
+ // / clear-preserve / TUI all key off startsWith(SUMMARY_PREFIX)) but skips the
1704
+ // full sha256/roleCounts header line that fitRecallFastTrackSummaryMessage
1705
+ // computes — smart-arrival replacement is a lightweight prefix swap, not the
1706
+ // anchored LLM-summary compact, so a heavy per-call header is unneeded cost.
1707
+ function makeRecallRootsSummaryMessageParts(oldHistory, rootsPart, priorPart, recallMeta = {}) {
1708
+ const header = `${SUMMARY_PREFIX}\nmessages=${(oldHistory || []).length} compact_type=${COMPACT_TYPE_RECALL_FASTTRACK} source=smart-arrival query_sha=${recallMeta.querySha || 'none'}`;
1709
+ const parts = [header];
1710
+ const priorBlock = formatPriorCompactedContextBlock(priorPart);
1711
+ if (priorBlock) parts.push(priorBlock);
1712
+ const roots = String(rootsPart || '').trim();
1713
+ if (roots) parts.push(roots);
1714
+ return makeSummaryMessage(parts.join('\n\n'));
1715
+ }
1716
+
1717
+ // Root-block-aware fit for the smart-compact arrival path (Step1). Mirrors
1718
+ // fitRecallFastTrackSummaryMessage's prior-block binary-search fit, but the
1719
+ // recall body is fit at ROOT-BLOCK granularity: when the full set of root
1720
+ // blocks (kept in original time order) exceeds remainingTokens, the OLDEST
1721
+ // blocks are dropped WHOLE (never character-truncated mid-block) until the
1722
+ // remaining (newest-biased) suffix fits. Because dropping more leading
1723
+ // blocks can only shrink (never grow) the serialized size, the minimal-drop
1724
+ // threshold is found via binary search on the drop count.
1725
+ export function fitRecallRootsMessage(oldHistory, recallText, remainingTokens, recallMeta = {}, priorPart = '') {
1726
+ const prior = String(priorPart || '').trim();
1727
+
1728
+ let fittedPrior = prior;
1729
+ if (prior) {
1730
+ let lo = 0;
1731
+ let hi = prior.length;
1732
+ let bestPriorLen = 0;
1733
+ while (lo <= hi) {
1734
+ const mid = Math.floor((lo + hi) / 2);
1735
+ const candidate = makeRecallRootsSummaryMessageParts(oldHistory, '', prior.slice(0, mid), recallMeta);
1736
+ if (estimateMessagesTokens([candidate]) <= remainingTokens) {
1737
+ bestPriorLen = mid;
1738
+ lo = mid + 1;
1739
+ } else {
1740
+ hi = mid - 1;
1741
+ }
1742
+ }
1743
+ fittedPrior = prior.slice(0, bestPriorLen);
1744
+ if (!fittedPrior && prior) {
1745
+ const markerOnly = makeRecallRootsSummaryMessageParts(oldHistory, '', RECALL_TAIL_TRUNCATION_MARKER, recallMeta);
1746
+ if (estimateMessagesTokens([markerOnly]) <= remainingTokens) {
1747
+ fittedPrior = RECALL_TAIL_TRUNCATION_MARKER;
1748
+ }
1749
+ }
1750
+ }
1751
+
1752
+ const minimal = makeRecallRootsSummaryMessageParts(oldHistory, '', fittedPrior, recallMeta);
1753
+ if (estimateMessagesTokens([minimal]) > remainingTokens) return null;
1754
+
1755
+ const { preamble, blocks } = splitRecallRootBlocks(recallText);
1756
+ if (blocks.length === 0) {
1757
+ // No parseable root-block boundaries (empty / non-dump recallText) —
1758
+ // degrade to keep-whole-if-it-fits, else drop entirely. Never
1759
+ // mid-truncates: a non-block body is treated as a single atomic unit.
1760
+ const whole = String(recallText || '').trim();
1761
+ if (!whole) return minimal;
1762
+ const full = makeRecallRootsSummaryMessageParts(oldHistory, whole, fittedPrior, recallMeta);
1763
+ if (estimateMessagesTokens([full]) <= remainingTokens) return full;
1764
+ return minimal;
1765
+ }
1766
+
1767
+ let loB = 0;
1768
+ let hiB = blocks.length;
1769
+ let bestLo = -1;
1770
+ while (loB <= hiB) {
1771
+ const mid = Math.floor((loB + hiB) / 2);
1772
+ const kept = blocks.slice(mid);
1773
+ const body = [preamble, ...kept].filter(Boolean).join('\n\n');
1774
+ const candidate = makeRecallRootsSummaryMessageParts(oldHistory, body, fittedPrior, recallMeta);
1775
+ if (estimateMessagesTokens([candidate]) <= remainingTokens) {
1776
+ bestLo = mid;
1777
+ hiB = mid - 1;
1778
+ } else {
1779
+ loB = mid + 1;
1780
+ }
1781
+ }
1782
+ if (bestLo >= 0) {
1783
+ const kept = blocks.slice(bestLo);
1784
+ const body = [preamble, ...kept].filter(Boolean).join('\n\n');
1785
+ return makeRecallRootsSummaryMessageParts(oldHistory, body, fittedPrior, recallMeta);
1786
+ }
1787
+ // Even zero root blocks (preamble alone) overflows alongside the fitted
1788
+ // prior — try preamble alone, else the no-recall minimal header.
1789
+ if (preamble) {
1790
+ const preambleOnly = makeRecallRootsSummaryMessageParts(oldHistory, preamble, fittedPrior, recallMeta);
1791
+ if (estimateMessagesTokens([preambleOnly]) <= remainingTokens) return preambleOnly;
1792
+ }
1793
+ return minimal;
1794
+ }
1795
+
1446
1796
  function combinedSignal(parent, timeoutMs) {
1447
1797
  const ms = Number(timeoutMs);
1448
1798
  if (!Number.isFinite(ms) || ms <= 0) return parent || undefined;
@@ -1455,13 +1805,29 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
1455
1805
  if (!provider || typeof provider.send !== 'function') {
1456
1806
  throw new Error('semanticCompactMessages: provider.send is required');
1457
1807
  }
1808
+ const startedAt = Date.now();
1458
1809
  let budget = effectiveBudget(budgetTokens, opts);
1459
1810
  const baseSanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
1811
+ const baseTokens = safeEstimateMessagesTokens(baseSanitized);
1460
1812
  // No-op fast path: if the original sanitized transcript already fits and we
1461
1813
  // are not forced, return it UNCHANGED (no preserved-tail redaction applied)
1462
1814
  // to keep prior no-compaction semantics.
1463
- if (estimateMessagesTokens(baseSanitized) <= budget && opts.force !== true) {
1464
- return { messages: baseSanitized, usage: null, semantic: false };
1815
+ if (baseTokens != null && baseTokens <= budget && opts.force !== true) {
1816
+ return {
1817
+ messages: baseSanitized,
1818
+ usage: null,
1819
+ semantic: false,
1820
+ compactType: COMPACT_TYPE_SEMANTIC,
1821
+ diagnostics: {
1822
+ noOp: true,
1823
+ reason: 'fits_budget',
1824
+ inputMessages: Array.isArray(messages) ? messages.length : 0,
1825
+ baseMessages: baseSanitized.length,
1826
+ baseTokens,
1827
+ budgetTokens: budget,
1828
+ durationMs: Date.now() - startedAt,
1829
+ },
1830
+ };
1465
1831
  }
1466
1832
  // Compaction will proceed: redact sensitive tool-call argument VALUES before
1467
1833
  // window selection so the preserved tail/system that survive verbatim are
@@ -1478,6 +1844,7 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
1478
1844
 
1479
1845
  const mandatory = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs([...selected.system, ...selected.tail])));
1480
1846
  const mandatoryCost = estimateMessagesTokens(mandatory);
1847
+ const originalBudget = budget;
1481
1848
  // The preserved tail is kept verbatim and the head is replaced by a much
1482
1849
  // smaller summary, so the compacted result is always smaller than the
1483
1850
  // input regardless of how the configured target budget compares to the
@@ -1487,6 +1854,7 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
1487
1854
  if (mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS > budget) {
1488
1855
  budget = mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS;
1489
1856
  }
1857
+ const budgetRaisedBy = Math.max(0, budget - originalBudget);
1490
1858
 
1491
1859
  const callBudget = Math.max(1, Math.floor((opts.compactionInputBudgetTokens || budget) * COMPACTION_PROMPT_HEADROOM));
1492
1860
  const prompt = fitCompactionPrompt(selected, callBudget);
@@ -1553,6 +1921,41 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
1553
1921
  if (finalTokens > budget) {
1554
1922
  throw new Error(`semanticCompactMessages: compacted result exceeds budget=${budget} (result=${finalTokens})`);
1555
1923
  }
1924
+ const diagnostics = {
1925
+ noOp: false,
1926
+ inputMessages: Array.isArray(messages) ? messages.length : 0,
1927
+ baseMessages: baseSanitized.length,
1928
+ baseTokens,
1929
+ systemMessages: selected.system.length,
1930
+ headMessages: selected.head.length,
1931
+ originalHeadMessages: selected.originalHead.length,
1932
+ tailMessages: selected.tail.length,
1933
+ mandatoryMessages: mandatory.length,
1934
+ finalMessages: result.length,
1935
+ systemTokens: safeEstimateMessagesTokens(selected.system),
1936
+ headTokens: safeEstimateMessagesTokens(selected.head),
1937
+ tailTokens: safeEstimateMessagesTokens(selected.tail),
1938
+ mandatoryCost,
1939
+ finalTokens,
1940
+ originalBudgetTokens: originalBudget,
1941
+ budgetTokens: budget,
1942
+ budgetRaised: budgetRaisedBy > 0,
1943
+ budgetRaisedBy,
1944
+ remainingTokens: budget - mandatoryCost,
1945
+ callBudgetTokens: callBudget,
1946
+ promptChars: String(prompt || '').length,
1947
+ promptBytes: textByteLength(prompt),
1948
+ promptTokens: safeEstimateMessagesTokens([
1949
+ { role: 'system', content: COMPACTION_SYSTEM_PROMPT },
1950
+ { role: 'user', content: prompt },
1951
+ ]),
1952
+ summaryChars: String(summary || '').length,
1953
+ rawSummaryChars: String(rawSummary || '').length,
1954
+ summaryRepaired: enforced.repaired === true,
1955
+ previousSummary: !!selected.previousSummary,
1956
+ durationMs: Date.now() - startedAt,
1957
+ };
1958
+ compactDebugLog('semantic result', diagnostics);
1556
1959
  return {
1557
1960
  messages: result,
1558
1961
  usage: response?.usage || null,
@@ -1561,6 +1964,7 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
1561
1964
  compactType: COMPACT_TYPE_SEMANTIC,
1562
1965
  summary,
1563
1966
  summaryRepaired: enforced.repaired === true,
1967
+ diagnostics,
1564
1968
  };
1565
1969
  }
1566
1970
 
@@ -1664,7 +2068,54 @@ function fitSingleRecallTurnToCap(turn, cap) {
1664
2068
  function truncateTailToCap(messages, cap) {
1665
2069
  const turn = Array.isArray(messages) ? messages : [];
1666
2070
  if (turn.length === 0) return [];
1667
- return fitSingleRecallTurnToCap(turn, cap);
2071
+ // No user anchor in this turn: keep the NEWEST messages that fit `cap`,
2072
+ // walking backward. (Previously this delegated back to
2073
+ // fitSingleRecallTurnToCap, which re-entered here on a no-user turn —
2074
+ // infinite mutual recursion. Unreachable while a no-user tail threw upstream;
2075
+ // now that a no-user tail is allowed, this path must terminate on its own.)
2076
+ let out = [];
2077
+ let startIdx = turn.length; // index in `turn` where `out` begins
2078
+ for (let i = turn.length - 1; i >= 0; i -= 1) {
2079
+ const candidate = [turn[i], ...out];
2080
+ if (estimateMessagesTokens(candidate) <= cap) {
2081
+ out = candidate;
2082
+ startIdx = i;
2083
+ continue;
2084
+ }
2085
+ if (out.length === 0) {
2086
+ // Even the newest single message exceeds cap: middle-truncate its
2087
+ // string content so at least one message survives.
2088
+ const m = turn[i];
2089
+ const text = typeof m?.content === 'string' ? m.content : extractText(m);
2090
+ const truncated = truncateMessageForRecallTail(text, Math.max(1, cap * RECALL_TAIL_CHARS_PER_TOKEN));
2091
+ out = [{ ...m, content: truncated }];
2092
+ startIdx = i;
2093
+ }
2094
+ break;
2095
+ }
2096
+ // A leading tool_result with no preceding assistant tool_call is an orphan
2097
+ // that sanitizeToolPairs drops — which could empty the whole tail. Extend the
2098
+ // window backward to swallow the preceding non-tool boundary (the assistant
2099
+ // that owns the tool_call), so the pair survives sanitize. Bounded by
2100
+ // startIdx so it always terminates.
2101
+ while (startIdx > 0 && out[0]?.role === 'tool') {
2102
+ startIdx -= 1;
2103
+ out = [turn[startIdx], ...out];
2104
+ }
2105
+ let sanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(out)));
2106
+ // Final guard: if sanitize still emptied the tail but the turn has a non-tool
2107
+ // message, rebuild from the newest non-tool message forward so the tail is
2108
+ // never empty when preservable content exists.
2109
+ if (sanitized.length === 0) {
2110
+ let nt = -1;
2111
+ for (let i = turn.length - 1; i >= 0; i -= 1) {
2112
+ if (turn[i]?.role !== 'tool') { nt = i; break; }
2113
+ }
2114
+ if (nt >= 0) {
2115
+ sanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(turn.slice(nt))));
2116
+ }
2117
+ }
2118
+ return sanitized;
1668
2119
  }
1669
2120
 
1670
2121
  function stripNestedSummaryHeaderLines(text) {
@@ -1713,10 +2164,10 @@ function selectRecallPreservedTail(live, opts = {}) {
1713
2164
  tail = fitSingleRecallTurnToCap(kept[kept.length - 1], cap);
1714
2165
  }
1715
2166
 
1716
- if (!tail.some((m) => m?.role === 'user')) {
1717
- throw new Error('recallFastTrackCompactMessages: preserved tail missing user anchor');
1718
- }
1719
-
2167
+ // A no-user tail is valid: a single-turn agent session may keep only
2168
+ // assistant/tool structure recently. Mirror the semantic cut-point model —
2169
+ // preserve the recent structured turn(s) verbatim without demanding a user
2170
+ // anchor rather than throwing. tool-pairing is already reconciled above.
1720
2171
  const tailStartIdx = recallTailStartIndex(msgs, tail);
1721
2172
  const head = msgs.slice(0, tailStartIdx);
1722
2173
  return { tail, head, tailStartIdx };
@@ -1730,10 +2181,26 @@ function selectRecallTailUserMessages(tail, opts = {}) {
1730
2181
  }
1731
2182
 
1732
2183
  function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
2184
+ const startedAt = Date.now();
1733
2185
  let budget = effectiveBudget(budgetTokens, opts);
1734
2186
  const baseSanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
1735
- if (estimateMessagesTokens(baseSanitized) <= budget && opts.force !== true) {
1736
- return { messages: baseSanitized, recallFastTrack: false };
2187
+ const baseTokens = safeEstimateMessagesTokens(baseSanitized);
2188
+ if (baseTokens != null && baseTokens <= budget && opts.force !== true) {
2189
+ return {
2190
+ messages: baseSanitized,
2191
+ recallFastTrack: false,
2192
+ compactType: COMPACT_TYPE_RECALL_FASTTRACK,
2193
+ query: opts.query || '',
2194
+ diagnostics: {
2195
+ noOp: true,
2196
+ reason: 'fits_budget',
2197
+ inputMessages: Array.isArray(messages) ? messages.length : 0,
2198
+ baseMessages: baseSanitized.length,
2199
+ baseTokens,
2200
+ budgetTokens: budget,
2201
+ durationMs: Date.now() - startedAt,
2202
+ },
2203
+ };
1737
2204
  }
1738
2205
  const sanitized = redactToolCallSecretsInMessages(baseSanitized);
1739
2206
 
@@ -1751,9 +2218,11 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
1751
2218
 
1752
2219
  const mandatory = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs([...safeSystem, ...recallTail])));
1753
2220
  const mandatoryCost = estimateMessagesTokens(mandatory);
2221
+ const originalBudget = budget;
1754
2222
  if (mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS > budget) {
1755
2223
  budget = mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS;
1756
2224
  }
2225
+ const budgetRaisedBy = Math.max(0, budget - originalBudget);
1757
2226
 
1758
2227
  if (!recallFit.recall && !recallFit.prior && opts.allowEmptyRecall !== true) {
1759
2228
  throw new Error('recallFastTrackCompactMessages: recall text is empty');
@@ -1779,10 +2248,50 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
1779
2248
  if (finalTokens > budget) {
1780
2249
  throw new Error(`recallFastTrackCompactMessages: compacted result exceeds budget=${budget} (result=${finalTokens})`);
1781
2250
  }
2251
+ const summaryContent = String(summaryMessage?.content || '');
2252
+ const diagnostics = {
2253
+ noOp: false,
2254
+ inputMessages: Array.isArray(messages) ? messages.length : 0,
2255
+ baseMessages: baseSanitized.length,
2256
+ baseTokens,
2257
+ systemMessages: safeSystem.length,
2258
+ liveMessages: live.length,
2259
+ headMessages: recallHead.length,
2260
+ tailMessages: recallTail.length,
2261
+ mandatoryMessages: mandatory.length,
2262
+ finalMessages: result.length,
2263
+ systemTokens: safeEstimateMessagesTokens(safeSystem),
2264
+ liveTokens: safeEstimateMessagesTokens(live),
2265
+ headTokens: safeEstimateMessagesTokens(recallHead),
2266
+ tailTokens: safeEstimateMessagesTokens(recallTail),
2267
+ mandatoryCost,
2268
+ finalTokens,
2269
+ originalBudgetTokens: originalBudget,
2270
+ budgetTokens: budget,
2271
+ budgetRaised: budgetRaisedBy > 0,
2272
+ budgetRaisedBy,
2273
+ remainingTokens: budget - mandatoryCost,
2274
+ recallChars: recallFit.recall.length,
2275
+ recallBytes: textByteLength(recallFit.recall),
2276
+ priorChars: recallFit.prior.length,
2277
+ priorBytes: textByteLength(recallFit.prior),
2278
+ summaryMessageChars: summaryContent.length,
2279
+ summaryMessageBytes: textByteLength(summaryContent),
2280
+ recallEmpty: !recallFit.recall,
2281
+ priorEmpty: !recallFit.prior,
2282
+ recallTruncatedInSummary: !!recallFit.recall && !summaryContent.includes(recallFit.recall),
2283
+ priorTruncatedInSummary: !!recallFit.prior && !summaryContent.includes(recallFit.prior),
2284
+ tailTruncated: recallTail.some((m) => messageContentHasMarker(m, RECALL_TAIL_TRUNCATION_MARKER) || messageContentHasMarker(m, RECALL_TAIL_SHORT_TRUNCATION_MARKER)),
2285
+ tailOptions: recallTailOpts,
2286
+ previousSummary: !!previousSummary,
2287
+ durationMs: Date.now() - startedAt,
2288
+ };
2289
+ compactDebugLog('recall-fasttrack result', diagnostics);
1782
2290
  return {
1783
2291
  messages: result,
1784
2292
  recallFastTrack: true,
1785
2293
  compactType: COMPACT_TYPE_RECALL_FASTTRACK,
1786
2294
  query: opts.query || '',
2295
+ diagnostics,
1787
2296
  };
1788
2297
  }