mixdog 0.8.1 → 0.9.1

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 (299) hide show
  1. package/README.md +47 -23
  2. package/package.json +34 -28
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +205 -33
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1222 -90
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -29
  62. package/src/lib/plugin-paths.cjs +1 -7
  63. package/src/lib/rules-builder.cjs +242 -98
  64. package/src/lib/text-utils.cjs +1 -1
  65. package/src/mixdog-session-runtime.mjs +2211 -446
  66. package/src/output-styles/default.md +12 -28
  67. package/src/output-styles/extreme-simple.md +9 -6
  68. package/src/output-styles/simple.md +22 -9
  69. package/src/repl.mjs +108 -59
  70. package/src/rules/agent/00-common.md +15 -0
  71. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  72. package/src/rules/agent/30-explorer.md +22 -0
  73. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  75. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  76. package/src/rules/lead/01-general.md +9 -5
  77. package/src/rules/lead/04-workflow.md +51 -12
  78. package/src/rules/lead/lead-tool.md +6 -0
  79. package/src/rules/shared/01-tool.md +12 -1
  80. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  81. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  85. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  86. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  87. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  88. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  89. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  90. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  92. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  93. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  94. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  95. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
  97. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  98. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  100. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  101. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  102. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  103. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  104. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  107. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
  109. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
  110. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  111. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
  113. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  114. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  115. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  116. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  117. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  118. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  119. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  120. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  121. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  122. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  123. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
  124. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
  125. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
  126. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  127. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  130. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  138. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  140. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  143. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  146. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  147. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  148. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  149. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  150. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  151. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  152. package/src/runtime/channels/index.mjs +149 -51
  153. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  154. package/src/runtime/channels/lib/config.mjs +0 -1
  155. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  156. package/src/runtime/channels/lib/executor.mjs +0 -3
  157. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  158. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  159. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  160. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  161. package/src/runtime/channels/lib/session-discovery.mjs +3 -6
  162. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
  164. package/src/runtime/channels/lib/webhook.mjs +1 -1
  165. package/src/runtime/channels/tool-defs.mjs +29 -29
  166. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  167. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  168. package/src/runtime/memory/index.mjs +519 -118
  169. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  170. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  171. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  172. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  173. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  174. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  175. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  176. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  177. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  178. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
  179. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  180. package/src/runtime/memory/lib/memory.mjs +88 -9
  181. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  182. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  183. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  184. package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
  185. package/src/runtime/memory/lib/session-ingest.mjs +196 -0
  186. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  187. package/src/runtime/memory/tool-defs.mjs +46 -37
  188. package/src/runtime/search/index.mjs +104 -473
  189. package/src/runtime/search/lib/config.mjs +0 -4
  190. package/src/runtime/search/lib/state.mjs +1 -15
  191. package/src/runtime/search/lib/web-tools.mjs +44 -25
  192. package/src/runtime/search/tool-defs.mjs +16 -23
  193. package/src/runtime/shared/abort-controller.mjs +1 -1
  194. package/src/runtime/shared/atomic-file.mjs +4 -3
  195. package/src/runtime/shared/background-tasks.mjs +122 -11
  196. package/src/runtime/shared/child-spawn-gate.mjs +139 -0
  197. package/src/runtime/shared/config.mjs +7 -4
  198. package/src/runtime/shared/err-text.mjs +131 -4
  199. package/src/runtime/shared/llm/cost.mjs +2 -2
  200. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  201. package/src/runtime/shared/llm/index.mjs +34 -11
  202. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  203. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  204. package/src/runtime/shared/singleton-owner.mjs +104 -0
  205. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  206. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  207. package/src/runtime/shared/tool-surface.mjs +624 -95
  208. package/src/runtime/shared/user-data-guard.mjs +0 -2
  209. package/src/standalone/agent-task-status.mjs +203 -0
  210. package/src/standalone/agent-task-status.test.mjs +76 -0
  211. package/src/standalone/agent-tool.mjs +1913 -0
  212. package/src/standalone/channel-worker.mjs +370 -14
  213. package/src/standalone/explore-tool.mjs +165 -70
  214. package/src/standalone/folder-dialog.mjs +314 -0
  215. package/src/standalone/hook-bus.mjs +898 -22
  216. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  217. package/src/standalone/projects.mjs +226 -0
  218. package/src/standalone/provider-admin.mjs +41 -24
  219. package/src/standalone/seeds.mjs +1 -78
  220. package/src/standalone/usage-dashboard.mjs +96 -8
  221. package/src/tui/App.jsx +4800 -2140
  222. package/src/tui/components/AnsiText.jsx +39 -28
  223. package/src/tui/components/ContextPanel.jsx +14 -4
  224. package/src/tui/components/Markdown.jsx +43 -77
  225. package/src/tui/components/MarkdownTable.jsx +9 -184
  226. package/src/tui/components/Message.jsx +28 -11
  227. package/src/tui/components/Picker.jsx +95 -56
  228. package/src/tui/components/PromptInput.jsx +428 -239
  229. package/src/tui/components/QueuedCommands.jsx +1 -1
  230. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  231. package/src/tui/components/Spinner.jsx +67 -38
  232. package/src/tui/components/StatusLine.jsx +606 -38
  233. package/src/tui/components/TextEntryPanel.jsx +128 -9
  234. package/src/tui/components/ToolExecution.jsx +597 -362
  235. package/src/tui/components/TurnDone.jsx +3 -3
  236. package/src/tui/components/UsagePanel.jsx +3 -5
  237. package/src/tui/components/tool-output-format.mjs +499 -0
  238. package/src/tui/components/tool-output-format.test.mjs +312 -0
  239. package/src/tui/dist/index.mjs +9192 -2399
  240. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  241. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  242. package/src/tui/engine.mjs +1455 -279
  243. package/src/tui/figures.mjs +21 -40
  244. package/src/tui/index.jsx +75 -31
  245. package/src/tui/input-editing.mjs +25 -0
  246. package/src/tui/markdown/format-token.mjs +671 -69
  247. package/src/tui/markdown/format-token.test.mjs +312 -0
  248. package/src/tui/markdown/render-ansi.mjs +94 -0
  249. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  250. package/src/tui/markdown/stream-fence.mjs +34 -0
  251. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  252. package/src/tui/markdown/table-layout.mjs +250 -0
  253. package/src/tui/paste-attachments.mjs +0 -7
  254. package/src/tui/spinner-verbs.mjs +1 -2
  255. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  256. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  257. package/src/tui/theme.mjs +756 -24
  258. package/src/tui/time-format.mjs +1 -1
  259. package/src/tui/transcript-tool-failures.mjs +67 -0
  260. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  261. package/src/ui/ansi.mjs +1 -2
  262. package/src/ui/markdown.mjs +85 -26
  263. package/src/ui/markdown.test.mjs +70 -0
  264. package/src/ui/model-display.mjs +121 -0
  265. package/src/ui/session-stats.mjs +44 -0
  266. package/src/ui/statusline-context-label.test.mjs +15 -0
  267. package/src/ui/statusline.mjs +386 -178
  268. package/src/ui/tool-card.mjs +2 -13
  269. package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
  270. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  271. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  272. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  273. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  274. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  275. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  276. package/src/workflows/default/WORKFLOW.md +12 -5
  277. package/src/workflows/default/workflow.json +0 -1
  278. package/src/workflows/solo/WORKFLOW.md +15 -0
  279. package/src/workflows/solo/workflow.json +7 -0
  280. package/vendor/ink/build/ink.js +54 -8
  281. package/vendor/ink/build/output.js +6 -1
  282. package/src/agents/scheduler-task.md +0 -3
  283. package/src/agents/web-researcher/AGENT.md +0 -3
  284. package/src/agents/web-researcher/agent.json +0 -6
  285. package/src/agents/webhook-handler.md +0 -3
  286. package/src/hooks/lib/permission-rules.cjs +0 -170
  287. package/src/hooks/lib/settings-loader.cjs +0 -112
  288. package/src/lib/hook-pipe-path.cjs +0 -10
  289. package/src/rules/bridge/00-common.md +0 -5
  290. package/src/rules/bridge/30-explorer.md +0 -4
  291. package/src/rules/lead/00-tool-lead.md +0 -5
  292. package/src/rules/shared/00-language.md +0 -3
  293. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  294. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  295. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  296. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  297. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  298. package/src/standalone/bridge-tool.mjs +0 -1414
  299. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -7,11 +7,27 @@ import {
7
7
  } from './context-utils.mjs';
8
8
 
9
9
  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.';
10
- export const DEFAULT_COMPACTION_BUFFER_TOKENS = 20_000;
11
- export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.10;
10
+ // Default auto-compact trigger sits below the effective compact boundary by a
11
+ // compaction buffer (10% of boundary, capped at MAX_COMPACTION_BUFFER_RATIO).
12
+ // That headroom lets semantic compact run before the transcript is already at the
13
+ // hard limit (zero buffer caused overflow_failed with no room to summarize).
14
+ // Operators may still set compaction.bufferTokens / bufferPercent / bufferRatio,
15
+ // to tune headroom. Telemetry-persisted bufferTokens/bufferRatio of zero is not
16
+ // operator config; loop/manager strip it and reapply this default (see
17
+ // compactBufferConfigForBoundary).
18
+ export const DEFAULT_COMPACTION_BUFFER_TOKENS = 0;
19
+ export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
12
20
  export const MAX_COMPACTION_BUFFER_RATIO = 0.25;
13
21
  export const DEFAULT_COMPACTION_KEEP_TOKENS = 8_000;
14
22
  export const SUMMARY_OUTPUT_TOKENS = 4_096;
23
+ // Minimum room the generated summary needs after the mandatory (system +
24
+ // preserved tail) cost is accounted for. When the configured target budget is
25
+ // smaller than the mandatory cost (e.g. the preserved recent turn carries a
26
+ // large tool result), the compaction MUST still proceed: the old head is the
27
+ // part being summarized away, so dropping it already shrinks the transcript.
28
+ // Refusing with "exceeds budget" here is what surfaced as auto-clear / overflow
29
+ // compact failures. Floor the working budget to mandatory + this room instead.
30
+ export const COMPACT_SUMMARY_MIN_ROOM_TOKENS = 4_000;
15
31
 
16
32
  const TOOL_CALL_ARGS_MAX_CHARS = 260;
17
33
  const TOOL_CALL_FACT_ARGS_MAX_CHARS = 140;
@@ -20,6 +36,50 @@ const TOOL_ARG_STRING_MAX_CHARS = 360;
20
36
  const TOOL_ARG_ARRAY_MAX_ITEMS = 8;
21
37
  const TOOL_ARG_MAX_DEPTH = 4;
22
38
  const SENSITIVE_TOOL_ARG_KEY_RE = /(?:^|[_-])(?:api[_-]?key|authorization|auth|cookie|credential|passwd|password|refresh[_-]?token|secret|token)(?:$|[_-])/i;
39
+ // Word alternation for raw-string (non-JSON) secret redaction. Mirrors the
40
+ // keys in SENSITIVE_TOOL_ARG_KEY_RE and the session-ingest redactor so a raw
41
+ // tool-call argument string like `authorization: Bearer abc.def` or
42
+ // `password="abc def"` never reaches preserved facts or the compaction prompt.
43
+ const SENSITIVE_TOOL_ARG_KEY_WORD = '(?:api[_-]?key|authorization|auth|cookie|credential|passwd|password|refresh[_-]?token|secret|token)';
44
+ // Full key matcher: the sensitive WORD may carry a prefix and/or suffix segment
45
+ // joined by `_`/`-` so prefixed variants like `access_token`, `access-token`,
46
+ // `x-api-key`, and `bearer_token` are matched as whole keys (not just the bare
47
+ // word at key start). Prefix/suffix are bounded by a `_`/`-` separator so the
48
+ // word stays at an identifier boundary.
49
+ const SENSITIVE_TOOL_ARG_KEY_FULL = `(?:[A-Za-z0-9_-]*[_-])?${SENSITIVE_TOOL_ARG_KEY_WORD}(?:[_-][A-Za-z0-9_-]*)?`;
50
+
51
+ // Redact `key: value` / `key=value` secret pairs inside a raw (non-JSON)
52
+ // string. Consumes the WHOLE value after the key — spaces, `Bearer `/`Basic `
53
+ // scheme words, quoted values with internal spaces, and `;`-separated cookie
54
+ // pairs — so no secret fragment survives. Kept local to compact-core to avoid a
55
+ // cross-module dependency on the memory lib; logic matches session-ingest's
56
+ // redactRawArgString.
57
+ function redactRawSecretString(text) {
58
+ const value = String(text ?? '');
59
+ if (!value) return value;
60
+ const keyRe = new RegExp(`((?:^|[\\s,{(])["']?${SENSITIVE_TOOL_ARG_KEY_FULL}["']?\\s*[:=]\\s*)`, 'gi');
61
+ let out = '';
62
+ let last = 0;
63
+ let match;
64
+ while ((match = keyRe.exec(value)) !== null) {
65
+ const prefixEnd = match.index + match[0].length;
66
+ out += value.slice(last, prefixEnd);
67
+ let i = prefixEnd;
68
+ const quote = value[i] === '"' || value[i] === "'" ? value[i] : '';
69
+ if (quote) {
70
+ i += 1;
71
+ while (i < value.length && value[i] !== quote) i += 1;
72
+ if (i < value.length) i += 1; // include closing quote
73
+ } else {
74
+ while (i < value.length && !/[,)}\n]/.test(value[i])) i += 1;
75
+ }
76
+ out += '[redacted]';
77
+ last = i;
78
+ keyRe.lastIndex = i;
79
+ }
80
+ out += value.slice(last);
81
+ return out;
82
+ }
23
83
 
24
84
  function sha16(value) {
25
85
  const text = typeof value === 'string' ? value : JSON.stringify(value ?? null);
@@ -62,7 +122,10 @@ function truncateMiddle(text, maxChars) {
62
122
  function normalizeToolArgValue(value, key = '', depth = 0) {
63
123
  if (SENSITIVE_TOOL_ARG_KEY_RE.test(String(key || ''))) return '[redacted]';
64
124
  if (typeof value === 'bigint') return String(value);
65
- if (typeof value === 'string') return truncateMiddle(value, TOOL_ARG_STRING_MAX_CHARS);
125
+ // Defense-in-depth: a non-sensitive KEY can still carry a secret embedded
126
+ // in its string VALUE (e.g. a freeform `headers` string). Redact raw
127
+ // key:value secret pairs before truncating.
128
+ if (typeof value === 'string') return truncateMiddle(redactRawSecretString(value), TOOL_ARG_STRING_MAX_CHARS);
66
129
  if (!value || typeof value !== 'object') return value ?? null;
67
130
  if (depth >= TOOL_ARG_MAX_DEPTH) return Array.isArray(value) ? `[array:${value.length}]` : '[object]';
68
131
  if (Array.isArray(value)) {
@@ -86,12 +149,15 @@ function stableToolArgJson(value) {
86
149
  if (/^[\[{]/.test(text)) {
87
150
  try { return JSON.stringify(normalizeToolArgValue(JSON.parse(text))); } catch { /* keep raw */ }
88
151
  }
89
- return truncateMiddle(text, TOOL_ARG_STRING_MAX_CHARS);
152
+ // Non-JSON raw string: redact secret key:value pairs before truncating
153
+ // so toolCallSummary / preserved facts / compaction prompt metadata
154
+ // never leak `authorization: Bearer ...`, passwords, cookies, tokens.
155
+ return truncateMiddle(redactRawSecretString(text), TOOL_ARG_STRING_MAX_CHARS);
90
156
  }
91
157
  try {
92
158
  return JSON.stringify(normalizeToolArgValue(value));
93
159
  } catch {
94
- return truncateMiddle(String(value || ''), TOOL_ARG_STRING_MAX_CHARS);
160
+ return truncateMiddle(redactRawSecretString(String(value || '')), TOOL_ARG_STRING_MAX_CHARS);
95
161
  }
96
162
  }
97
163
 
@@ -127,15 +193,6 @@ function toolResultId(m) {
127
193
  return m?.role === 'tool' && m.toolCallId ? ` tool_result=${m.toolCallId}` : '';
128
194
  }
129
195
 
130
- function lineForMessage(m, index, perMessageChars) {
131
- const role = m?.role || 'unknown';
132
- const text = truncateMiddle(extractText(m).trim(), perMessageChars);
133
- const meta = `${toolCallSummary(m, toolCallArgBudget(perMessageChars))}${toolResultId(m)}`;
134
- return text
135
- ? `${index + 1}. ${role}${meta}: ${text}`
136
- : `${index + 1}. ${role}${meta}`;
137
- }
138
-
139
196
  function compactHeader(oldHistory) {
140
197
  const encoded = JSON.stringify(oldHistory ?? []);
141
198
  return [
@@ -144,64 +201,168 @@ function compactHeader(oldHistory) {
144
201
  ];
145
202
  }
146
203
 
147
- function buildSummaryContent(oldHistory, perMessageChars, factsText = '') {
148
- const lines = compactHeader(oldHistory);
149
- if (factsText) {
150
- lines.push('', factsText);
151
- }
152
- if (oldHistory.length > 0) {
153
- lines.push('timeline:');
154
- for (let i = 0; i < oldHistory.length; i += 1) {
155
- lines.push(lineForMessage(oldHistory[i], i, perMessageChars));
156
- }
157
- }
158
- return lines.join('\n');
159
- }
160
-
161
204
  function makeSummaryMessage(content) {
162
205
  return { role: 'user', content };
163
206
  }
164
207
 
165
- function fitSummaryMessage(oldHistory, remainingTokens) {
166
- const factsText = extractPreservedFacts(oldHistory);
167
- const tryFit = (withFacts) => {
168
- const ft = withFacts ? factsText : '';
169
- const minimalText = ft
170
- ? `${compactHeader(oldHistory).join('\n')}\n\n${ft}`
171
- : compactHeader(oldHistory).join('\n');
172
- const minimal = makeSummaryMessage(minimalText);
173
- if (estimateMessagesTokens([minimal]) > remainingTokens) return null;
208
+ // A compact summary message is a synthetic role:'user' message carrying the
209
+ // SUMMARY_PREFIX anchor. It is NOT a real user turn: it must be excluded from
210
+ // real user-turn boundary calculations and treated as merge input, otherwise
211
+ // an old summary can sit in the preserved tail as a live user message,
212
+ // duplicate, or fail to merge across repeated compaction.
213
+ function isSummaryMessage(m) {
214
+ return m?.role === 'user'
215
+ && typeof m.content === 'string'
216
+ && m.content.startsWith(SUMMARY_PREFIX);
217
+ }
174
218
 
175
- let maxText = 0;
176
- for (const m of oldHistory) maxText = Math.max(maxText, extractText(m).length);
219
+ function isProtectedContextUserMessage(m) {
220
+ if (m?.role !== 'user' || typeof m.content !== 'string') return false;
221
+ return m.content.trimStart().startsWith('<system-reminder>');
222
+ }
177
223
 
178
- let lo = 0;
179
- let hi = Math.max(maxText, 0);
180
- let best = minimal;
181
- while (lo <= hi) {
182
- const mid = Math.floor((lo + hi) / 2);
183
- const candidate = makeSummaryMessage(buildSummaryContent(oldHistory, mid, ft));
184
- if (estimateMessagesTokens([candidate]) <= remainingTokens) {
185
- best = candidate;
186
- lo = mid + 1;
187
- } else {
188
- hi = mid - 1;
189
- }
224
+ function isProtectedContextAckMessage(m) {
225
+ return m?.role === 'assistant'
226
+ && typeof m.content === 'string'
227
+ && m.content.trim() === '.'
228
+ && !Array.isArray(m.toolCalls);
229
+ }
230
+
231
+ function splitProtectedContext(messages) {
232
+ const protectedPrefix = [];
233
+ const conversation = [];
234
+ let prefixMode = true;
235
+ let previousWasProtectedContext = false;
236
+ for (const m of messages || []) {
237
+ if (m?.role === 'system') {
238
+ protectedPrefix.push(m);
239
+ previousWasProtectedContext = false;
240
+ continue;
190
241
  }
191
- return best;
192
- };
193
- if (factsText) {
194
- const withFacts = tryFit(true);
195
- if (withFacts) return withFacts;
242
+ if (prefixMode && isProtectedContextUserMessage(m)) {
243
+ protectedPrefix.push(m);
244
+ previousWasProtectedContext = true;
245
+ continue;
246
+ }
247
+ if (prefixMode && previousWasProtectedContext && isProtectedContextAckMessage(m)) {
248
+ protectedPrefix.push(m);
249
+ previousWasProtectedContext = false;
250
+ continue;
251
+ }
252
+ prefixMode = false;
253
+ previousWasProtectedContext = false;
254
+ conversation.push(m);
196
255
  }
197
- return tryFit(false);
256
+ return { protectedPrefix, conversation };
198
257
  }
199
258
 
200
- function currentTurnStart(nonSystem) {
201
- for (let i = nonSystem.length - 1; i >= 0; i -= 1) {
202
- if (nonSystem[i]?.role === 'user') return i;
259
+ // Redaction-ONLY recursive walk for tool-call argument VALUES kept verbatim
260
+ // through compaction. Unlike normalizeToolArgValue (which is for prompt-side
261
+ // summaries and truncates/summarizes/key-sorts), this preserves shape exactly:
262
+ // - sensitive KEY -> '[redacted]'
263
+ // - string value -> redactRawSecretString(value) (no truncation/middle-cut)
264
+ // - array -> same length, items walked in order (no slicing/caps)
265
+ // - object -> same keys in INSERTION order (no sorting/depth caps)
266
+ // - other primitive (number/boolean/bigint/null) -> returned unchanged
267
+ // Returns { value, changed } so callers can preserve byte-exact input when the
268
+ // walk altered nothing. Only sensitive-key values and embedded raw secret pairs
269
+ // inside strings are altered; everything else is structure identical.
270
+ function redactToolArgValueOnly(value, key = '') {
271
+ if (SENSITIVE_TOOL_ARG_KEY_RE.test(String(key || ''))) {
272
+ return { value: '[redacted]', changed: value !== '[redacted]' };
273
+ }
274
+ if (value == null) return { value, changed: false };
275
+ if (typeof value === 'string') {
276
+ const redacted = redactRawSecretString(value);
277
+ return { value: redacted, changed: redacted !== value };
278
+ }
279
+ if (Array.isArray(value)) {
280
+ let changed = false;
281
+ const out = value.map((item) => {
282
+ const r = redactToolArgValueOnly(item, '');
283
+ if (r.changed) changed = true;
284
+ return r.value;
285
+ });
286
+ return { value: changed ? out : value, changed };
287
+ }
288
+ if (typeof value === 'object') {
289
+ let changed = false;
290
+ const out = {};
291
+ for (const k of Object.keys(value)) {
292
+ const r = redactToolArgValueOnly(value[k], k);
293
+ if (r.changed) changed = true;
294
+ out[k] = r.value;
295
+ }
296
+ return { value: changed ? out : value, changed };
297
+ }
298
+ return { value, changed: false };
299
+ }
300
+
301
+ // Redact sensitive values inside a single tool-call arguments payload while
302
+ // preserving its original shape (string stays a string, object stays an
303
+ // object) so the provider-valid tool_call structure is not broken. Used to
304
+ // scrub messages that survive compaction VERBATIM (preserved tail / mandatory
305
+ // context), where prompt-side redaction does not apply. Redaction-only — no
306
+ // truncation, summarization, key sorting, or depth/array caps.
307
+ function redactToolCallArgumentsValue(rawArgs) {
308
+ if (rawArgs == null) return rawArgs;
309
+ if (typeof rawArgs === 'string') {
310
+ const trimmed = rawArgs.trim();
311
+ if (/^[\[{]/.test(trimmed)) {
312
+ try {
313
+ // Parse, redaction-only walk; only reserialize when the walk
314
+ // actually changed a sensitive value. When nothing changed,
315
+ // return the ORIGINAL string byte-exact (no JSON re-formatting).
316
+ const { value, changed } = redactToolArgValueOnly(JSON.parse(trimmed));
317
+ return changed ? JSON.stringify(value) : rawArgs;
318
+ } catch { /* fall through to raw redaction */ }
319
+ }
320
+ return redactRawSecretString(rawArgs);
321
+ }
322
+ if (typeof rawArgs === 'object') {
323
+ try {
324
+ const { value, changed } = redactToolArgValueOnly(rawArgs);
325
+ return changed ? value : rawArgs;
326
+ } catch { return rawArgs; }
327
+ }
328
+ return rawArgs;
329
+ }
330
+
331
+ // Return a copy of a message with sensitive tool-call argument values redacted,
332
+ // keeping role / content / toolCallId / tool names+ids and non-sensitive args
333
+ // intact. Non-tool-bearing messages are returned unchanged (same reference).
334
+ function redactMessageToolCallSecrets(m) {
335
+ if (!m || typeof m !== 'object' || !Array.isArray(m.toolCalls) || m.toolCalls.length === 0) {
336
+ return m;
203
337
  }
204
- return -1;
338
+ let changed = false;
339
+ const toolCalls = m.toolCalls.map((tc) => {
340
+ if (!tc || typeof tc !== 'object') return tc;
341
+ const out = { ...tc };
342
+ if ('arguments' in tc && tc.arguments != null) {
343
+ const redacted = redactToolCallArgumentsValue(tc.arguments);
344
+ if (redacted !== tc.arguments) { out.arguments = redacted; changed = true; }
345
+ }
346
+ if (tc.function && typeof tc.function === 'object' && tc.function.arguments != null) {
347
+ const redacted = redactToolCallArgumentsValue(tc.function.arguments);
348
+ if (redacted !== tc.function.arguments) {
349
+ out.function = { ...tc.function, arguments: redacted };
350
+ changed = true;
351
+ }
352
+ }
353
+ return out;
354
+ });
355
+ if (!changed) return m;
356
+ return { ...m, toolCalls };
357
+ }
358
+
359
+ // Scrub an array of messages that are kept verbatim through compaction so a
360
+ // recent assistant tool call carrying a secret (e.g. `authorization: Bearer
361
+ // ...`) cannot survive into the returned compacted transcript. Only assistant
362
+ // toolCalls argument VALUES are touched; structure/order/pairing is preserved.
363
+ export function redactToolCallSecretsInMessages(messages) {
364
+ if (!Array.isArray(messages)) return messages;
365
+ return messages.map((m) => redactMessageToolCallSecrets(m));
205
366
  }
206
367
 
207
368
  export function normalizeCompactionBufferRatio(value, fallback = DEFAULT_COMPACTION_BUFFER_RATIO) {
@@ -222,7 +383,7 @@ export function compactionBufferTokensForBoundary(boundaryTokens, opts = {}) {
222
383
  }
223
384
 
224
385
  function effectiveBudget(budgetTokens, opts) {
225
- if (!(budgetTokens > 0)) throw new Error('compactMessages: budgetTokens must be > 0');
386
+ if (!(budgetTokens > 0)) throw new Error('compact: budgetTokens must be > 0');
226
387
  const reserve = Number(opts?.reserveTokens) || 0;
227
388
  if (reserve <= 0) return budgetTokens;
228
389
  const effectiveReserve = Math.min(reserve, Math.floor(budgetTokens * 0.5));
@@ -239,6 +400,99 @@ const COMPACTION_INPUT_MAX_CHARS = 2_000;
239
400
  const COMPACTION_PROMPT_HEADROOM = 0.85;
240
401
  const PRESERVED_FACTS_MAX_CHARS = 600;
241
402
 
403
+ export const COMPACT_TYPE_SEMANTIC = 'semantic';
404
+ export const COMPACT_TYPE_RECALL_FASTTRACK = 'recall-fasttrack';
405
+ export const DEFAULT_COMPACT_TYPE = COMPACT_TYPE_SEMANTIC;
406
+ export const COMPACT_TYPES = Object.freeze([
407
+ COMPACT_TYPE_SEMANTIC,
408
+ COMPACT_TYPE_RECALL_FASTTRACK,
409
+ ]);
410
+
411
+ export function normalizeCompactType(value, fallback = DEFAULT_COMPACT_TYPE) {
412
+ const raw = String(value ?? '').trim().toLowerCase().replace(/_/g, '-');
413
+ if (!raw) return fallback;
414
+ if (raw === '1' || raw === 'type1' || raw === 'type-1' || raw === 'bench1' || raw === 'bench-1' || raw === 'semantic' || raw === 'summary') {
415
+ return COMPACT_TYPE_SEMANTIC;
416
+ }
417
+ // Recall fast-track aliases. `replace(/_/g,'-')` above already folds
418
+ // snake_case (fast_track -> fast-track), but list both dash/no-dash forms
419
+ // explicitly so callers passing either spelling resolve deterministically.
420
+ if (raw === '2' || raw === 'type2' || raw === 'type-2' || raw === 'recall' || raw === 'recall-fast' || raw === 'recall-fasttrack' || raw === 'recall-fast-track' || raw === 'fasttrack' || raw === 'fast-track') {
421
+ return COMPACT_TYPE_RECALL_FASTTRACK;
422
+ }
423
+ // Unknown / unrecognized value: fall back to the caller-provided default
424
+ // (semantic by default). Callers that need to detect an unknown value
425
+ // should compare the input against COMPACT_TYPES before normalizing.
426
+ return fallback;
427
+ }
428
+
429
+ export function compactTypeIsSemantic(value) {
430
+ return normalizeCompactType(value) === COMPACT_TYPE_SEMANTIC;
431
+ }
432
+
433
+ export function compactTypeIsRecallFastTrack(value) {
434
+ return normalizeCompactType(value) === COMPACT_TYPE_RECALL_FASTTRACK;
435
+ }
436
+
437
+ // Count raw (unchunked) pending rows still present in a dump_session_roots
438
+ // payload. recall-fasttrack must keep cycle1-chunking until this reaches 0 so
439
+ // the injected root is the chunked summary, not the raw transcript tail.
440
+ export function countRawPendingRows(dumpText) {
441
+ const text = String(dumpText || '');
442
+ const matches = text.match(/(?:^|\n)# raw_pending\s+\d+\s+id=/gi);
443
+ return matches ? matches.length : 0;
444
+ }
445
+
446
+ // Drain a single session's cycle1 in fixed window×concurrency units, looping
447
+ // until no raw rows remain (or a pass stops making progress / the deadline
448
+ // elapses). This replaces the previous single-pass cycle1 so large sessions
449
+ // get fully chunked before their root is injected into the compacted context.
450
+ export async function drainSessionCycle1(runTool, { sessionId, cycleArgs = {}, dumpArgs, maxPasses = 0, deadlineMs = 0 } = {}) {
451
+ if (typeof runTool !== 'function') throw new Error('drainSessionCycle1: runTool is required');
452
+ if (!sessionId) throw new Error('drainSessionCycle1: sessionId is required');
453
+ if (!dumpArgs) throw new Error('drainSessionCycle1: dumpArgs is required');
454
+ const startedAt = Date.now();
455
+ const hardPasses = Math.max(1, Number(maxPasses) || 50);
456
+ const lines = [];
457
+ let recallText = await runTool('memory', dumpArgs);
458
+ let rawRemaining = countRawPendingRows(recallText);
459
+ let pass = 0;
460
+ while (rawRemaining > 0 && pass < hardPasses) {
461
+ if (deadlineMs > 0 && (Date.now() - startedAt) >= deadlineMs) break;
462
+ pass += 1;
463
+ const passDeadline = deadlineMs > 0
464
+ ? Math.max(1, deadlineMs - (Date.now() - startedAt))
465
+ : 0;
466
+ let passText = '';
467
+ try {
468
+ passText = await runTool('memory', {
469
+ action: 'cycle1',
470
+ sessionId,
471
+ ...cycleArgs,
472
+ ...(passDeadline > 0 ? { _callerDeadlineMs: passDeadline } : {}),
473
+ });
474
+ } catch (err) {
475
+ lines.push(`cycle1 pass=${pass} error=${err?.message || err}`);
476
+ break;
477
+ }
478
+ if (passText) lines.push(`cycle1 pass=${pass}: ${String(passText).trim()}`);
479
+ recallText = await runTool('memory', dumpArgs);
480
+ const nextRaw = countRawPendingRows(recallText);
481
+ // No forward progress (raw not shrinking) — stop instead of spinning.
482
+ if (nextRaw >= rawRemaining) {
483
+ rawRemaining = nextRaw;
484
+ break;
485
+ }
486
+ rawRemaining = nextRaw;
487
+ }
488
+ return {
489
+ recallText,
490
+ cycle1Text: lines.join('\n'),
491
+ passes: pass,
492
+ rawRemaining,
493
+ };
494
+ }
495
+
242
496
  function preservedFactHints() {
243
497
  return String(process.env.MIXDOG_COMPACT_FACT_HINTS || '')
244
498
  .split(/[\n,;]+/u)
@@ -449,43 +703,113 @@ function userIndexes(messages) {
449
703
  return out;
450
704
  }
451
705
 
452
- function selectCompactionWindow(messages, budget, opts = {}) {
706
+ function indexLiveTurns(live) {
707
+ const turns = splitTailIntoTurns(live);
708
+ const indexed = [];
709
+ let scan = 0;
710
+ for (const messages of turns) {
711
+ while (scan < live.length && live[scan] !== messages[0]) scan += 1;
712
+ const start = scan;
713
+ const end = start + messages.length;
714
+ indexed.push({ start, end, messages });
715
+ scan = end;
716
+ }
717
+ return indexed;
718
+ }
719
+
720
+ function splitTurnStartIndexForBudget(turn, budget) {
721
+ const { start, end, messages } = turn;
722
+ for (let i = 0; i < messages.length; i += 1) {
723
+ const suffixStart = start + i;
724
+ const suffix = messages.slice(i);
725
+ if (suffix.length > 0 && estimateMessagesTokens(suffix) <= budget) {
726
+ return suffixStart;
727
+ }
728
+ }
729
+ return end;
730
+ }
731
+
732
+ function splitLiveCompactionContext(messages) {
453
733
  const sanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
454
- const system = sanitized.filter(m => m?.role === 'system');
455
- const nonSystem = sanitized.filter(m => m?.role !== 'system');
456
- const users = userIndexes(nonSystem);
457
- if (!users.length) throw new Error('semanticCompactMessages: no user turn to preserve');
734
+ const { protectedPrefix, conversation: nonSystem } = splitProtectedContext(sanitized);
735
+ let previousSummary = null;
736
+ for (let i = nonSystem.length - 1; i >= 0; i -= 1) {
737
+ if (isSummaryMessage(nonSystem[i])) {
738
+ previousSummary = nonSystem[i].content;
739
+ break;
740
+ }
741
+ }
742
+ const live = nonSystem.filter((m) => !isSummaryMessage(m));
743
+ return { system: protectedPrefix, live, previousSummary, sanitized };
744
+ }
745
+
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');
458
749
 
459
750
  const tailTurns = Math.max(1, Number(opts.tailTurns) || DEFAULT_TAIL_TURNS);
460
751
  const recentBudget = preserveRecentBudget(budget, opts);
461
- let tailStart = users[users.length - 1];
462
- for (let u = users.length - 2, kept = 1; u >= 0 && kept < tailTurns; u -= 1) {
463
- const candidateStart = users[u];
464
- const candidateTail = nonSystem.slice(candidateStart);
465
- if (estimateMessagesTokens(candidateTail) > recentBudget) break;
466
- tailStart = candidateStart;
467
- kept += 1;
752
+ const indexedTurns = indexLiveTurns(live);
753
+ if (indexedTurns.length === 0) throw new Error('semanticCompactMessages: no user turn to preserve');
754
+
755
+ let tailStartIdx = live.length;
756
+ let keptTurns = 0;
757
+
758
+ for (let t = indexedTurns.length - 1; t >= 0; t -= 1) {
759
+ if (keptTurns >= tailTurns) break;
760
+ const turn = indexedTurns[t];
761
+ const tailFromTurn = live.slice(turn.start);
762
+ if (keptTurns === 0) {
763
+ if (estimateMessagesTokens(tailFromTurn) <= recentBudget) {
764
+ tailStartIdx = turn.start;
765
+ keptTurns += 1;
766
+ continue;
767
+ }
768
+ const splitIdx = splitTurnStartIndexForBudget(turn, recentBudget);
769
+ if (splitIdx < turn.end) {
770
+ tailStartIdx = splitIdx;
771
+ keptTurns += 1;
772
+ break;
773
+ }
774
+ // Newest turn has no fitting suffix: keep entire live transcript in head for summarization.
775
+ tailStartIdx = live.length;
776
+ keptTurns = 0;
777
+ break;
778
+ }
779
+ const candidateStart = turn.start;
780
+ const candidateTail = live.slice(candidateStart);
781
+ if (estimateMessagesTokens(candidateTail) <= recentBudget) {
782
+ tailStartIdx = candidateStart;
783
+ keptTurns += 1;
784
+ continue;
785
+ }
786
+ break;
468
787
  }
469
788
 
470
- const head = nonSystem.slice(0, tailStart);
471
- const tail = nonSystem.slice(tailStart);
472
- let previousSummary = null;
473
- let headStart = 0;
474
- for (let i = head.length - 1; i >= 0; i -= 1) {
475
- const m = head[i];
476
- if (m?.role === 'user' && typeof m.content === 'string' && m.content.startsWith(SUMMARY_PREFIX)) {
477
- previousSummary = m.content;
478
- headStart = i + 1;
479
- break;
789
+ const head = live.slice(0, tailStartIdx);
790
+ let tail = live.slice(tailStartIdx);
791
+ tail = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(tail)));
792
+
793
+ 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');
480
802
  }
481
803
  }
482
- const preservedFacts = extractPreservedFacts(head.slice(headStart));
804
+
805
+ const preservedFacts = extractPreservedFacts(head);
806
+ const originalHead = head;
483
807
  return {
484
808
  system,
485
- head: head.slice(headStart),
809
+ head,
486
810
  tail,
487
811
  previousSummary,
488
- originalHead: head,
812
+ originalHead,
489
813
  preservedFacts,
490
814
  };
491
815
  }
@@ -523,29 +847,114 @@ function buildCompactionPrompt({ head, previousSummary, preservedFacts }, perMes
523
847
  return lines.join('\n');
524
848
  }
525
849
 
850
+ function estimateCompactionPromptTokens(input, perMessageChars) {
851
+ const prompt = buildCompactionPrompt(input, perMessageChars);
852
+ return estimateMessagesTokens([
853
+ { role: 'system', content: COMPACTION_SYSTEM_PROMPT },
854
+ { role: 'user', content: prompt },
855
+ ]);
856
+ }
857
+
858
+ function previousSummaryBodyForCompactionPrompt(previousSummary) {
859
+ const text = String(previousSummary || '').trim();
860
+ if (!text) return '';
861
+ return stripNestedSummaryHeaderLines(text);
862
+ }
863
+
864
+ function priorSummaryNeedsNormalization(text) {
865
+ const body = String(text || '').trim();
866
+ if (!body) return false;
867
+ if (!/^##\s+/m.test(body)) return true;
868
+ if (!summaryIsSchemaValid(body)) return true;
869
+ return summaryHasUnrecognizedHeadings(body);
870
+ }
871
+
872
+ function normalizePriorSummaryForCompactionPrompt(fullBody) {
873
+ const text = String(fullBody || '').trim();
874
+ if (!text) return '';
875
+ if (!priorSummaryNeedsNormalization(text)) return text;
876
+ return repairSemanticSummary(text, { head: [], tail: [] });
877
+ }
878
+
879
+ // Shrink or drop a prior anchored summary so the compaction provider prompt fits
880
+ // the call budget. Unstructured/legacy priors are repaired first; section
881
+ // anchors are preserved via truncateSummaryBySections;
882
+ // the last resort is omitting <previous-summary> entirely.
883
+ function fitPreviousSummaryForCompactionPrompt(input, perMessageChars, targetTokens) {
884
+ if (!input?.previousSummary) return input;
885
+ const fullBody = normalizePriorSummaryForCompactionPrompt(
886
+ previousSummaryBodyForCompactionPrompt(input.previousSummary),
887
+ );
888
+ const withSummary = (summaryText) => {
889
+ const trimmed = String(summaryText || '').trim();
890
+ if (!trimmed) return { ...input, previousSummary: null };
891
+ return { ...input, previousSummary: trimmed };
892
+ };
893
+
894
+ if (estimateCompactionPromptTokens(withSummary(fullBody), perMessageChars) <= targetTokens) {
895
+ return withSummary(fullBody);
896
+ }
897
+
898
+ if (fullBody) {
899
+ let lo = 0;
900
+ let hi = fullBody.length;
901
+ let bestChars = -1;
902
+ while (lo <= hi) {
903
+ const mid = Math.floor((lo + hi) / 2);
904
+ const truncated = truncateSummaryBySections(fullBody, mid);
905
+ const candidate = withSummary(truncated);
906
+ if (estimateCompactionPromptTokens(candidate, perMessageChars) <= targetTokens) {
907
+ bestChars = mid;
908
+ lo = mid + 1;
909
+ } else {
910
+ hi = mid - 1;
911
+ }
912
+ }
913
+ if (bestChars >= 0) {
914
+ return withSummary(truncateSummaryBySections(fullBody, bestChars));
915
+ }
916
+ }
917
+
918
+ const minimalPrior = minimalSchemaSummary();
919
+ if (estimateCompactionPromptTokens(withSummary(minimalPrior), perMessageChars) <= targetTokens) {
920
+ return withSummary(minimalPrior);
921
+ }
922
+
923
+ const withoutPrior = withSummary(null);
924
+ if (estimateCompactionPromptTokens(withoutPrior, perMessageChars) <= targetTokens) {
925
+ return withoutPrior;
926
+ }
927
+
928
+ return null;
929
+ }
930
+
526
931
  function fitCompactionPrompt(input, targetTokens) {
527
932
  const tryFit = (withFacts) => {
528
- const inp = withFacts ? input : { ...input, preservedFacts: null };
529
- const minimal = buildCompactionPrompt(inp, 0);
530
- const baseMessages = [
531
- { role: 'system', content: COMPACTION_SYSTEM_PROMPT },
532
- { role: 'user', content: minimal },
533
- ];
534
- if (estimateMessagesTokens(baseMessages) > targetTokens) return null;
933
+ const baseInp = withFacts ? input : { ...input, preservedFacts: null };
934
+
935
+ const fitAt = (perMessageChars) => {
936
+ let inp = baseInp;
937
+ if (estimateCompactionPromptTokens(inp, perMessageChars) > targetTokens) {
938
+ const fitted = fitPreviousSummaryForCompactionPrompt(inp, perMessageChars, targetTokens);
939
+ if (!fitted) return null;
940
+ inp = fitted;
941
+ if (estimateCompactionPromptTokens(inp, perMessageChars) > targetTokens) return null;
942
+ }
943
+ return buildCompactionPrompt(inp, perMessageChars);
944
+ };
945
+
946
+ const minimalPrompt = fitAt(0);
947
+ if (!minimalPrompt) return null;
535
948
 
536
949
  let maxText = 0;
537
- for (const m of input.head) maxText = Math.max(maxText, extractText(m).length);
950
+ for (const m of baseInp.head) maxText = Math.max(maxText, extractText(m).length);
538
951
  let lo = 0;
539
952
  let hi = Math.min(COMPACTION_INPUT_MAX_CHARS, Math.max(maxText, 0));
540
- let best = minimal;
953
+ let best = minimalPrompt;
541
954
  while (lo <= hi) {
542
955
  const mid = Math.floor((lo + hi) / 2);
543
- const candidate = buildCompactionPrompt(inp, mid);
544
- const candidateMessages = [
545
- { role: 'system', content: COMPACTION_SYSTEM_PROMPT },
546
- { role: 'user', content: candidate },
547
- ];
548
- if (estimateMessagesTokens(candidateMessages) <= targetTokens) {
956
+ const candidate = fitAt(mid);
957
+ if (candidate) {
549
958
  best = candidate;
550
959
  lo = mid + 1;
551
960
  } else {
@@ -582,8 +991,258 @@ function extractResponseText(response) {
582
991
  return '';
583
992
  }
584
993
 
994
+ // Canonical section anchors the semantic summary template (SUMMARY_TEMPLATE)
995
+ // must contain. Used for lightweight schema validation of provider output.
996
+ const REQUIRED_SUMMARY_SECTIONS = Object.freeze([
997
+ '## Goal',
998
+ '## Constraints',
999
+ '## Progress',
1000
+ '## Key Decisions',
1001
+ '## Next Steps',
1002
+ '## Critical Context',
1003
+ '## Relevant Files',
1004
+ ]);
1005
+
1006
+ // Collect actual top-level (`## `, not `### `) heading lines from a summary.
1007
+ // Validation is heading-anchor based (not substring includes) so prose or code
1008
+ // that merely mentions "## Relevant Files" inside a bullet body cannot satisfy
1009
+ // a section anchor.
1010
+ function summaryHeadingLines(summary) {
1011
+ const out = [];
1012
+ for (const rawLine of String(summary || '').split('\n')) {
1013
+ const line = rawLine.trim();
1014
+ if (/^##\s+\S/.test(line) && !/^###\s+/.test(line)) out.push(line);
1015
+ }
1016
+ return out;
1017
+ }
1018
+
1019
+ // An anchor matches a heading when the heading title equals the anchor title or
1020
+ // extends it at a word/punctuation boundary. This lets `## Constraints &
1021
+ // Preferences` satisfy the `## Constraints` anchor while NOT letting an
1022
+ // unrelated `## Goalkeeper` heading satisfy `## Goal`. Requires a real `## `
1023
+ // heading line (not a substring buried in prose).
1024
+ function headingMatchesAnchor(heading, anchor) {
1025
+ const anchorTitle = anchor.replace(/^##\s+/, '').trim().toLowerCase();
1026
+ const headingTitle = heading.replace(/^##\s+/, '').trim().toLowerCase();
1027
+ if (headingTitle === anchorTitle) return true;
1028
+ if (!headingTitle.startsWith(anchorTitle)) return false;
1029
+ // Next char after the anchor title must be a boundary (space or &/punct),
1030
+ // not a continuation letter/digit.
1031
+ const nextChar = headingTitle.charAt(anchorTitle.length);
1032
+ return /[\s&:(-]/.test(nextChar);
1033
+ }
1034
+
1035
+ function summarySchemaScore(summary) {
1036
+ const headings = summaryHeadingLines(summary);
1037
+ let hits = 0;
1038
+ for (const anchor of REQUIRED_SUMMARY_SECTIONS) {
1039
+ if (headings.some((h) => headingMatchesAnchor(h, anchor))) hits += 1;
1040
+ }
1041
+ return hits;
1042
+ }
1043
+
1044
+ // A summary is schema-valid only when EVERY required section anchor is present
1045
+ // as a real heading. A partial summary (e.g. missing Critical Context /
1046
+ // Relevant Files) must be repaired rather than injected unchanged.
1047
+ function summaryIsSchemaValid(summary) {
1048
+ if (summarySchemaScore(summary) !== REQUIRED_SUMMARY_SECTIONS.length) return false;
1049
+ return !summaryHasUnrecognizedHeadings(summary);
1050
+ }
1051
+
1052
+ function deriveRelevantFilesBullets(head) {
1053
+ const seen = new Set();
1054
+ const out = [];
1055
+ const fileRe = /(?:[A-Za-z]:[\\/]|\.{1,2}[\\/]|[\w$.-]+[\\/])?[\w$.-]+\.(?:mjs|cjs|js|jsx|ts|tsx|json|md|rs|go|py|java|kt|cs|cpp|c|h|hpp|css|html|yml|yaml|toml|lock|sh|ps1)\b/gi;
1056
+ for (const m of Array.isArray(head) ? head : []) {
1057
+ const text = extractText(m);
1058
+ if (!text) continue;
1059
+ let match;
1060
+ while ((match = fileRe.exec(text)) && out.length < 8) {
1061
+ const file = match[0];
1062
+ const key = file.toLowerCase();
1063
+ if (seen.has(key)) continue;
1064
+ seen.add(key);
1065
+ out.push(`- ${file}`);
1066
+ }
1067
+ if (out.length >= 8) break;
1068
+ }
1069
+ return out;
1070
+ }
1071
+
1072
+ function deriveCurrentRequest(messages) {
1073
+ for (let i = (Array.isArray(messages) ? messages.length : 0) - 1; i >= 0; i -= 1) {
1074
+ const m = messages[i];
1075
+ if (m?.role === 'user' && !isProtectedContextUserMessage(m)) {
1076
+ const text = truncateMiddle(extractText(m).trim(), 400);
1077
+ if (text) return text;
1078
+ }
1079
+ }
1080
+ return '';
1081
+ }
1082
+
1083
+ // Canonical ordered section headings the structured summary scaffold emits.
1084
+ // Each `## ` heading maps to one REQUIRED_SUMMARY_SECTIONS anchor; Progress
1085
+ // additionally carries its three `### ` sub-headings.
1086
+ const SUMMARY_SECTION_LAYOUT = Object.freeze([
1087
+ { heading: '## Goal', anchor: '## Goal' },
1088
+ { heading: '## Constraints & Preferences', anchor: '## Constraints' },
1089
+ { heading: '## Progress', anchor: '## Progress', sub: ['### Done', '### In Progress', '### Blocked'] },
1090
+ { heading: '## Key Decisions', anchor: '## Key Decisions' },
1091
+ { heading: '## Next Steps', anchor: '## Next Steps' },
1092
+ { heading: '## Critical Context', anchor: '## Critical Context' },
1093
+ { heading: '## Relevant Files', anchor: '## Relevant Files' },
1094
+ ]);
1095
+
1096
+ // Split a markdown summary into a map of top-level `## ` heading -> body lines.
1097
+ function parseSummarySections(text) {
1098
+ const map = new Map();
1099
+ let current = null;
1100
+ for (const rawLine of String(text || '').split('\n')) {
1101
+ const trimmed = rawLine.trim();
1102
+ const line = rawLine.replace(/\s+$/, '');
1103
+ if (/^##\s+/.test(trimmed) && !/^###\s+/.test(trimmed)) {
1104
+ current = trimmed;
1105
+ if (!map.has(current)) map.set(current, []);
1106
+ continue;
1107
+ }
1108
+ if (current) map.get(current).push(line);
1109
+ }
1110
+ return map;
1111
+ }
1112
+
1113
+ function summarySectionIsRecognized(heading) {
1114
+ for (const section of SUMMARY_SECTION_LAYOUT) {
1115
+ if (headingMatchesAnchor(heading, section.anchor)) return true;
1116
+ }
1117
+ return false;
1118
+ }
1119
+
1120
+ function summaryHasUnrecognizedHeadings(summary) {
1121
+ for (const heading of parseSummarySections(summary).keys()) {
1122
+ if (!summarySectionIsRecognized(heading)) return true;
1123
+ }
1124
+ return false;
1125
+ }
1126
+
1127
+ function summaryLinesToBullets(text) {
1128
+ return String(text || '')
1129
+ .split('\n')
1130
+ .map((l) => l.trim())
1131
+ .filter(Boolean)
1132
+ .map((l) => (l.startsWith('-') ? l : `- ${l}`));
1133
+ }
1134
+
1135
+ function unrecognizedSummarySectionText(present) {
1136
+ const chunks = [];
1137
+ for (const [heading, body] of present) {
1138
+ if (summarySectionIsRecognized(heading)) continue;
1139
+ const lines = [heading];
1140
+ for (const line of body || []) {
1141
+ const trimmed = String(line).trim();
1142
+ if (trimmed) lines.push(line);
1143
+ }
1144
+ chunks.push(lines.join('\n'));
1145
+ }
1146
+ return chunks.join('\n\n').trim();
1147
+ }
1148
+
1149
+ // Deterministic schema repair for a non-empty but malformed/partial semantic
1150
+ // summary. Preserve every section the provider DID supply (matched by anchor),
1151
+ // and scaffold the missing required sections so downstream consumers always
1152
+ // receive the full structured anchored shape. Content that lives outside any
1153
+ // recognized section is routed into Critical Context so nothing is dropped.
1154
+ // Lightly backfill Goal / Relevant Files from the transcript when empty.
1155
+ function repairSemanticSummary(summary, { head = [], tail = [] } = {}) {
1156
+ const raw = String(summary || '').trim();
1157
+ const present = parseSummarySections(raw);
1158
+ // Capture any leading content before the first recognized `## ` heading so
1159
+ // an entirely unstructured blob is preserved rather than silently dropped.
1160
+ let preamble = '';
1161
+ if (raw) {
1162
+ const firstHeading = raw.search(/(^|\n)\s*##\s+/);
1163
+ preamble = firstHeading === -1 ? raw : raw.slice(0, firstHeading);
1164
+ preamble = preamble.trim();
1165
+ }
1166
+ const orphanText = unrecognizedSummarySectionText(present);
1167
+ const extraContextParts = [];
1168
+ if (preamble) extraContextParts.push(preamble);
1169
+ if (orphanText) extraContextParts.push(orphanText);
1170
+ const extraContext = extraContextParts.join('\n\n').trim();
1171
+ const bulletize = (lines) => {
1172
+ const cleaned = (Array.isArray(lines) ? lines : [])
1173
+ .map((l) => String(l).trim())
1174
+ .filter(Boolean);
1175
+ return cleaned.length ? cleaned : null;
1176
+ };
1177
+ const findPresent = (anchor) => {
1178
+ for (const [heading, body] of present) {
1179
+ if (headingMatchesAnchor(heading, anchor)) return body;
1180
+ }
1181
+ return null;
1182
+ };
1183
+ const goal = deriveCurrentRequest(tail) || deriveCurrentRequest(head);
1184
+ const files = deriveRelevantFilesBullets(head);
1185
+ const out = [];
1186
+ for (const section of SUMMARY_SECTION_LAYOUT) {
1187
+ if (out.length) out.push('');
1188
+ out.push(section.heading);
1189
+ const body = bulletize(findPresent(section.anchor));
1190
+ if (section.sub) {
1191
+ // Progress: keep provider sub-bodies when present, else scaffold.
1192
+ if (body) {
1193
+ out.push(...body);
1194
+ } else {
1195
+ for (const sub of section.sub) {
1196
+ out.push(sub, '- (none)');
1197
+ }
1198
+ }
1199
+ continue;
1200
+ }
1201
+ if (section.anchor === '## Critical Context') {
1202
+ const ccLines = [];
1203
+ if (body) ccLines.push(...body);
1204
+ for (const line of summaryLinesToBullets(extraContext)) {
1205
+ if (!ccLines.some((existing) => existing.trim() === line.trim())) ccLines.push(line);
1206
+ }
1207
+ if (ccLines.some((line) => line.trim() !== '- (none)')) {
1208
+ const withoutPlaceholder = ccLines.filter((line) => line.trim() !== '- (none)');
1209
+ out.push(...(withoutPlaceholder.length ? withoutPlaceholder : ccLines));
1210
+ } else {
1211
+ out.push(...(ccLines.length ? ccLines : ['- (none)']));
1212
+ }
1213
+ continue;
1214
+ }
1215
+ if (body) {
1216
+ out.push(...body);
1217
+ continue;
1218
+ }
1219
+ if (section.anchor === '## Goal') {
1220
+ out.push(goal ? `- ${goal}` : '- (none)');
1221
+ } else if (section.anchor === '## Relevant Files') {
1222
+ out.push(...(files.length ? files : ['- (none)']));
1223
+ } else {
1224
+ out.push('- (none)');
1225
+ }
1226
+ }
1227
+ return out.join('\n');
1228
+ }
1229
+
1230
+ // Validate the provider summary against the required template sections; when it
1231
+ // is missing ANY required section anchor (fully or partially malformed) repair
1232
+ // it deterministically so a non-empty-but-broken response is never injected as
1233
+ // the sole summary. Returns { summary, repaired }.
1234
+ function enforceSemanticSummarySchema(summary, ctx = {}) {
1235
+ const text = String(summary || '').trim();
1236
+ if (!text) return { summary: text, repaired: false };
1237
+ if (summaryIsSchemaValid(text)) {
1238
+ return { summary: text, repaired: false };
1239
+ }
1240
+ return { summary: repairSemanticSummary(text, ctx), repaired: true };
1241
+ }
1242
+
585
1243
  function makeSemanticSummaryMessage(oldHistory, summary, semanticMeta = {}, preservedFacts = '') {
586
1244
  const header = compactHeader(oldHistory);
1245
+ header.push(`compact_type=${COMPACT_TYPE_SEMANTIC}`);
587
1246
  header.push(`semantic=true provider=${semanticMeta.provider || 'unknown'} model=${semanticMeta.model || 'unknown'}`);
588
1247
  const facts = String(preservedFacts || '').trim();
589
1248
  const body = String(summary || '').trim();
@@ -593,19 +1252,103 @@ function makeSemanticSummaryMessage(oldHistory, summary, semanticMeta = {}, pres
593
1252
  return makeSummaryMessage(parts.join('\n\n'));
594
1253
  }
595
1254
 
1255
+ export function buildRecallFastTrackQuery(messages, opts = {}) {
1256
+ const maxChars = Math.max(200, Number(opts.maxChars) || 2_000);
1257
+ const hints = String(opts.hints || 'current task decisions constraints file paths changed files verification failures next steps').trim();
1258
+ let latestUser = '';
1259
+ const recent = [];
1260
+ const input = Array.isArray(messages) ? messages : [];
1261
+ for (let i = input.length - 1; i >= 0; i -= 1) {
1262
+ const m = input[i];
1263
+ const text = extractText(m).trim();
1264
+ if (!text) continue;
1265
+ if (recent.length < 6) recent.unshift(text);
1266
+ if (!latestUser && m?.role === 'user' && !isProtectedContextUserMessage(m)) {
1267
+ latestUser = text;
1268
+ }
1269
+ if (latestUser && recent.length >= 6) break;
1270
+ }
1271
+ const parts = [latestUser, hints, recent.join('\n')]
1272
+ .map((s) => String(s || '').trim())
1273
+ .filter(Boolean);
1274
+ return truncateMiddle([...new Set(parts)].join('\n'), maxChars);
1275
+ }
1276
+
1277
+ // A headings-only structured summary: every required `## ` (and Progress `### `)
1278
+ // anchor present with `- (none)` bodies. This is the minimal schema-valid shape
1279
+ // the fitter can fall back to when token pressure cannot hold real section
1280
+ // bodies — it still passes summaryIsSchemaValid so the injected message is
1281
+ // never partial.
1282
+ function minimalSchemaSummary() {
1283
+ const out = [];
1284
+ for (const section of SUMMARY_SECTION_LAYOUT) {
1285
+ if (out.length) out.push('');
1286
+ out.push(section.heading);
1287
+ if (section.sub) {
1288
+ for (const sub of section.sub) out.push(sub, '- (none)');
1289
+ } else {
1290
+ out.push('- (none)');
1291
+ }
1292
+ }
1293
+ return out.join('\n');
1294
+ }
1295
+
1296
+ // Section-aware truncation: keep EVERY `## ` heading and Progress `### `
1297
+ // sub-heading intact, trimming only section bodies to `perSectionChars`. Unlike
1298
+ // a raw text.slice(0, n) this never drops a trailing required section, so the
1299
+ // result stays schema-valid (all anchors present) at any budget.
1300
+ function truncateSummaryBySections(summary, perSectionChars) {
1301
+ const sections = parseSummarySections(summary);
1302
+ const out = [];
1303
+ for (const section of SUMMARY_SECTION_LAYOUT) {
1304
+ if (out.length) out.push('');
1305
+ out.push(section.heading);
1306
+ let body = null;
1307
+ for (const [heading, lines] of sections) {
1308
+ if (headingMatchesAnchor(heading, section.anchor)) { body = lines; break; }
1309
+ }
1310
+ const bodyText = (Array.isArray(body) ? body : [])
1311
+ .map((l) => String(l).trim())
1312
+ .filter(Boolean)
1313
+ .join('\n');
1314
+ if (!bodyText) {
1315
+ if (section.sub) for (const sub of section.sub) out.push(sub, '- (none)');
1316
+ else out.push('- (none)');
1317
+ continue;
1318
+ }
1319
+ const trimmed = perSectionChars > 0 ? truncateMiddle(bodyText, perSectionChars) : '';
1320
+ if (trimmed) out.push(trimmed);
1321
+ else if (section.sub) for (const sub of section.sub) out.push(sub, '- (none)');
1322
+ else out.push('- (none)');
1323
+ }
1324
+ return out.join('\n');
1325
+ }
1326
+
1327
+ // Fit the structured semantic summary into the remaining token budget WITHOUT
1328
+ // dropping any required section. The incoming `summary` is already schema-valid
1329
+ // (enforceSemanticSummarySchema ran upstream); here we shrink section bodies via
1330
+ // section-aware truncation, fall back to a headings-only schema-valid summary,
1331
+ // and finally revalidate so the injected SUMMARY_PREFIX message always carries
1332
+ // every required anchor. Returns null only when even the minimal schema-valid
1333
+ // summary cannot fit (caller throws).
596
1334
  function fitSemanticSummaryMessage(oldHistory, summary, remainingTokens, semanticMeta, preservedFacts = '') {
597
1335
  const tryFit = (factsText) => {
598
- const minimal = makeSemanticSummaryMessage(oldHistory, '', semanticMeta, factsText);
599
- if (estimateMessagesTokens([minimal]) > remainingTokens) return null;
600
1336
  const text = String(summary || '').trim();
1337
+ // Minimal schema-valid body (headings + "(none)"). If even this does
1338
+ // not fit, this facts variant cannot produce a valid message.
1339
+ const minimalBody = text ? minimalSchemaSummary() : '';
1340
+ const minimal = makeSemanticSummaryMessage(oldHistory, minimalBody, semanticMeta, factsText);
1341
+ if (estimateMessagesTokens([minimal]) > remainingTokens) return null;
601
1342
  if (!text) return minimal;
1343
+ // Binary search the per-section body budget; keep all anchors intact.
602
1344
  let lo = 0;
603
1345
  let hi = text.length;
604
1346
  let best = minimal;
605
1347
  while (lo <= hi) {
606
1348
  const mid = Math.floor((lo + hi) / 2);
607
- const candidate = makeSemanticSummaryMessage(oldHistory, text.slice(0, mid), semanticMeta, factsText);
608
- if (estimateMessagesTokens([candidate]) <= remainingTokens) {
1349
+ const body = truncateSummaryBySections(text, mid);
1350
+ const candidate = makeSemanticSummaryMessage(oldHistory, body, semanticMeta, factsText);
1351
+ if (estimateMessagesTokens([candidate]) <= remainingTokens && summaryIsSchemaValid(body)) {
609
1352
  best = candidate;
610
1353
  lo = mid + 1;
611
1354
  } else {
@@ -614,11 +1357,90 @@ function fitSemanticSummaryMessage(oldHistory, summary, remainingTokens, semanti
614
1357
  }
615
1358
  return best;
616
1359
  };
617
- if (preservedFacts) {
618
- const withFacts = tryFit(preservedFacts);
619
- if (withFacts) return withFacts;
1360
+ let result = null;
1361
+ if (preservedFacts) result = tryFit(preservedFacts);
1362
+ if (!result) result = tryFit('');
1363
+ return result;
1364
+ }
1365
+
1366
+ // Recall fast-track (compact type 2) does no LLM summarization — it just emits
1367
+ // the chunked history in order. Keep the message clean: the mandatory anchor
1368
+ // header (so selectCompactionWindow / clear-preserve / TUI can recognize the
1369
+ // compact message) plus the chunk text itself. No "Preserved Facts" extraction,
1370
+ // no "Recall Fast-Track Context" heading, no "(no recall hits)" filler.
1371
+ function makeRecallFastTrackSummaryMessage(oldHistory, recallText, recallMeta = {}) {
1372
+ return makeRecallFastTrackSummaryMessageParts(oldHistory, recallText, '', recallMeta);
1373
+ }
1374
+
1375
+ const RECALL_TAIL_TRUNCATION_MARKER = '[... truncated during recall tail preservation ...]';
1376
+ const RECALL_TAIL_SHORT_TRUNCATION_MARKER = '[truncated]';
1377
+
1378
+ const PRIOR_COMPACTED_CONTEXT_OPEN = '<prior-compacted-context>';
1379
+ const PRIOR_COMPACTED_CONTEXT_CLOSE = '</prior-compacted-context>';
1380
+
1381
+ function formatPriorCompactedContextBlock(priorText) {
1382
+ const prior = String(priorText || '').trim();
1383
+ if (!prior) return '';
1384
+ return `${PRIOR_COMPACTED_CONTEXT_OPEN}\n${prior}\n${PRIOR_COMPACTED_CONTEXT_CLOSE}`;
1385
+ }
1386
+
1387
+ function makeRecallFastTrackSummaryMessageParts(oldHistory, recallPart, priorPart, recallMeta = {}) {
1388
+ const header = compactHeader(oldHistory);
1389
+ header.push(`compact_type=${COMPACT_TYPE_RECALL_FASTTRACK} source=recall-fasttrack query_sha=${recallMeta.querySha || 'none'}`);
1390
+ const parts = [header.join('\n')];
1391
+ const priorBlock = formatPriorCompactedContextBlock(priorPart);
1392
+ if (priorBlock) parts.push(priorBlock);
1393
+ const recall = String(recallPart || '').trim();
1394
+ if (recall) parts.push(recall);
1395
+ return makeSummaryMessage(parts.join('\n\n'));
1396
+ }
1397
+
1398
+ function fitRecallFastTrackSummaryMessage(oldHistory, recallText, remainingTokens, recallMeta = {}, priorPart = '') {
1399
+ const recall = String(recallText || '').trim();
1400
+ const prior = String(priorPart || '').trim();
1401
+
1402
+ let fittedPrior = prior;
1403
+ if (prior) {
1404
+ let lo = 0;
1405
+ let hi = prior.length;
1406
+ let bestPriorLen = 0;
1407
+ while (lo <= hi) {
1408
+ const mid = Math.floor((lo + hi) / 2);
1409
+ const candidate = makeRecallFastTrackSummaryMessageParts(oldHistory, '', prior.slice(0, mid), recallMeta);
1410
+ if (estimateMessagesTokens([candidate]) <= remainingTokens) {
1411
+ bestPriorLen = mid;
1412
+ lo = mid + 1;
1413
+ } else {
1414
+ hi = mid - 1;
1415
+ }
1416
+ }
1417
+ fittedPrior = prior.slice(0, bestPriorLen);
1418
+ if (!fittedPrior && prior) {
1419
+ const markerOnly = makeRecallFastTrackSummaryMessageParts(oldHistory, '', RECALL_TAIL_TRUNCATION_MARKER, recallMeta);
1420
+ if (estimateMessagesTokens([markerOnly]) <= remainingTokens) {
1421
+ fittedPrior = RECALL_TAIL_TRUNCATION_MARKER;
1422
+ }
1423
+ }
620
1424
  }
621
- return tryFit('');
1425
+
1426
+ const minimal = makeRecallFastTrackSummaryMessageParts(oldHistory, '', fittedPrior, recallMeta);
1427
+ if (estimateMessagesTokens([minimal]) > remainingTokens) return null;
1428
+ if (!recall) return minimal;
1429
+
1430
+ let lo = 0;
1431
+ let hi = recall.length;
1432
+ let best = minimal;
1433
+ while (lo <= hi) {
1434
+ const mid = Math.floor((lo + hi) / 2);
1435
+ const candidate = makeRecallFastTrackSummaryMessageParts(oldHistory, recall.slice(0, mid), fittedPrior, recallMeta);
1436
+ if (estimateMessagesTokens([candidate]) <= remainingTokens) {
1437
+ best = candidate;
1438
+ lo = mid + 1;
1439
+ } else {
1440
+ hi = mid - 1;
1441
+ }
1442
+ }
1443
+ return best;
622
1444
  }
623
1445
 
624
1446
  function combinedSignal(parent, timeoutMs) {
@@ -633,11 +1455,21 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
633
1455
  if (!provider || typeof provider.send !== 'function') {
634
1456
  throw new Error('semanticCompactMessages: provider.send is required');
635
1457
  }
636
- const budget = effectiveBudget(budgetTokens, opts);
637
- const sanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
638
- if (estimateMessagesTokens(sanitized) <= budget && opts.force !== true) {
639
- return { messages: sanitized, usage: null, semantic: false };
1458
+ let budget = effectiveBudget(budgetTokens, opts);
1459
+ const baseSanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
1460
+ // No-op fast path: if the original sanitized transcript already fits and we
1461
+ // are not forced, return it UNCHANGED (no preserved-tail redaction applied)
1462
+ // to keep prior no-compaction semantics.
1463
+ if (estimateMessagesTokens(baseSanitized) <= budget && opts.force !== true) {
1464
+ return { messages: baseSanitized, usage: null, semantic: false };
640
1465
  }
1466
+ // Compaction will proceed: redact sensitive tool-call argument VALUES before
1467
+ // window selection so the preserved tail/system that survive verbatim are
1468
+ // measured AND emitted in their redacted form. Head prompt normalizers
1469
+ // (toolCallSummary/normalizeToolArgValue) still apply on top for the
1470
+ // summarized head. Redaction is shape-preserving, so tool-pair structure
1471
+ // stays provider-valid.
1472
+ const sanitized = redactToolCallSecretsInMessages(baseSanitized);
641
1473
 
642
1474
  const selected = selectCompactionWindow(sanitized, budget, opts);
643
1475
  if (selected.head.length === 0 && !selected.previousSummary) {
@@ -646,8 +1478,14 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
646
1478
 
647
1479
  const mandatory = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs([...selected.system, ...selected.tail])));
648
1480
  const mandatoryCost = estimateMessagesTokens(mandatory);
649
- if (mandatoryCost > budget) {
650
- throw new Error(`semanticCompactMessages: system+preserved tail exceeds budget=${budget} (base=${mandatoryCost})`);
1481
+ // The preserved tail is kept verbatim and the head is replaced by a much
1482
+ // smaller summary, so the compacted result is always smaller than the
1483
+ // input regardless of how the configured target budget compares to the
1484
+ // mandatory cost. When the budget cannot even hold what we must keep, raise
1485
+ // it to fit (mandatory + summary room) rather than refusing — a refusal
1486
+ // here was the source of auto-clear / overflow compact failures.
1487
+ if (mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS > budget) {
1488
+ budget = mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS;
651
1489
  }
652
1490
 
653
1491
  const callBudget = Math.max(1, Math.floor((opts.compactionInputBudgetTokens || budget) * COMPACTION_PROMPT_HEADROOM));
@@ -666,9 +1504,14 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
666
1504
  maxOutputTokens: opts.maxOutputTokens || SUMMARY_OUTPUT_TOKENS,
667
1505
  providerState: undefined,
668
1506
  onToolCall: undefined,
1507
+ onToolResult: undefined,
1508
+ onTextDelta: undefined,
1509
+ onReasoningDelta: undefined,
1510
+ onUsageDelta: undefined,
669
1511
  onStreamDelta: undefined,
670
1512
  onStageChange: undefined,
671
- remoteCompact: false,
1513
+ drainSteering: undefined,
1514
+ onSteerMessage: undefined,
672
1515
  signal: combinedSignal(opts.signal || opts.sendOpts?.signal || null, opts.timeoutMs || 30_000),
673
1516
  };
674
1517
  if (opts.sessionId) sendOpts.sessionId = `${opts.sessionId}:compact`;
@@ -683,8 +1526,13 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
683
1526
  { role: 'system', content: COMPACTION_SYSTEM_PROMPT },
684
1527
  { role: 'user', content: prompt },
685
1528
  ], compactModel, undefined, sendOpts);
686
- const summary = extractResponseText(response);
687
- if (!summary) throw new Error('semanticCompactMessages: compaction agent returned empty summary');
1529
+ const rawSummary = extractResponseText(response);
1530
+ if (!rawSummary) throw new Error('semanticCompactMessages: compaction agent returned empty summary');
1531
+ // Lightweight schema enforcement: a non-empty but malformed provider
1532
+ // response (missing the required template sections) is deterministically
1533
+ // repaired into the structured anchored shape rather than injected blindly.
1534
+ const enforced = enforceSemanticSummarySchema(rawSummary, { head: selected.head, tail: selected.tail });
1535
+ const summary = enforced.summary;
688
1536
 
689
1537
  const oldHistory = selected.originalHead;
690
1538
  const semanticMeta = {
@@ -696,6 +1544,9 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
696
1544
  throw new Error(`semanticCompactMessages: summary cannot fit remaining budget=${budget - mandatoryCost}`);
697
1545
  }
698
1546
 
1547
+ // selected.system / selected.tail already carry redacted tool-call args
1548
+ // (sanitized was redacted before window selection), so the preserved tail
1549
+ // is both measured and emitted in redacted form.
699
1550
  let result = sanitizeToolPairs([...selected.system, summaryMessage, ...selected.tail]);
700
1551
  result = reconcileDedupStubs(dedupToolResultBodies(result));
701
1552
  const finalTokens = estimateMessagesTokens(result);
@@ -707,176 +1558,231 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
707
1558
  usage: response?.usage || null,
708
1559
  providerState: response?.providerState,
709
1560
  semantic: true,
1561
+ compactType: COMPACT_TYPE_SEMANTIC,
710
1562
  summary,
1563
+ summaryRepaired: enforced.repaired === true,
711
1564
  };
712
1565
  }
713
1566
 
714
- export function compactMessages(messages, budgetTokens, opts = {}) {
715
- const budget = effectiveBudget(budgetTokens, opts);
716
- const sanitized = sanitizeToolPairs(messages);
717
- if (estimateMessagesTokens(sanitized) <= budget && opts.force !== true) {
718
- return reconcileDedupStubs(sanitized);
719
- }
720
-
721
- const system = sanitized.filter(m => m?.role === 'system');
722
- const nonSystem = sanitized.filter(m => m?.role !== 'system');
723
- const turnStart = currentTurnStart(nonSystem);
724
- if (turnStart <= 0) {
725
- throw new Error(`compactMessages: no compactable prior history before current turn (budget=${budget})`);
726
- }
727
-
728
- const oldHistory = nonSystem.slice(0, turnStart);
729
- const currentTurn = nonSystem.slice(turnStart);
730
- let mandatory = sanitizeToolPairs([...system, ...currentTurn]);
731
- mandatory = reconcileDedupStubs(dedupToolResultBodies(mandatory));
732
- const mandatoryCost = estimateMessagesTokens(mandatory);
733
- if (mandatoryCost > budget) {
734
- throw new Error(`compactMessages: mandatory system+current turn exceeds budget=${budget} (base=${mandatoryCost})`);
735
- }
736
-
737
- const summary = fitSummaryMessage(oldHistory, budget - mandatoryCost);
738
- if (!summary) {
739
- throw new Error(`compactMessages: compact summary cannot fit remaining budget=${budget - mandatoryCost}`);
740
- }
741
-
742
- let result = sanitizeToolPairs([...system, summary, ...currentTurn]);
743
- result = reconcileDedupStubs(dedupToolResultBodies(result));
744
- const finalTokens = estimateMessagesTokens(result);
745
- if (finalTokens > budget) {
746
- throw new Error(`compactMessages: compacted result exceeds budget=${budget} (result=${finalTokens})`);
747
- }
748
- return result;
1567
+ export function recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
1568
+ return _recallFastTrackCompactMessages(messages, budgetTokens, opts);
749
1569
  }
750
1570
 
751
- // Split the assistant/tool portion of a single user turn into ordered
752
- // groups. Each group starts at an assistant message and absorbs the tool
753
- // results that follow it, so a group is the atomic unit we can drop or
754
- // prune without orphaning a tool_use/tool_result pair.
755
- function splitTurnGroups(messages) {
756
- const groups = [];
1571
+ // Recall fast-track (type 2) tail policy: preserve the most recent turns of the
1572
+ // live conversation VERBATIM and STRUCTURED, keeping role semantics for
1573
+ // user / assistant / tool / system / developer instead of collapsing the tail
1574
+ // to user-only. The chunk summary anchors older history; the preserved tail
1575
+ // keeps recent assistant reasoning, tool_calls, and tool_results so fresh
1576
+ // state is not silently dropped.
1577
+ //
1578
+ // Turns are anchored on user-role boundaries: each turn = a user message plus
1579
+ // the assistant/tool/system/developer messages that follow it (a leading run of
1580
+ // non-user messages before the first user boundary is treated as its own
1581
+ // partial turn so nothing is lost). We keep the newest RECALL_TAIL_USER_MAX
1582
+ // turns; if the kept set exceeds RECALL_TAIL_TOKEN_CAP we drop whole oldest
1583
+ // turns first, then middle-truncate the oldest surviving messages' string
1584
+ // content so the set fits while leaving the newest message whole.
1585
+ //
1586
+ // Partial tool_call/tool_result pairs that truncation might leave behind are
1587
+ // repaired by sanitizeToolPairs/reconcileDedupStubs in the caller, so pairing
1588
+ // stays valid even after trimming.
1589
+ const RECALL_TAIL_USER_MAX = 2;
1590
+ const RECALL_TAIL_TOKEN_CAP = DEFAULT_COMPACTION_KEEP_TOKENS; // 8k
1591
+ // Rough chars-per-token used only to size a truncation target; the real fit is
1592
+ // re-checked with estimateMessagesTokens below.
1593
+ const RECALL_TAIL_CHARS_PER_TOKEN = 4;
1594
+
1595
+ function splitTailIntoTurns(messages) {
1596
+ const turns = [];
757
1597
  let current = null;
758
1598
  for (const m of messages) {
759
- if (m?.role === 'assistant') {
760
- if (current) groups.push(current);
1599
+ if (isSummaryMessage(m)) continue;
1600
+ if (m?.role === 'user') {
1601
+ if (current) turns.push(current);
761
1602
  current = [m];
762
1603
  } else {
763
1604
  if (!current) current = [];
764
1605
  current.push(m);
765
1606
  }
766
1607
  }
767
- if (current) groups.push(current);
768
- return groups;
769
- }
770
-
771
- /**
772
- * Active-turn fallback compaction (bridge/worker only).
773
- *
774
- * compactMessages/semanticCompactMessages treat the ENTIRE current turn as
775
- * mandatory, so a hidden worker with one user turn plus many assistant/tool
776
- * iterations throws overflow even when older same-turn tool outputs could be
777
- * safely shrunk. This narrow fallback shrinks the current turn itself while
778
- * preserving, in priority order:
779
- * - all system messages,
780
- * - the original task user message (the turn's first user message),
781
- * - the latest assistant/tool group(s),
782
- * - valid tool_use/tool_result pairing (via sanitizeToolPairs).
783
- *
784
- * Older prior history (before the current turn) is condensed to a best-effort
785
- * summary when it still fits the remaining budget, otherwise dropped — it has
786
- * already been superseded by completed turns. It NEVER silently drops the task
787
- * user message or the latest group: if system + task user + the fully-pruned
788
- * latest group still cannot fit, it throws so the caller surfaces overflow.
789
- */
790
- export function compactActiveTurn(messages, budgetTokens, opts = {}) {
791
- const budget = effectiveBudget(budgetTokens, opts);
792
- const sanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
793
- const system = sanitized.filter(m => m?.role === 'system');
794
- const nonSystem = sanitized.filter(m => m?.role !== 'system');
795
- const turnStart = currentTurnStart(nonSystem);
796
- if (turnStart < 0) {
797
- throw new Error(`compactActiveTurn: no current user turn to preserve (budget=${budget})`);
798
- }
799
-
800
- const oldHistory = nonSystem.slice(0, turnStart);
801
- const currentTurn = nonSystem.slice(turnStart);
802
- const userMsg = currentTurn[0];
803
- const groups = splitTurnGroups(currentTurn.slice(1));
804
- const minGroups = Math.max(1, Number(opts.minActiveTurnGroups) || 1);
805
- const maxChars = Math.max(256, Number(opts.maxToolOutputChars) || PRUNE_TOOL_OUTPUT_MAX_CHARS);
806
- const force = opts.force === true;
807
-
808
- const pruneToolMsg = (m) => (
809
- m?.role === 'tool' && typeof m.content === 'string' && m.content.length > maxChars
810
- ? {
811
- ...m,
812
- content: pruneToolOutputText(m.content, maxChars, m.toolCallId),
813
- compacted: true,
814
- compactedKind: 'active_turn_tool_prune',
815
- }
816
- : m
817
- );
1608
+ if (current && current.length) turns.push(current);
1609
+ return turns;
1610
+ }
818
1611
 
819
- // pruneMode: 'older' prunes every group except the latest; 'all' prunes
820
- // every kept group (last resort before declaring overflow).
821
- const buildTurnMsgs = (keptGroups, pruneMode) => {
822
- const out = [userMsg];
823
- for (let gi = 0; gi < keptGroups.length; gi += 1) {
824
- const isLatest = gi === keptGroups.length - 1;
825
- const prune = pruneMode === 'all' || (pruneMode === 'older' && !isLatest);
826
- for (const m of keptGroups[gi]) out.push(prune ? pruneToolMsg(m) : m);
1612
+ function truncateMessageForRecallTail(text, maxChars) {
1613
+ const marker = RECALL_TAIL_TRUNCATION_MARKER;
1614
+ const value = String(text ?? '').replace(/\r\n/g, '\n');
1615
+ if (value.length <= maxChars) return value;
1616
+ if (maxChars <= 0) return RECALL_TAIL_SHORT_TRUNCATION_MARKER;
1617
+ if (maxChars < marker.length) return RECALL_TAIL_SHORT_TRUNCATION_MARKER;
1618
+ const room = maxChars - marker.length;
1619
+ const head = Math.ceil(room * 0.35);
1620
+ const tailPart = Math.floor(room * 0.65);
1621
+ return `${value.slice(0, head)}${marker}${value.slice(value.length - tailPart)}`;
1622
+ }
1623
+
1624
+ function fitRecallUserMessageToCap(userMsg, cap, following = []) {
1625
+ const followCost = estimateMessagesTokens(following);
1626
+ const room = Math.max(1, cap - followCost);
1627
+ const base = { ...userMsg };
1628
+ const raw = typeof base.content === 'string' ? base.content : extractText(base);
1629
+ if (estimateMessagesTokens([{ ...base, content: raw }]) <= room) return { ...base, content: raw };
1630
+
1631
+ let lo = 0;
1632
+ let hi = raw.length;
1633
+ let best = truncateMessageForRecallTail(raw, 0);
1634
+ while (lo <= hi) {
1635
+ const mid = Math.floor((lo + hi) / 2);
1636
+ const candidateText = truncateMessageForRecallTail(raw, mid);
1637
+ const candidate = { ...base, content: candidateText };
1638
+ if (estimateMessagesTokens([candidate]) <= room) {
1639
+ best = candidateText;
1640
+ lo = mid + 1;
1641
+ } else {
1642
+ hi = mid - 1;
827
1643
  }
828
- return out;
829
- };
1644
+ }
1645
+ return { ...base, content: best };
1646
+ }
1647
+
1648
+ function fitSingleRecallTurnToCap(turn, cap) {
1649
+ const userIdx = turn.findIndex((m) => m?.role === 'user');
1650
+ if (userIdx < 0) {
1651
+ return truncateTailToCap(turn, cap);
1652
+ }
1653
+ const userMsg = turn[userIdx];
1654
+ const following = turn.slice(userIdx + 1);
1655
+ const fittedUser = fitRecallUserMessageToCap(userMsg, cap, following);
1656
+ let out = [fittedUser];
1657
+ for (const m of following) {
1658
+ const candidate = [...out, m];
1659
+ if (estimateMessagesTokens(candidate) <= cap) out.push(m);
1660
+ }
1661
+ return reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(out)));
1662
+ }
830
1663
 
831
- const buildWithSummaries = (turnMsgs, { oldSummary = null, activeSummary = null } = {}) => {
832
- const [taskUser, ...restTurn] = turnMsgs;
833
- return reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs([
834
- ...system,
835
- ...(oldSummary ? [oldSummary] : []),
836
- taskUser,
837
- ...(activeSummary ? [activeSummary] : []),
838
- ...restTurn,
839
- ])));
1664
+ function truncateTailToCap(messages, cap) {
1665
+ const turn = Array.isArray(messages) ? messages : [];
1666
+ if (turn.length === 0) return [];
1667
+ return fitSingleRecallTurnToCap(turn, cap);
1668
+ }
1669
+
1670
+ function stripNestedSummaryHeaderLines(text) {
1671
+ const lines = String(text ?? '').split('\n');
1672
+ const out = [];
1673
+ for (const line of lines) {
1674
+ if (line.startsWith(SUMMARY_PREFIX)) continue;
1675
+ if (/^messages=\d+\s+sha256=/.test(line.trim())) continue;
1676
+ out.push(line);
1677
+ }
1678
+ return out.join('\n').trim();
1679
+ }
1680
+
1681
+ function splitRecallFitInputs(recallText, previousSummary) {
1682
+ return {
1683
+ recall: String(recallText || '').trim(),
1684
+ prior: previousSummary ? stripNestedSummaryHeaderLines(previousSummary) : '',
840
1685
  };
1686
+ }
841
1687
 
842
- const finalize = (turnMsgs, activeHistory = []) => {
843
- let oldSummary = null;
844
- let activeSummary = null;
845
- let base = buildWithSummaries(turnMsgs);
846
- if (activeHistory.length) {
847
- const baseCost = estimateMessagesTokens(base);
848
- if (baseCost < budget) {
849
- activeSummary = fitSummaryMessage(activeHistory, budget - baseCost);
850
- if (activeSummary) base = buildWithSummaries(turnMsgs, { activeSummary });
851
- }
852
- }
853
- if (oldHistory.length) {
854
- const baseCost = estimateMessagesTokens(base);
855
- if (baseCost < budget) {
856
- oldSummary = fitSummaryMessage(oldHistory, budget - baseCost);
857
- if (oldSummary) base = buildWithSummaries(turnMsgs, { oldSummary, activeSummary });
858
- }
859
- }
860
- return base;
1688
+ function recallTailStartIndex(live, tail) {
1689
+ if (!tail.length) return live.length;
1690
+ const first = tail[0];
1691
+ const idx = live.indexOf(first);
1692
+ if (idx >= 0) return idx;
1693
+ return Math.max(0, live.length - tail.length);
1694
+ }
1695
+
1696
+ function selectRecallPreservedTail(live, opts = {}) {
1697
+ const msgs = (Array.isArray(live) ? live : []).filter((m) => m && !isSummaryMessage(m));
1698
+ if (msgs.length === 0) return { tail: [], head: [], tailStartIdx: 0 };
1699
+ const maxTurns = Math.max(1, Number(opts.maxUsers) || RECALL_TAIL_USER_MAX);
1700
+ const cap = Math.max(1, Number(opts.tokenCap) || RECALL_TAIL_TOKEN_CAP);
1701
+ const turns = splitTailIntoTurns(msgs);
1702
+ if (turns.length === 0) return { tail: [], head: msgs, tailStartIdx: 0 };
1703
+
1704
+ let kept = turns.slice(-maxTurns);
1705
+ while (kept.length > 1 && estimateMessagesTokens(kept.flat()) > cap) {
1706
+ kept = kept.slice(1);
1707
+ }
1708
+
1709
+ let tail;
1710
+ if (estimateMessagesTokens(kept.flat()) <= cap) {
1711
+ tail = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(kept.flat())));
1712
+ } else {
1713
+ tail = fitSingleRecallTurnToCap(kept[kept.length - 1], cap);
1714
+ }
1715
+
1716
+ if (!tail.some((m) => m?.role === 'user')) {
1717
+ throw new Error('recallFastTrackCompactMessages: preserved tail missing user anchor');
1718
+ }
1719
+
1720
+ const tailStartIdx = recallTailStartIndex(msgs, tail);
1721
+ const head = msgs.slice(0, tailStartIdx);
1722
+ return { tail, head, tailStartIdx };
1723
+ }
1724
+
1725
+ // Kept name as the type-2 tail anchor; behavior now preserves whole structured
1726
+ // turns (all roles) rather than only role=user messages.
1727
+ function selectRecallTailUserMessages(tail, opts = {}) {
1728
+ const msgs = (Array.isArray(tail) ? tail : []).filter((m) => m && !isSummaryMessage(m));
1729
+ return selectRecallPreservedTail(msgs, opts).tail;
1730
+ }
1731
+
1732
+ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
1733
+ let budget = effectiveBudget(budgetTokens, opts);
1734
+ const baseSanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
1735
+ if (estimateMessagesTokens(baseSanitized) <= budget && opts.force !== true) {
1736
+ return { messages: baseSanitized, recallFastTrack: false };
1737
+ }
1738
+ const sanitized = redactToolCallSecretsInMessages(baseSanitized);
1739
+
1740
+ const { system: safeSystem, live, previousSummary } = splitLiveCompactionContext(sanitized);
1741
+ const recallTailOpts = {
1742
+ maxUsers: opts.recallTailMaxUsers ?? opts.tailTurns ?? RECALL_TAIL_USER_MAX,
1743
+ tokenCap: opts.recallTailTokenCap ?? preserveRecentBudget(budget, opts),
861
1744
  };
1745
+ const { tail: recallTail, head: recallHead } = selectRecallPreservedTail(live, recallTailOpts);
1746
+ const recallFit = splitRecallFitInputs(opts.recallText, previousSummary);
1747
+ if (recallHead.length === 0 && !previousSummary
1748
+ && !(recallFit.recall || recallFit.prior || opts.allowEmptyRecall === true)) {
1749
+ throw new Error('recallFastTrackCompactMessages: no compactable prior history before preserved tail');
1750
+ }
862
1751
 
863
- const maxDrop = Math.max(0, groups.length - minGroups);
864
- const startDrop = force && maxDrop > 0 ? 1 : 0;
865
- for (let drop = startDrop; drop <= maxDrop; drop += 1) {
866
- const kept = groups.slice(drop);
867
- const activeHistory = groups.slice(0, drop).flat();
868
- const modes = force && drop === 0 && activeHistory.length === 0
869
- ? ['all', 'older']
870
- : ['older', 'all'];
871
- for (const mode of modes) {
872
- const candidate = finalize(buildTurnMsgs(kept, mode), activeHistory);
873
- if (estimateMessagesTokens(candidate) <= budget) return candidate;
874
- }
1752
+ const mandatory = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs([...safeSystem, ...recallTail])));
1753
+ const mandatoryCost = estimateMessagesTokens(mandatory);
1754
+ if (mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS > budget) {
1755
+ budget = mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS;
875
1756
  }
876
1757
 
877
- const floor = finalize(buildTurnMsgs(groups.slice(-minGroups), 'all'), groups.slice(0, -minGroups).flat());
878
- throw new Error(
879
- `compactActiveTurn: system+task user+latest turn group exceeds budget=${budget} ` +
880
- `(floor=${estimateMessagesTokens(floor)})`,
1758
+ if (!recallFit.recall && !recallFit.prior && opts.allowEmptyRecall !== true) {
1759
+ throw new Error('recallFastTrackCompactMessages: recall text is empty');
1760
+ }
1761
+ const oldHistory = recallHead;
1762
+ const recallMeta = {
1763
+ querySha: opts.querySha || null,
1764
+ };
1765
+ const summaryMessage = fitRecallFastTrackSummaryMessage(
1766
+ oldHistory,
1767
+ recallFit.recall,
1768
+ budget - mandatoryCost,
1769
+ recallMeta,
1770
+ recallFit.prior,
881
1771
  );
1772
+ if (!summaryMessage) {
1773
+ throw new Error(`recallFastTrackCompactMessages: summary cannot fit remaining budget=${budget - mandatoryCost}`);
1774
+ }
1775
+
1776
+ let result = sanitizeToolPairs([...safeSystem, summaryMessage, ...recallTail]);
1777
+ result = reconcileDedupStubs(dedupToolResultBodies(result));
1778
+ const finalTokens = estimateMessagesTokens(result);
1779
+ if (finalTokens > budget) {
1780
+ throw new Error(`recallFastTrackCompactMessages: compacted result exceeds budget=${budget} (result=${finalTokens})`);
1781
+ }
1782
+ return {
1783
+ messages: result,
1784
+ recallFastTrack: true,
1785
+ compactType: COMPACT_TYPE_RECALL_FASTTRACK,
1786
+ query: opts.query || '',
1787
+ };
882
1788
  }