mindweave 0.0.1 → 2.1.2

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 (481) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +171 -14
  3. package/dist/alternator/chassis/cache.js +47 -0
  4. package/dist/alternator/chassis/cache.js.map +1 -0
  5. package/dist/alternator/chassis/domRefs.js +53 -0
  6. package/dist/alternator/chassis/domRefs.js.map +1 -0
  7. package/dist/alternator/chassis/extractWorker.js +65 -0
  8. package/dist/alternator/chassis/extractWorker.js.map +1 -0
  9. package/dist/alternator/chassis/graph.js +221 -0
  10. package/dist/alternator/chassis/graph.js.map +1 -0
  11. package/dist/alternator/chassis/index.js +463 -0
  12. package/dist/alternator/chassis/index.js.map +1 -0
  13. package/dist/alternator/chassis/isolation.js +336 -0
  14. package/dist/alternator/chassis/isolation.js.map +1 -0
  15. package/dist/alternator/chassis/langCases.js +69 -0
  16. package/dist/alternator/chassis/langCases.js.map +1 -0
  17. package/dist/alternator/chassis/lsp.js +469 -0
  18. package/dist/alternator/chassis/lsp.js.map +1 -0
  19. package/dist/alternator/chassis/markup.js +262 -0
  20. package/dist/alternator/chassis/markup.js.map +1 -0
  21. package/dist/alternator/chassis/provision.js +198 -0
  22. package/dist/alternator/chassis/provision.js.map +1 -0
  23. package/dist/alternator/chassis/rank.js +72 -0
  24. package/dist/alternator/chassis/rank.js.map +1 -0
  25. package/dist/alternator/chassis/servers.js +215 -0
  26. package/dist/alternator/chassis/servers.js.map +1 -0
  27. package/dist/alternator/chassis/treesitter.js +608 -0
  28. package/dist/alternator/chassis/treesitter.js.map +1 -0
  29. package/dist/alternator/chassis/types.js +24 -0
  30. package/dist/alternator/chassis/types.js.map +1 -0
  31. package/dist/alternator/lane.js +67 -0
  32. package/dist/alternator/lane.js.map +1 -0
  33. package/dist/cli/App.js +2586 -0
  34. package/dist/cli/App.js.map +1 -0
  35. package/dist/cli/altScreen.js +67 -0
  36. package/dist/cli/altScreen.js.map +1 -0
  37. package/dist/cli/approvalChannel.js +72 -0
  38. package/dist/cli/approvalChannel.js.map +1 -0
  39. package/dist/cli/attachments.js +188 -0
  40. package/dist/cli/attachments.js.map +1 -0
  41. package/dist/cli/bootstrap.js +279 -0
  42. package/dist/cli/bootstrap.js.map +1 -0
  43. package/dist/cli/chatAnchor.js +94 -0
  44. package/dist/cli/chatAnchor.js.map +1 -0
  45. package/dist/cli/commandArgs.js +79 -0
  46. package/dist/cli/commandArgs.js.map +1 -0
  47. package/dist/cli/commandRoute.js +78 -0
  48. package/dist/cli/commandRoute.js.map +1 -0
  49. package/dist/cli/compaction.js +69 -0
  50. package/dist/cli/compaction.js.map +1 -0
  51. package/dist/cli/components/ApprovalBox.js +100 -0
  52. package/dist/cli/components/ApprovalBox.js.map +1 -0
  53. package/dist/cli/components/BlockView.js +128 -0
  54. package/dist/cli/components/BlockView.js.map +1 -0
  55. package/dist/cli/components/FirstRunFrame.js +41 -0
  56. package/dist/cli/components/FirstRunFrame.js.map +1 -0
  57. package/dist/cli/components/KeyManager.js +197 -0
  58. package/dist/cli/components/KeyManager.js.map +1 -0
  59. package/dist/cli/components/KeySetup.js +125 -0
  60. package/dist/cli/components/KeySetup.js.map +1 -0
  61. package/dist/cli/components/MenuFrame.js +24 -0
  62. package/dist/cli/components/MenuFrame.js.map +1 -0
  63. package/dist/cli/components/Picker.js +82 -0
  64. package/dist/cli/components/Picker.js.map +1 -0
  65. package/dist/cli/components/PromptInput.js +509 -0
  66. package/dist/cli/components/PromptInput.js.map +1 -0
  67. package/dist/cli/components/SubagentView.js +86 -0
  68. package/dist/cli/components/SubagentView.js.map +1 -0
  69. package/dist/cli/components/ToolGroup.js +60 -0
  70. package/dist/cli/components/ToolGroup.js.map +1 -0
  71. package/dist/cli/components/ToolLine.js +123 -0
  72. package/dist/cli/components/ToolLine.js.map +1 -0
  73. package/dist/cli/components/TrustGate.js +37 -0
  74. package/dist/cli/components/TrustGate.js.map +1 -0
  75. package/dist/cli/costView.js +40 -0
  76. package/dist/cli/costView.js.map +1 -0
  77. package/dist/cli/frameRate.js +30 -0
  78. package/dist/cli/frameRate.js.map +1 -0
  79. package/dist/cli/framebuffer/paint.js +165 -0
  80. package/dist/cli/framebuffer/paint.js.map +1 -0
  81. package/dist/cli/framebuffer/parse.js +266 -0
  82. package/dist/cli/framebuffer/parse.js.map +1 -0
  83. package/dist/cli/framebuffer/screen.js +196 -0
  84. package/dist/cli/framebuffer/screen.js.map +1 -0
  85. package/dist/cli/framebuffer/writer.js +139 -0
  86. package/dist/cli/framebuffer/writer.js.map +1 -0
  87. package/dist/cli/groupReveal.js +51 -0
  88. package/dist/cli/groupReveal.js.map +1 -0
  89. package/dist/cli/help.js +55 -0
  90. package/dist/cli/help.js.map +1 -0
  91. package/dist/cli/inputView.js +105 -0
  92. package/dist/cli/inputView.js.map +1 -0
  93. package/dist/cli/keyManager.js +65 -0
  94. package/dist/cli/keyManager.js.map +1 -0
  95. package/dist/cli/keySetup.js +35 -0
  96. package/dist/cli/keySetup.js.map +1 -0
  97. package/dist/cli/keyStore.js +73 -0
  98. package/dist/cli/keyStore.js.map +1 -0
  99. package/dist/cli/markdown.js +460 -0
  100. package/dist/cli/markdown.js.map +1 -0
  101. package/dist/cli/messageQueue.js +96 -0
  102. package/dist/cli/messageQueue.js.map +1 -0
  103. package/dist/cli/modes.js +90 -0
  104. package/dist/cli/modes.js.map +1 -0
  105. package/dist/cli/mouse.js +78 -0
  106. package/dist/cli/mouse.js.map +1 -0
  107. package/dist/cli/pasteAssembler.js +44 -0
  108. package/dist/cli/pasteAssembler.js.map +1 -0
  109. package/dist/cli/pathComplete.js +39 -0
  110. package/dist/cli/pathComplete.js.map +1 -0
  111. package/dist/cli/perfLog.js +90 -0
  112. package/dist/cli/perfLog.js.map +1 -0
  113. package/dist/cli/reveal.js +32 -0
  114. package/dist/cli/reveal.js.map +1 -0
  115. package/dist/cli/revealPace.js +59 -0
  116. package/dist/cli/revealPace.js.map +1 -0
  117. package/dist/cli/sessionCarry.js +19 -0
  118. package/dist/cli/sessionCarry.js.map +1 -0
  119. package/dist/cli/smoothScroll.js +74 -0
  120. package/dist/cli/smoothScroll.js.map +1 -0
  121. package/dist/cli/startupArgs.js +96 -0
  122. package/dist/cli/startupArgs.js.map +1 -0
  123. package/dist/cli/terminalRestore.js +34 -0
  124. package/dist/cli/terminalRestore.js.map +1 -0
  125. package/dist/cli/toolDisplay.js +219 -0
  126. package/dist/cli/toolDisplay.js.map +1 -0
  127. package/dist/cli/toolItems.js +65 -0
  128. package/dist/cli/toolItems.js.map +1 -0
  129. package/dist/cli/transcript.js +351 -0
  130. package/dist/cli/transcript.js.map +1 -0
  131. package/dist/cli/trust.js +79 -0
  132. package/dist/cli/trust.js.map +1 -0
  133. package/dist/cli/updateCheck.js +107 -0
  134. package/dist/cli/updateCheck.js.map +1 -0
  135. package/dist/cli/version.js +39 -0
  136. package/dist/cli/version.js.map +1 -0
  137. package/dist/cli/viewport.js +179 -0
  138. package/dist/cli/viewport.js.map +1 -0
  139. package/dist/cli/virtualWindow.js +103 -0
  140. package/dist/cli/virtualWindow.js.map +1 -0
  141. package/dist/cli/workingVerb.js +37 -0
  142. package/dist/cli/workingVerb.js.map +1 -0
  143. package/dist/cli/wrap.js +145 -0
  144. package/dist/cli/wrap.js.map +1 -0
  145. package/dist/drivers/anthropic/cachePoints.js +77 -0
  146. package/dist/drivers/anthropic/cachePoints.js.map +1 -0
  147. package/dist/drivers/anthropic/client.js +432 -0
  148. package/dist/drivers/anthropic/client.js.map +1 -0
  149. package/dist/drivers/anthropic/index.js +10 -0
  150. package/dist/drivers/anthropic/index.js.map +1 -0
  151. package/dist/drivers/anthropic/manifest.js +221 -0
  152. package/dist/drivers/anthropic/manifest.js.map +1 -0
  153. package/dist/drivers/cerebras/client.js +69 -0
  154. package/dist/drivers/cerebras/client.js.map +1 -0
  155. package/dist/drivers/cerebras/index.js +5 -0
  156. package/dist/drivers/cerebras/index.js.map +1 -0
  157. package/dist/drivers/cerebras/manifest.js +140 -0
  158. package/dist/drivers/cerebras/manifest.js.map +1 -0
  159. package/dist/drivers/clientId.js +41 -0
  160. package/dist/drivers/clientId.js.map +1 -0
  161. package/dist/drivers/contextOverflow.js +77 -0
  162. package/dist/drivers/contextOverflow.js.map +1 -0
  163. package/dist/drivers/deepseek/client.js +168 -0
  164. package/dist/drivers/deepseek/client.js.map +1 -0
  165. package/dist/drivers/deepseek/index.js +16 -0
  166. package/dist/drivers/deepseek/index.js.map +1 -0
  167. package/dist/drivers/deepseek/inlineTools.js +46 -0
  168. package/dist/drivers/deepseek/inlineTools.js.map +1 -0
  169. package/dist/drivers/deepseek/manifest.js +166 -0
  170. package/dist/drivers/deepseek/manifest.js.map +1 -0
  171. package/dist/drivers/gemini/client.js +38 -0
  172. package/dist/drivers/gemini/client.js.map +1 -0
  173. package/dist/drivers/gemini/index.js +5 -0
  174. package/dist/drivers/gemini/index.js.map +1 -0
  175. package/dist/drivers/gemini/manifest.js +150 -0
  176. package/dist/drivers/gemini/manifest.js.map +1 -0
  177. package/dist/drivers/glm/client.js +90 -0
  178. package/dist/drivers/glm/client.js.map +1 -0
  179. package/dist/drivers/glm/index.js +9 -0
  180. package/dist/drivers/glm/index.js.map +1 -0
  181. package/dist/drivers/glm/manifest.js +183 -0
  182. package/dist/drivers/glm/manifest.js.map +1 -0
  183. package/dist/drivers/groq/client.js +80 -0
  184. package/dist/drivers/groq/client.js.map +1 -0
  185. package/dist/drivers/groq/index.js +5 -0
  186. package/dist/drivers/groq/index.js.map +1 -0
  187. package/dist/drivers/groq/manifest.js +160 -0
  188. package/dist/drivers/groq/manifest.js.map +1 -0
  189. package/dist/drivers/kimi/client.js +82 -0
  190. package/dist/drivers/kimi/client.js.map +1 -0
  191. package/dist/drivers/kimi/index.js +9 -0
  192. package/dist/drivers/kimi/index.js.map +1 -0
  193. package/dist/drivers/kimi/manifest.js +168 -0
  194. package/dist/drivers/kimi/manifest.js.map +1 -0
  195. package/dist/drivers/meta/client.js +30 -0
  196. package/dist/drivers/meta/client.js.map +1 -0
  197. package/dist/drivers/meta/index.js +5 -0
  198. package/dist/drivers/meta/index.js.map +1 -0
  199. package/dist/drivers/meta/manifest.js +101 -0
  200. package/dist/drivers/meta/manifest.js.map +1 -0
  201. package/dist/drivers/minimax/client.js +36 -0
  202. package/dist/drivers/minimax/client.js.map +1 -0
  203. package/dist/drivers/minimax/index.js +5 -0
  204. package/dist/drivers/minimax/index.js.map +1 -0
  205. package/dist/drivers/minimax/manifest.js +120 -0
  206. package/dist/drivers/minimax/manifest.js.map +1 -0
  207. package/dist/drivers/mistral/client.js +54 -0
  208. package/dist/drivers/mistral/client.js.map +1 -0
  209. package/dist/drivers/mistral/index.js +5 -0
  210. package/dist/drivers/mistral/index.js.map +1 -0
  211. package/dist/drivers/mistral/manifest.js +118 -0
  212. package/dist/drivers/mistral/manifest.js.map +1 -0
  213. package/dist/drivers/openai/client.js +290 -0
  214. package/dist/drivers/openai/client.js.map +1 -0
  215. package/dist/drivers/openai/index.js +9 -0
  216. package/dist/drivers/openai/index.js.map +1 -0
  217. package/dist/drivers/openai/manifest.js +133 -0
  218. package/dist/drivers/openai/manifest.js.map +1 -0
  219. package/dist/drivers/openaiCompat/wire.js +493 -0
  220. package/dist/drivers/openaiCompat/wire.js.map +1 -0
  221. package/dist/drivers/partialTurn.js +23 -0
  222. package/dist/drivers/partialTurn.js.map +1 -0
  223. package/dist/drivers/providerError.js +181 -0
  224. package/dist/drivers/providerError.js.map +1 -0
  225. package/dist/drivers/qwen/client.js +93 -0
  226. package/dist/drivers/qwen/client.js.map +1 -0
  227. package/dist/drivers/qwen/index.js +9 -0
  228. package/dist/drivers/qwen/index.js.map +1 -0
  229. package/dist/drivers/qwen/manifest.js +146 -0
  230. package/dist/drivers/qwen/manifest.js.map +1 -0
  231. package/dist/drivers/registry.js +177 -0
  232. package/dist/drivers/registry.js.map +1 -0
  233. package/dist/drivers/retryPolicy.js +129 -0
  234. package/dist/drivers/retryPolicy.js.map +1 -0
  235. package/dist/drivers/searchBlocks.js +61 -0
  236. package/dist/drivers/searchBlocks.js.map +1 -0
  237. package/dist/drivers/types.js +2 -0
  238. package/dist/drivers/types.js.map +1 -0
  239. package/dist/drivers/xai/client.js +63 -0
  240. package/dist/drivers/xai/client.js.map +1 -0
  241. package/dist/drivers/xai/index.js +5 -0
  242. package/dist/drivers/xai/index.js.map +1 -0
  243. package/dist/drivers/xai/manifest.js +137 -0
  244. package/dist/drivers/xai/manifest.js.map +1 -0
  245. package/dist/dynamo/_probe.tmp.js +20 -0
  246. package/dist/dynamo/_probe.tmp.js.map +1 -0
  247. package/dist/dynamo/cacheBreak.js +88 -0
  248. package/dist/dynamo/cacheBreak.js.map +1 -0
  249. package/dist/dynamo/contextWindow.js +243 -0
  250. package/dist/dynamo/contextWindow.js.map +1 -0
  251. package/dist/dynamo/engine.js +2011 -0
  252. package/dist/dynamo/engine.js.map +1 -0
  253. package/dist/dynamo/guard.js +132 -0
  254. package/dist/dynamo/guard.js.map +1 -0
  255. package/dist/dynamo/liveMeter.js +58 -0
  256. package/dist/dynamo/liveMeter.js.map +1 -0
  257. package/dist/dynamo/model.js +172 -0
  258. package/dist/dynamo/model.js.map +1 -0
  259. package/dist/dynamo/planArtifact.js +125 -0
  260. package/dist/dynamo/planArtifact.js.map +1 -0
  261. package/dist/dynamo/pricing.js +140 -0
  262. package/dist/dynamo/pricing.js.map +1 -0
  263. package/dist/dynamo/prompt.js +264 -0
  264. package/dist/dynamo/prompt.js.map +1 -0
  265. package/dist/dynamo/spend.js +45 -0
  266. package/dist/dynamo/spend.js.map +1 -0
  267. package/dist/dynamo/verify.js +483 -0
  268. package/dist/dynamo/verify.js.map +1 -0
  269. package/dist/governor/forbidden.js +181 -0
  270. package/dist/governor/forbidden.js.map +1 -0
  271. package/dist/governor/freshness.js +83 -0
  272. package/dist/governor/freshness.js.map +1 -0
  273. package/dist/governor/frontmatter.js +38 -0
  274. package/dist/governor/frontmatter.js.map +1 -0
  275. package/dist/governor/glob.js +75 -0
  276. package/dist/governor/glob.js.map +1 -0
  277. package/dist/governor/index.js +87 -0
  278. package/dist/governor/index.js.map +1 -0
  279. package/dist/governor/rules.js +80 -0
  280. package/dist/governor/rules.js.map +1 -0
  281. package/dist/governor/scope.js +91 -0
  282. package/dist/governor/scope.js.map +1 -0
  283. package/dist/governor/skills.js +176 -0
  284. package/dist/governor/skills.js.map +1 -0
  285. package/dist/governor/types.js +16 -0
  286. package/dist/governor/types.js.map +1 -0
  287. package/dist/governor/write.js +182 -0
  288. package/dist/governor/write.js.map +1 -0
  289. package/dist/index.js +89 -0
  290. package/dist/index.js.map +1 -0
  291. package/dist/mcp/catalog.js +232 -0
  292. package/dist/mcp/catalog.js.map +1 -0
  293. package/dist/mcp/config.js +116 -0
  294. package/dist/mcp/config.js.map +1 -0
  295. package/dist/mcp/configWrite.js +254 -0
  296. package/dist/mcp/configWrite.js.map +1 -0
  297. package/dist/mcp/connection.js +504 -0
  298. package/dist/mcp/connection.js.map +1 -0
  299. package/dist/mcp/deferred.js +104 -0
  300. package/dist/mcp/deferred.js.map +1 -0
  301. package/dist/mcp/discover.js +128 -0
  302. package/dist/mcp/discover.js.map +1 -0
  303. package/dist/mcp/manager.js +679 -0
  304. package/dist/mcp/manager.js.map +1 -0
  305. package/dist/mcp/paramHeaders.js +163 -0
  306. package/dist/mcp/paramHeaders.js.map +1 -0
  307. package/dist/mcp/prompts.js +136 -0
  308. package/dist/mcp/prompts.js.map +1 -0
  309. package/dist/mcp/protocol.js +171 -0
  310. package/dist/mcp/protocol.js.map +1 -0
  311. package/dist/mcp/resources.js +119 -0
  312. package/dist/mcp/resources.js.map +1 -0
  313. package/dist/mcp/resultStore.js +180 -0
  314. package/dist/mcp/resultStore.js.map +1 -0
  315. package/dist/mcp/subscriptions.js +74 -0
  316. package/dist/mcp/subscriptions.js.map +1 -0
  317. package/dist/mcp/transport/headerValue.js +40 -0
  318. package/dist/mcp/transport/headerValue.js.map +1 -0
  319. package/dist/mcp/transport/http.js +321 -0
  320. package/dist/mcp/transport/http.js.map +1 -0
  321. package/dist/mcp/transport/stdio.js +234 -0
  322. package/dist/mcp/transport/stdio.js.map +1 -0
  323. package/dist/mcp/transport/types.js +27 -0
  324. package/dist/mcp/transport/types.js.map +1 -0
  325. package/dist/mcp/trust.js +98 -0
  326. package/dist/mcp/trust.js.map +1 -0
  327. package/dist/mcp/trustStore.js +55 -0
  328. package/dist/mcp/trustStore.js.map +1 -0
  329. package/dist/memory/autoMemory.js +340 -0
  330. package/dist/memory/autoMemory.js.map +1 -0
  331. package/dist/memory/compaction.js +513 -0
  332. package/dist/memory/compaction.js.map +1 -0
  333. package/dist/memory/images.js +173 -0
  334. package/dist/memory/images.js.map +1 -0
  335. package/dist/memory/presence.js +59 -0
  336. package/dist/memory/presence.js.map +1 -0
  337. package/dist/memory/projectNotes.js +247 -0
  338. package/dist/memory/projectNotes.js.map +1 -0
  339. package/dist/memory/restore.js +72 -0
  340. package/dist/memory/restore.js.map +1 -0
  341. package/dist/memory/session.js +472 -0
  342. package/dist/memory/session.js.map +1 -0
  343. package/dist/memory/sessionMemory.js +142 -0
  344. package/dist/memory/sessionMemory.js.map +1 -0
  345. package/dist/memory/sessionMemoryCompact.js +136 -0
  346. package/dist/memory/sessionMemoryCompact.js.map +1 -0
  347. package/dist/memory/store.js +217 -0
  348. package/dist/memory/store.js.map +1 -0
  349. package/dist/memory/types.js +14 -0
  350. package/dist/memory/types.js.map +1 -0
  351. package/dist/memory/workingSet.js +383 -0
  352. package/dist/memory/workingSet.js.map +1 -0
  353. package/dist/project/context.js +425 -0
  354. package/dist/project/context.js.map +1 -0
  355. package/dist/tools/approval.js +189 -0
  356. package/dist/tools/approval.js.map +1 -0
  357. package/dist/tools/askUser.js +103 -0
  358. package/dist/tools/askUser.js.map +1 -0
  359. package/dist/tools/atomicWrite.js +151 -0
  360. package/dist/tools/atomicWrite.js.map +1 -0
  361. package/dist/tools/backgroundShells.js +468 -0
  362. package/dist/tools/backgroundShells.js.map +1 -0
  363. package/dist/tools/captureSweep.js +77 -0
  364. package/dist/tools/captureSweep.js.map +1 -0
  365. package/dist/tools/chassisMux.js +92 -0
  366. package/dist/tools/chassisMux.js.map +1 -0
  367. package/dist/tools/checkpoints.js +328 -0
  368. package/dist/tools/checkpoints.js.map +1 -0
  369. package/dist/tools/codeIntel.js +267 -0
  370. package/dist/tools/codeIntel.js.map +1 -0
  371. package/dist/tools/deferredNative.js +121 -0
  372. package/dist/tools/deferredNative.js.map +1 -0
  373. package/dist/tools/detail.js +135 -0
  374. package/dist/tools/detail.js.map +1 -0
  375. package/dist/tools/diagnostics.js +174 -0
  376. package/dist/tools/diagnostics.js.map +1 -0
  377. package/dist/tools/edit.js +167 -0
  378. package/dist/tools/edit.js.map +1 -0
  379. package/dist/tools/editCore.js +283 -0
  380. package/dist/tools/editCore.js.map +1 -0
  381. package/dist/tools/editFile.js +183 -0
  382. package/dist/tools/editFile.js.map +1 -0
  383. package/dist/tools/editRipple.js +126 -0
  384. package/dist/tools/editRipple.js.map +1 -0
  385. package/dist/tools/editTarget.js +131 -0
  386. package/dist/tools/editTarget.js.map +1 -0
  387. package/dist/tools/editWindow.js +49 -0
  388. package/dist/tools/editWindow.js.map +1 -0
  389. package/dist/tools/eol.js +56 -0
  390. package/dist/tools/eol.js.map +1 -0
  391. package/dist/tools/exitPlan.js +175 -0
  392. package/dist/tools/exitPlan.js.map +1 -0
  393. package/dist/tools/focus.js +42 -0
  394. package/dist/tools/focus.js.map +1 -0
  395. package/dist/tools/gitignore.js +148 -0
  396. package/dist/tools/gitignore.js.map +1 -0
  397. package/dist/tools/glob.js +134 -0
  398. package/dist/tools/glob.js.map +1 -0
  399. package/dist/tools/governorTools.js +258 -0
  400. package/dist/tools/governorTools.js.map +1 -0
  401. package/dist/tools/grep.js +450 -0
  402. package/dist/tools/grep.js.map +1 -0
  403. package/dist/tools/guard.js +220 -0
  404. package/dist/tools/guard.js.map +1 -0
  405. package/dist/tools/killTree.js +215 -0
  406. package/dist/tools/killTree.js.map +1 -0
  407. package/dist/tools/listDir.js +143 -0
  408. package/dist/tools/listDir.js.map +1 -0
  409. package/dist/tools/mcpAdd.js +176 -0
  410. package/dist/tools/mcpAdd.js.map +1 -0
  411. package/dist/tools/mcpResources.js +116 -0
  412. package/dist/tools/mcpResources.js.map +1 -0
  413. package/dist/tools/mcpSearch.js +123 -0
  414. package/dist/tools/mcpSearch.js.map +1 -0
  415. package/dist/tools/multiEdit.js +168 -0
  416. package/dist/tools/multiEdit.js.map +1 -0
  417. package/dist/tools/paths.js +225 -0
  418. package/dist/tools/paths.js.map +1 -0
  419. package/dist/tools/posixShell.js +123 -0
  420. package/dist/tools/posixShell.js.map +1 -0
  421. package/dist/tools/readFile.js +371 -0
  422. package/dist/tools/readFile.js.map +1 -0
  423. package/dist/tools/readSymbol.js +145 -0
  424. package/dist/tools/readSymbol.js.map +1 -0
  425. package/dist/tools/registry.js +120 -0
  426. package/dist/tools/registry.js.map +1 -0
  427. package/dist/tools/replaceSymbol.js +141 -0
  428. package/dist/tools/replaceSymbol.js.map +1 -0
  429. package/dist/tools/results.js +15 -0
  430. package/dist/tools/results.js.map +1 -0
  431. package/dist/tools/ripgrep.js +145 -0
  432. package/dist/tools/ripgrep.js.map +1 -0
  433. package/dist/tools/runCommand.js +633 -0
  434. package/dist/tools/runCommand.js.map +1 -0
  435. package/dist/tools/saveMemory.js +102 -0
  436. package/dist/tools/saveMemory.js.map +1 -0
  437. package/dist/tools/screenshot.js +200 -0
  438. package/dist/tools/screenshot.js.map +1 -0
  439. package/dist/tools/screenshotWin.js +227 -0
  440. package/dist/tools/screenshotWin.js.map +1 -0
  441. package/dist/tools/search.js +121 -0
  442. package/dist/tools/search.js.map +1 -0
  443. package/dist/tools/sessionTools.js +187 -0
  444. package/dist/tools/sessionTools.js.map +1 -0
  445. package/dist/tools/shellCheckpoint.js +123 -0
  446. package/dist/tools/shellCheckpoint.js.map +1 -0
  447. package/dist/tools/shellLint.js +102 -0
  448. package/dist/tools/shellLint.js.map +1 -0
  449. package/dist/tools/shellTools.js +203 -0
  450. package/dist/tools/shellTools.js.map +1 -0
  451. package/dist/tools/spanCore.js +78 -0
  452. package/dist/tools/spanCore.js.map +1 -0
  453. package/dist/tools/subagent.js +224 -0
  454. package/dist/tools/subagent.js.map +1 -0
  455. package/dist/tools/subagentReport.js +114 -0
  456. package/dist/tools/subagentReport.js.map +1 -0
  457. package/dist/tools/tempSweep.js +118 -0
  458. package/dist/tools/tempSweep.js.map +1 -0
  459. package/dist/tools/todo.js +117 -0
  460. package/dist/tools/todo.js.map +1 -0
  461. package/dist/tools/types.js +2 -0
  462. package/dist/tools/types.js.map +1 -0
  463. package/dist/tools/untrusted.js +46 -0
  464. package/dist/tools/untrusted.js.map +1 -0
  465. package/dist/tools/useSkill.js +62 -0
  466. package/dist/tools/useSkill.js.map +1 -0
  467. package/dist/tools/walk.js +152 -0
  468. package/dist/tools/walk.js.map +1 -0
  469. package/dist/tools/web.js +61 -0
  470. package/dist/tools/web.js.map +1 -0
  471. package/dist/tools/webFetch.js +444 -0
  472. package/dist/tools/webFetch.js.map +1 -0
  473. package/dist/tools/webSearch.js +130 -0
  474. package/dist/tools/webSearch.js.map +1 -0
  475. package/dist/tools/workspace.js +212 -0
  476. package/dist/tools/workspace.js.map +1 -0
  477. package/dist/tools/workspaceDiscover.js +216 -0
  478. package/dist/tools/workspaceDiscover.js.map +1 -0
  479. package/dist/tools/writeFile.js +162 -0
  480. package/dist/tools/writeFile.js.map +1 -0
  481. package/package.json +74 -28
@@ -0,0 +1,2011 @@
1
+ /**
2
+ * dynamo — the engine.
3
+ *
4
+ * Takes a live session and produces Mindweave's next reply, running tools along the
5
+ * way. The loop is intentionally tiny: ask the model → if it wants tools, run
6
+ * them and feed the results back → repeat → when it answers with no tool call,
7
+ * that's the reply. The model does the reasoning; the loop stays out of the way.
8
+ *
9
+ * The engine owns the TRANSCRIPT (it appends every user/assistant/tool turn to
10
+ * `session.transcript`) and keeps it healthy with the compaction cascade. It is
11
+ * pure of the filesystem: it never reads or writes session files (the CLI
12
+ * persists). The one disk touch is re-reading project files, which goes through
13
+ * the read-only tool exactly like any other tool call — so this whole function
14
+ * can later move to a server unchanged, with tools executing on the client.
15
+ */
16
+ import { activeDriver, ensureDriver, manifestForModel } from "../drivers/registry.js";
17
+ import { summarizeTask, taskLimitReason } from "./pricing.js";
18
+ import { addTurn, emptySpend } from "./spend.js";
19
+ import { mutationNeedsVerification, isVerification, reScopeCheck, isBackgroundPollStep, stepFailureSignature, repeatFailureStep, repeatFailureNudge, failedActionLabel, firstErrorLine, sameFileEditCounts, overusedSingleEdits, batchEditNudge, narrationFault, narrationNudge, unknownToolError, replyFault, replyRewrite, VERIFY_NUDGE } from "./verify.js";
20
+ import { guardOptions, GUARD_REFUSAL, GUARD_REFUSAL_INPUT, guardRefusalWith, guardQuestion, guardDetail, interpretGuardChoice } from "./guard.js";
21
+ import { readFreeText } from "../tools/approval.js";
22
+ import { findTool, toolSchemas, TOOLS } from "../tools/registry.js";
23
+ import { deferredToolsIndex } from "../tools/deferredNative.js";
24
+ import { prefixPrint, diffPrefix } from "./cacheBreak.js";
25
+ import { commandShellLabel } from "../tools/runCommand.js";
26
+ import { basePrompt } from "./prompt.js";
27
+ import { basename } from "node:path";
28
+ import { randomUUID } from "node:crypto";
29
+ import { promises as fsp } from "node:fs";
30
+ import { resolvePath, rootLabel, rootsOf } from "../tools/paths.js";
31
+ import { renderRules, renderSkillCatalog, reloadGovernance, governanceStamp, rescope } from "../governor/index.js";
32
+ import { forkSession, reloadProjectMemory } from "../memory/session.js";
33
+ import { selectActiveFiles } from "../memory/workingSet.js";
34
+ import { directoryNotesFor } from "../memory/projectNotes.js";
35
+ import { rippleCheck } from "../tools/editRipple.js";
36
+ import { fullReadPaths } from "../memory/presence.js";
37
+ import { RESTORE_MAX_TOKENS_PER_FILE, renderRestored, restoreBudgetFor, selectForRestore, } from "../memory/restore.js";
38
+ import { KEEP_LAST_N, KEEP_LAST_N_BOUNDARY, summaryRequest, SUMMARY_SYSTEM_PROMPT, dropOldestRounds, estimateEntriesTokens, estimateTokens, estimateTokensForChars, formatTranscriptForSummary, isContinuation, microcompact, spliceSummary, usableSummary, } from "../memory/compaction.js";
39
+ import { loadPlanArtifact, completePlanArtifact, renderPlanBlock, planDivergenceStop } from "./planArtifact.js";
40
+ import { autoCompactThreshold, microCompactThreshold, cacheLikelyCold, clearIsWorthIt, measuredOverhead, sharpContextWindow, } from "./contextWindow.js";
41
+ import { renderSessionMemory, shouldUpdateSessionMemory, updateSessionMemory } from "../memory/sessionMemory.js";
42
+ import { compactFromSessionMemory } from "../memory/sessionMemoryCompact.js";
43
+ import { isContextOverflowError } from "../drivers/contextOverflow.js";
44
+ import { detailOf, providerMessage } from "../drivers/providerError.js";
45
+ import { transcriptPath } from "../memory/store.js";
46
+ /** Stop retrying autocompact after this many consecutive failures in a session, so a
47
+ * transcript that's irrecoverably over the limit can't hammer the summarizer each turn
48
+ * (a circuit-breaker for runaway retry loops, which can otherwise pile up thousands of doomed retries). */
49
+ const MAX_COMPACT_FAILURES = 3;
50
+ // The static base (identity, output/formatting, tone, tool mechanics, safety,
51
+ // task hygiene, and how to use cross-session memory) comes from basePrompt in
52
+ // prompt.ts. Here we wrap it with the per-session, per-turn context: the
53
+ // governor (rules/forbidden/skills), the project snapshot, MINDWEAVE.md, the memory
54
+ // index, the ranked code map, the task list, and the multi-root workspace. The
55
+ // line we hold is the thin-prompt boundary: rich on what the harness owns, but
56
+ // we still do NOT teach engineering judgment (how to debug, how to write code) —
57
+ // that is the model's job.
58
+ export function staticSystemPrompt(projectContext, projectMemory, memoryDir, memoryIndex, governance, workspace, priorSessions = 0) {
59
+ let prompt = basePrompt(commandShellLabel());
60
+ if (workspace) {
61
+ prompt += `
62
+
63
+ This session spans more than one root folder. Each file is addressed as \`label/path\`; search tools cover every root unless you pass a specific \`path\`. The roots are:
64
+ <workspace>
65
+ ${workspace}
66
+ </workspace>`;
67
+ }
68
+ // NOTE: the user's standing rules are deliberately NOT rendered here. They live
69
+ // in the volatile tail (volatileContext) instead — rebuilt every turn at the
70
+ // boundary where attention is strongest, so a long session can't bury them in the
71
+ // middle of a huge cached prefix. Rules are the one governance layer that depends
72
+ // purely on the model reading and obeying (forbidden is enforced mechanically;
73
+ // skills are a reference catalog), so they alone get the salience boost. Keeping
74
+ // them out of the prefix also stops a mid-session `remember_rule` from busting it.
75
+ if (governance.forbidden) {
76
+ prompt += `
77
+
78
+ You are FORBIDDEN from modifying these paths — never write, edit, or run a command that changes them. The tools also enforce this and will refuse, but do not even try:
79
+ <forbidden>
80
+ ${governance.forbidden}
81
+ </forbidden>`;
82
+ }
83
+ if (governance.forbiddenCommands) {
84
+ prompt += `
85
+
86
+ You are FORBIDDEN from running these commands (or any command that contains one) — run_command will refuse them and only the user can lift that. Do not attempt them or a workaround:
87
+ <forbidden_commands>
88
+ ${governance.forbiddenCommands}
89
+ </forbidden_commands>`;
90
+ }
91
+ if (governance.skills) {
92
+ prompt += `
93
+
94
+ You have project skills available — named procedures you can run. To run one, call use_skill with its name; its full steps are loaded then (you only see the summary here). Use one when its description fits the task:
95
+ <available_skills>
96
+ ${governance.skills}
97
+ </available_skills>`;
98
+ }
99
+ if (projectContext) {
100
+ prompt += `
101
+
102
+ The following describes the project and machine you're working in, captured at the start of this session (a snapshot — use tools for anything current or deeper):
103
+ ${projectContext}`;
104
+ }
105
+ if (projectMemory) {
106
+ prompt += `
107
+
108
+ The project provides this context in its MINDWEAVE.md — treat it as background facts about this codebase:
109
+ <project_memory>
110
+ ${projectMemory}
111
+ </project_memory>`;
112
+ }
113
+ // Its own past work in this project. The COUNT goes in the prompt (so the model
114
+ // knows the history exists without being told every turn what is in it); the
115
+ // CONTENT is pulled on demand with the `sessions` tool. Injecting the
116
+ // sessions themselves would be ruinous — this way an ordinary turn pays nothing
117
+ // and a question about past work gets a real answer instead of a deflection.
118
+ if (priorSessions > 0) {
119
+ const s = priorSessions === 1 ? "" : "s";
120
+ prompt += `
121
+
122
+ You have worked in this project before: ${priorSessions} earlier session${s} of yours are saved, and you can read them. When the user refers to earlier work — "last session", "what did we do", "the bug we fixed" — call \`sessions\` to list them, then \`sessions\` again with an id to read the one they mean, and answer from what you find. It is not in your tool list until you load it with find_tools. Do not say you cannot see your past sessions, and do not guess from the project files instead. \`/continue\` is for the user to RESUME a session; it is not a substitute for you looking. Never present another tool's saved conversations as your own.`;
123
+ }
124
+ if (memoryDir) {
125
+ prompt += `
126
+
127
+ Your cross-session memory for this project lives in \`${memoryDir}\` (read or grep the topic files there for the full text of any entry). Its index:
128
+ <memory_index>
129
+ ${memoryIndex || "(empty — nothing has been saved to memory yet)"}
130
+ </memory_index>`;
131
+ }
132
+ // The deferred pool's index. Roughly forty tokens standing in for several hundred of
133
+ // schema, and it earns them: without it a deferred tool is indistinguishable from a
134
+ // missing feature, and the model routes around a capability it actually has.
135
+ const deferred = deferredToolsIndex();
136
+ if (deferred) {
137
+ prompt += `
138
+
139
+ ${deferred}`;
140
+ }
141
+ return prompt;
142
+ }
143
+ /**
144
+ * The volatile per-turn context, rendered at the TAIL of the request (outside the
145
+ * cacheable prefix): the ranked code map and the live task list. These change
146
+ * across steps/turns, so keeping them out of the system prompt is what lets the
147
+ * system + conversation prefix stay byte-stable and be served from the provider's
148
+ * prompt cache. Returns "" when there's nothing to add.
149
+ */
150
+ /**
151
+ * How many of the most recently touched files are checked for folder notes.
152
+ *
153
+ * Bounded because this reads disk every turn. The agent works in a handful of places
154
+ * at a time, and the files below this line are ones it has already moved on from.
155
+ */
156
+ const ACTIVE_FILES_FOR_NOTES = 20;
157
+ export function volatileContext(rules, planMode, sessionMemory, approvedPlan = "",
158
+ /** Notes for the folders being worked in right now (see memory/projectNotes.ts). */
159
+ directoryNotes = []) {
160
+ const parts = [];
161
+ // Standing rules FIRST in the volatile tail. They're rebuilt every turn here (not
162
+ // in the cached prefix), so a long conversation can never bury them — and they sit
163
+ // at the top of the freshest context the model reads before it acts. Binding by
164
+ // design: they override the model's own defaults.
165
+ if (rules) {
166
+ parts.push("The user's standing rules for this project. They are BINDING — follow them exactly, and let them " +
167
+ "override your own defaults and habits. Do not violate them or work around them:\n" +
168
+ `<rules>\n${rules}\n</rules>`);
169
+ }
170
+ // The approved plan is standing knowledge: rendered fresh here every request
171
+ // (never from the transcript), which is what makes it immune to compaction. It
172
+ // binds EXECUTION turns; while planning, the model is deliberately not anchored
173
+ // to the previous agreement — the artifact stays on disk if it wants history.
174
+ if (approvedPlan && !planMode) {
175
+ parts.push(approvedPlan);
176
+ }
177
+ // Plan mode (Architect) lives in the VOLATILE tail, not the cached prefix, so
178
+ // toggling it with shift-tab never invalidates the cached system prompt.
179
+ if (planMode) {
180
+ parts.push("You are in PLAN MODE (Architect). Research the codebase and think the change through; do NOT modify files, " +
181
+ "run commands, or take any action while planning — the editing tools are withheld until the plan is approved. " +
182
+ "Where a decision is genuinely the user's (which approach, which of two designs), ask with ask_user rather " +
183
+ "than choosing for them. " +
184
+ "When you know exactly what you would change, call exit_plan with the WHOLE plan in it. That is how planning " +
185
+ "ends: the user reads the plan there, and approving it starts the work immediately, in the same turn, with " +
186
+ "you following that plan. Do not write the plan out as an ordinary reply and stop — prose between steps is " +
187
+ "shortened before the user sees it, so a plan presented that way reaches them in pieces.");
188
+ }
189
+ // The maintained session state — first in the volatile tail so the model reads
190
+ // "here's where we are" before the map/task list. Survives compaction.
191
+ const memBlock = renderSessionMemory(sessionMemory);
192
+ if (memBlock)
193
+ parts.push(memBlock);
194
+ // Notes belonging to the FOLDERS currently in play. Volatile on purpose: they change
195
+ // as the agent moves around the repository, and folding them into the cached prefix
196
+ // would rewrite that prefix every time it opened a file in a new directory. Read
197
+ // after the session state and before the work, so the most specific standing facts
198
+ // are the last thing seen.
199
+ if (directoryNotes.length > 0) {
200
+ const notes = directoryNotes
201
+ .map((n) => `<notes for="${n.path}">\n${n.text}\n</notes>`)
202
+ .join("\n");
203
+ parts.push("Notes the project keeps for the folders you are working in. They apply to files in " +
204
+ "those folders and are as binding as the project's own notes:\n" +
205
+ notes);
206
+ }
207
+ // NO ranked code map and NO task list. Both were rebuilt and re-sent on every step,
208
+ // and both already exist somewhere cached:
209
+ //
210
+ // - the map is what the `relevant` tool returns, on demand, when the model wants it.
211
+ // Pushing it unasked also meant paying a chassis ranking call per turn for an
212
+ // answer the model had not asked for and often did not use.
213
+ // - the task list is the literal body of `todo_write`'s own tool result, which sits
214
+ // in the append-only conversation where the provider caches it.
215
+ //
216
+ // Re-sending either was buying a second copy of something already in context, at full
217
+ // price, once per step. A capability the model can reach for is not the same cost as a
218
+ // block it is handed continuously.
219
+ // NO working-files block. File contents live in the conversation as tool results,
220
+ // where the append-only shape lets the provider cache them. Re-sending them here cost
221
+ // up to 12K tokens on EVERY model call and could never be cached, because each step
222
+ // appends to the conversation ahead of this block — so no position within the tail
223
+ // could have saved it. See the note in the step loop.
224
+ // REPLY_STYLE is NOT pushed here any more. At 645 tokens it was the largest thing
225
+ // left in the tail and it was re-sent, uncached, on every step of every turn — ten
226
+ // steps meant paying for it ten times to govern ONE final message. It now lives in
227
+ // the system prompt, which is cached, and which is where the equivalent sits in every
228
+ // other agent that does this well.
229
+ //
230
+ // The comment above records that it was moved OUT of the prefix once because it was
231
+ // being ignored by turn three. That is a real observation and this reverses it, so if
232
+ // replies start sprawling again the answer is a short reassertion attached to
233
+ // something already in the conversation — not a 645-token block on every request.
234
+ return parts.join("\n\n");
235
+ }
236
+ function governancePrompt(session) {
237
+ const g = session.governance;
238
+ // Which glob-scoped rules have fired is decided when a path is TOUCHED, not here —
239
+ // see governor/scope.ts. This used to rebuild the whole working set on every model
240
+ // call and match every scoped rule against all of it, which is O(paths x rules) per
241
+ // step against a set that only ever grew.
242
+ const fired = session.toolContext.ruleScope?.matched ?? new Set();
243
+ return {
244
+ // A set lookup per rule. Rules render into the VOLATILE tail, which is rebuilt every
245
+ // step regardless, so this is now genuinely the free part it always claimed to be.
246
+ rules: renderRules(g.rules, fired),
247
+ forbidden: g.forbidden.patterns.map((p) => `- ${p}`).join("\n"),
248
+ forbiddenCommands: (g.forbidden.commands ?? []).map((c) => `- ${c}`).join("\n"),
249
+ // The skill catalog renders into the CACHED SYSTEM PROMPT, so it is deliberately
250
+ // NOT filtered by the working set. Filtering it there was a silent cache killer:
251
+ // a glob-scoped skill appears or disappears the moment the model reads a matching
252
+ // file, which changes the system prompt, which invalidates the tools, the system
253
+ // AND the whole conversation — the most expensive invalidation the API has. A turn
254
+ // that read one file could re-bill the entire prefix.
255
+ //
256
+ // Anthropic's own caching guidance names this exact shape: "conditional system
257
+ // sections — every flag combination is a distinct prefix." The glob filter was
258
+ // saving a few lines of catalog and paying for it with a full rebuild.
259
+ //
260
+ // Safe because the catalog is bounded by construction: at most MAX_SKILL_ENTRIES
261
+ // lines, each clipped to MAX_SKILL_LINE_CHARS. Unfiltered is bigger, and stable —
262
+ // and stable is what a cached prefix has to be.
263
+ skills: renderSkillCatalog(g.skills),
264
+ };
265
+ }
266
+ // The tiny, budgeted ranked map injected each turn (the "auto-map" half of the
267
+ // relevance feed). Personalized to the files recently read. A pure in-memory
268
+ // chassis query — no I/O, no model call — so the engine stays filesystem-pure.
269
+ /** A positive integer from the environment, or the fallback. */
270
+ function envInt(name, fallback) {
271
+ const v = Number(process.env[name]);
272
+ return Number.isInteger(v) && v > 0 ? v : fallback;
273
+ }
274
+ /** A boolean env flag. Default ON unless explicitly set to 0/false/off/no. */
275
+ function envFlag(name, fallback) {
276
+ const v = process.env[name];
277
+ if (v == null || v === "")
278
+ return fallback;
279
+ return !/^(0|false|off|no)$/i.test(v.trim());
280
+ }
281
+ /** A non-negative number from the environment, or the fallback. */
282
+ function envNum(name, fallback) {
283
+ const v = Number(process.env[name]);
284
+ return Number.isFinite(v) && v >= 0 ? v : fallback;
285
+ }
286
+ /** Per-task cost/time ceilings. OFF (0) by default — opt-in via env, so there are
287
+ * no surprise pauses; a runaway is one env var away from being capped. */
288
+ function taskLimits() {
289
+ return {
290
+ maxUsd: envNum("MINDWEAVE_MAX_TASK_USD", 0),
291
+ maxSeconds: envNum("MINDWEAVE_MAX_TASK_SECONDS", 0),
292
+ };
293
+ }
294
+ // Max model turns (tool rounds) in one reply. A generous ceiling: real multi-file
295
+ // work — a feature across a dozen files, a refactor — should finish in one go, so
296
+ // this is a circuit-breaker against a runaway loop, NOT a work limit. When it is
297
+ // hit the loop pauses LOSSLESSLY (the transcript, task list, and working set are
298
+ // intact) and hands the decision to continue back to the user, so it can never
299
+ // silently burn tokens. Env-overridable for power users.
300
+ const STEP_BUDGET = envInt("MINDWEAVE_STEP_BUDGET", 50);
301
+ // Verification gate: when the model edits files then tries to finish without
302
+ // running any check, nudge it once to verify. On by default; MINDWEAVE_VERIFY_GATE=0
303
+ // disables it. See verify.ts for the (pure, tested) fact detectors.
304
+ const VERIFY_GATE = envFlag("MINDWEAVE_VERIFY_GATE", true);
305
+ // Background-poll allowance: how many still-running background-shell polls the model
306
+ // may make in one turn before the loop stops it. A finished shell notifies the model
307
+ // automatically, so polling is redundant; one poll is allowed (a legitimate "grab the
308
+ // current tail" when the user asks), the wait-loop after that is stopped deterministically.
309
+ const BG_POLL_ALLOWANCE = envInt("MINDWEAVE_BG_POLL_LIMIT", 1);
310
+ // How many times in a row the model may fire a step that fails the SAME way before we
311
+ // stop it. 3 is the threshold: repeated identical failures past that are a stuck loop,
312
+ // not progress. Env-overridable for tuning.
313
+ const REPEAT_FAIL_LIMIT = envInt("MINDWEAVE_REPEAT_FAIL_LIMIT", 3);
314
+ /** True if an error is an AbortError (the model call was cancelled). */
315
+ function isAbort(error) {
316
+ return error instanceof Error && error.name === "AbortError";
317
+ }
318
+ /**
319
+ * Shape a thrown tool fault into a result the model can act on (pure).
320
+ *
321
+ * The MESSAGE only, never the stack. A stack is noise to the model, and it names
322
+ * absolute paths that would then live in the user's transcript and be re-sent to the
323
+ * provider on every later turn.
324
+ *
325
+ * It says whose fault it is on purpose. Told only that something failed, a model
326
+ * reliably assumes it called the tool wrongly and retries the identical call; naming
327
+ * the tool as the faulty party is what turns a loop into a change of approach.
328
+ */
329
+ export function toolFailureResult(name, error) {
330
+ const why = error instanceof Error && error.message ? error.message : String(error);
331
+ return {
332
+ output: `The ${name} tool failed unexpectedly: ${why}\n` +
333
+ `This is a fault in the tool, not in your request. Try a different approach, or tell the user what is not working.`,
334
+ summary: `${name} failed`,
335
+ isError: true,
336
+ };
337
+ }
338
+ /** Record and return a clean interrupted reply (well-formed transcript). */
339
+ function interrupted(session) {
340
+ const msg = "(interrupted)";
341
+ session.transcript.push({ role: "assistant", content: msg });
342
+ return msg;
343
+ }
344
+ /** Map our stored tool calls to the provider's wire shape. */
345
+ function toWire(calls) {
346
+ return calls.map((c) => ({
347
+ id: c.id,
348
+ type: "function",
349
+ function: { name: c.name, arguments: c.arguments },
350
+ // Carried through untouched. Core has no idea what is in here; a driver that needs
351
+ // it splices it back onto the wire call, and one that does not ignores it. Gemini
352
+ // rejects a follow-up whose call lost its `thought_signature`, so dropping this
353
+ // makes tool use fail outright rather than merely degrade.
354
+ ...(c.meta ? { meta: c.meta } : {}),
355
+ }));
356
+ }
357
+ /** The labeled root list for the prompt — "" for an ordinary single-root session. */
358
+ function workspaceText(session) {
359
+ const roots = session.toolContext.roots ?? [];
360
+ if (roots.length <= 1)
361
+ return "";
362
+ return roots.map((r) => `- ${rootLabel(roots, r)} → ${r}`).join("\n");
363
+ }
364
+ /**
365
+ * Build the provider-agnostic request from a session. The split is deliberate and
366
+ * is what makes prompt caching work on every model (see ModelRequest):
367
+ * - `system` — the STABLE system prompt (identity, tools guidance, governance,
368
+ * project facts). Same bytes every step → cached prefix.
369
+ * - `messages` — the conversation, append-only, plus any one-shot background-shell
370
+ * notes for this turn (transient — never stored, so they can't
371
+ * re-inject).
372
+ * - `context` — the VOLATILE per-turn map + task list, rendered at the tail so it
373
+ * never invalidates the cached prefix.
374
+ */
375
+ /**
376
+ * Read the bytes for every image still live in the transcript, keyed by path.
377
+ *
378
+ * Bytes are loaded HERE, once per turn, rather than stored in the transcript or read
379
+ * by each driver. That keeps the session file small, keeps drivers off the filesystem
380
+ * (they format, they don't fetch), and means the caps and validation live in one place.
381
+ * A file that has since been deleted or become unreadable is simply absent from the
382
+ * map; `buildRequest` turns that into a line the model can read, never a crash.
383
+ */
384
+ /** Can the model currently selected actually look at a picture? A manifest FACT, asked
385
+ * in one place — the same rule the screenshot path already follows. */
386
+ function modelSeesImages(session) {
387
+ return manifestForModel(session.modelConfig.model).acceptsImages?.(session.modelConfig.model) ?? false;
388
+ }
389
+ async function loadImagePayloads(session) {
390
+ // Nothing to load for a model that cannot look at one. This is the /provider switch
391
+ // case: a picture attached while a vision model was running stays in the transcript,
392
+ // and without this it was re-encoded and re-sent on every request to a text-only model
393
+ // that will not read it — measured, and it goes out as an `image_url` part that a
394
+ // text-only endpoint is entitled to reject outright.
395
+ if (!modelSeesImages(session))
396
+ return new Map();
397
+ const paths = new Set();
398
+ for (const e of session.transcript) {
399
+ if (e.role === "user" && e.images)
400
+ for (const img of e.images)
401
+ paths.add(img.path);
402
+ }
403
+ const out = new Map();
404
+ await Promise.all([...paths].map(async (p) => {
405
+ try {
406
+ out.set(p, (await fsp.readFile(p)).toString("base64"));
407
+ }
408
+ catch {
409
+ // Gone or unreadable — deliberately left out of the map.
410
+ }
411
+ }));
412
+ return out;
413
+ }
414
+ function buildRequest(session, bgEvents, tools, imagePayloads = new Map(),
415
+ /** Notes for the folders in play, resolved by the caller (it has to read disk). */
416
+ directoryNotes = []) {
417
+ const canSeeImages = modelSeesImages(session);
418
+ const messages = [];
419
+ for (const e of session.transcript) {
420
+ if (e.role === "user" || e.role === "summary") {
421
+ // Attached images ride with the message, but only while their payload is still
422
+ // live: microcompaction drops the refs once the turn is old, and a file deleted
423
+ // since it was attached simply isn't in the payload map. Either way the model is
424
+ // TOLD rather than quietly handed a message that claims an image it cannot see.
425
+ const refs = e.role === "user" ? e.images : undefined;
426
+ if (refs && refs.length > 0) {
427
+ const images = [];
428
+ const missing = [];
429
+ const unseen = [];
430
+ for (const ref of refs) {
431
+ // Told, never silently dropped. A message that mentions a screenshot and
432
+ // carries nothing reads to the model as a picture it failed to notice; the
433
+ // reason it cannot see it is the one thing that makes the message sensible.
434
+ if (!canSeeImages) {
435
+ unseen.push(basename(ref.path));
436
+ continue;
437
+ }
438
+ const data = imagePayloads.get(ref.path);
439
+ if (data)
440
+ images.push({ path: ref.path, mediaType: ref.mediaType, data });
441
+ else
442
+ missing.push(basename(ref.path));
443
+ }
444
+ const notes = [
445
+ ...(unseen.length > 0
446
+ ? [`${unseen.join(", ")} was attached, but the model now running cannot see images`]
447
+ : []),
448
+ ...(missing.length > 0 ? [`${missing.join(", ")} could not be read from disk`] : []),
449
+ ];
450
+ const content = notes.length > 0 ? `${e.content}
451
+
452
+ [${notes.join("; ")}]` : e.content;
453
+ messages.push({ role: "user", content, ...(images.length > 0 ? { images } : {}) });
454
+ continue;
455
+ }
456
+ messages.push({ role: "user", content: e.content });
457
+ }
458
+ else if (e.role === "assistant") {
459
+ messages.push({
460
+ role: "assistant",
461
+ content: e.content,
462
+ ...(e.toolCalls && e.toolCalls.length > 0 ? { tool_calls: toWire(e.toolCalls) } : {}),
463
+ });
464
+ }
465
+ else {
466
+ messages.push({ role: "tool", tool_call_id: e.toolCallId, content: e.content });
467
+ }
468
+ }
469
+ for (const note of bgEvents)
470
+ messages.push({ role: "user", content: note });
471
+ // Compute the governance blocks once: the prefix uses forbidden/skills, the
472
+ // volatile tail uses the rules (moved there for salience — see volatileContext).
473
+ const gov = governancePrompt(session);
474
+ return {
475
+ system: staticSystemPrompt(session.projectContext, session.projectMemory, session.memoryDir, session.memoryIndex, gov, workspaceText(session), session.priorSessions),
476
+ messages,
477
+ context: volatileContext(gov.rules, session.toolContext.planMode ?? false, session.sessionMemory ?? "", session.toolContext.activePlan
478
+ ? renderPlanBlock({
479
+ plan: session.toolContext.activePlan,
480
+ approvedAt: session.toolContext.activePlanApprovedAt ?? "",
481
+ mode: "lightning",
482
+ })
483
+ : "", directoryNotes),
484
+ tools,
485
+ model: session.modelConfig,
486
+ };
487
+ }
488
+ /**
489
+ * Collect one-shot notes for background shells that finished since the last turn.
490
+ * Drained ONCE here (the manager marks them reported), so the model is told exactly
491
+ * once — never the re-injecting-forever leak that plagues other agents.
492
+ */
493
+ async function backgroundEventNotes(session) {
494
+ const mgr = session.toolContext.backgroundShells;
495
+ if (!mgr)
496
+ return [];
497
+ const events = await mgr.drainEvents();
498
+ return events.map(({ info, kind, tail, wake }) => {
499
+ // It came up. This is the only positive event a server ever produces, and it is
500
+ // what lets the model actually deliver the "I'll tell you when it's running" it
501
+ // was told to say. Nothing has gone wrong, so there is nothing to fix.
502
+ if (kind === "ready") {
503
+ return (`[Background shell #${info.id} (\`${info.command}\`) is up and running.]\n` +
504
+ `Recent output:\n${tail || "(no output)"}\n\n` +
505
+ `Tell the user in one short line that it's running. Nothing is wrong — do not investigate, ` +
506
+ `do not restart it, and do not change any files because of this.`);
507
+ }
508
+ const status = info.status === "killed"
509
+ ? info.stoppedBy === "user"
510
+ ? "was stopped by the user"
511
+ : "was killed"
512
+ : `finished with exit code ${info.exitCode}`;
513
+ // An ending that is NOT worth interrupting for still arrives, so the model knows the
514
+ // thing is down and can answer about it. It is explicitly not a task: this is the
515
+ // path a user closing their own app takes, and treating it as news is what made the
516
+ // agent reopen it.
517
+ if (!wake) {
518
+ return (`[Background shell #${info.id} (\`${info.command}\`) ${status}. It had already started up, so ` +
519
+ `this is the user stopping their own app, not a failure.]\n` +
520
+ `This is background information only. Do NOT mention it unless it is relevant, do NOT restart ` +
521
+ `it, and do NOT change any files because of it. If the user later asks about this app, you now ` +
522
+ `know it is stopped.`);
523
+ }
524
+ // For a server, only a failure to come up reaches here: a normal stop does not wake.
525
+ const guidance = info.notify === "on_failure"
526
+ ? "This is a server or app that never came up, so the user never saw it running. Tell them what happened and offer to fix it — but do not restart it repeatedly on your own."
527
+ : "If it failed, tell the user briefly what went wrong and propose a fix — don't change files unless they agree.";
528
+ return (`[Background shell #${info.id} (\`${info.command}\`) ${status}.]\n` +
529
+ `Recent output:\n${tail || "(no output)"}\n\n` +
530
+ guidance);
531
+ });
532
+ }
533
+ /**
534
+ * Produce Mindweave's next reply for the latest user message already on
535
+ * `session.transcript`. Appends the assistant/tool turns it generates and
536
+ * returns the final assistant text.
537
+ */
538
+ /**
539
+ * Whether a tool call may run in the PARALLEL lane (pure — unit-tested). A tool's
540
+ * per-args `isConcurrencySafe` wins when present (e.g. a read-only sub-agent is safe
541
+ * to fan out, an editing one is not); otherwise the default is read-only ⇒ safe.
542
+ */
543
+ export function callIsConcurrencySafe(tool, args) {
544
+ return tool.isConcurrencySafe ? tool.isConcurrencySafe(args) : tool.readOnly;
545
+ }
546
+ /**
547
+ * Re-read the governor if its files changed on disk, or if `force` says to regardless.
548
+ *
549
+ * Two triggers, and they cover different failures. The STAT check catches a person
550
+ * editing a rule in their editor mid-session — the common case, and the one that used
551
+ * to do nothing at all until restart. The FORCED reload runs after a compaction, which
552
+ * is the natural moment for it: the prompt is being rebuilt from scratch anyway, so it
553
+ * is the point to rebuild what the prompt is made of, and it costs one directory read
554
+ * on an operation that just made a model call.
555
+ *
556
+ * Degrade-safe. Governance is a convenience layer over files that may be mid-write, and
557
+ * an unreadable rules directory must not take the turn down with it — on any failure the
558
+ * session simply keeps the governance it already had.
559
+ */
560
+ async function refreshGovernance(session, force = false) {
561
+ try {
562
+ const stamp = await governanceStamp(session.toolContext.cwd);
563
+ // Skipping when the stamp is unchanged is what keeps our OWN writes from causing a
564
+ // reload storm: a governor tool writes the file and mirrors the change into the live
565
+ // object, so the next turn sees a new stamp, reloads once, and reads back exactly
566
+ // what it already had. Cheap and idempotent, but only once.
567
+ if (!force && stamp === session.governanceStamp)
568
+ return;
569
+ const fresh = await reloadGovernance(session.toolContext.cwd, session.governance);
570
+ session.governance = fresh;
571
+ session.toolContext.governance = fresh;
572
+ session.governanceStamp = stamp;
573
+ // A rule that did not exist when a path was touched never got its chance to fire,
574
+ // so the remembered paths are re-judged against the new list. Additive — a rule
575
+ // already fired stays fired, and one deleted from disk stops rendering because
576
+ // rendering filters by the live rule list.
577
+ if (session.toolContext.ruleScope)
578
+ rescope(session.toolContext.ruleScope, fresh.rules);
579
+ // The MCP deny-list is pushed into the manager rather than read from governance, so
580
+ // it has to be re-pushed or a tool the user just forbade stays advertised.
581
+ session.toolContext.mcp?.setForbidden(fresh.forbidden.mcpTools ?? []);
582
+ }
583
+ catch {
584
+ // Keep what we have. See the note above.
585
+ }
586
+ }
587
+ /**
588
+ * Run one turn, and put planning back afterwards if an approved plan left it.
589
+ *
590
+ * The restore is in a `finally` rather than at the end of the turn because approval
591
+ * grants ONE turn of doing however that turn ends — a step-budget pause, an error, or
592
+ * Esc all have to come back to planning. Leaving it off would strand the session in a
593
+ * mode it was put into by a tool call rather than by the user, and the next request
594
+ * would run unplanned.
595
+ */
596
+ export async function respond(session, options = {}) {
597
+ const finished = await respondTurn(session, options);
598
+ // An approved plan ends when the turn that was carrying it out ends of its own
599
+ // accord — which covers both ways the agreement can finish. Either every step is
600
+ // done, or the model hit something the plan did not survive and stopped to say so,
601
+ // exactly as the plan contract tells it to. In both cases the agreement is spent,
602
+ // and leaving it active is what made a plan approved once bind every later session.
603
+ //
604
+ // An INTERRUPTED turn is the one case that keeps it: the work was cut off rather
605
+ // than concluded, so the next turn should pick it up where it stopped.
606
+ await settlePlanIfFinished(session, options);
607
+ return finished;
608
+ }
609
+ /**
610
+ * The one message a freshly-cleared session starts from.
611
+ *
612
+ * The plan is repeated in full because it is now the ONLY instruction: the discussion
613
+ * that produced it is gone. The session file is named alongside it so nothing is
614
+ * actually lost — a model that needs an exact snippet or an error string from the
615
+ * planning phase can go and read it, which is cheaper than having carried the whole
616
+ * investigation forward on every request just in case.
617
+ */
618
+ function implementFromScratch(priorPath, plan) {
619
+ const path = priorPath;
620
+ const where = path
621
+ ? `
622
+
623
+ If you need something exact from the planning that produced this — a snippet, an ` +
624
+ `error message, a path — the full conversation is at: ${path}`
625
+ : "";
626
+ return `Implement the following plan:
627
+
628
+ ${plan}${where}`;
629
+ }
630
+ /**
631
+ * Mark an approved plan complete once its work turn has ended.
632
+ *
633
+ * Nothing used to do this. `completePlanArtifact` existed, worked and was tested, and
634
+ * had no caller outside its own test — so `.mindweave/plan.md` stayed active forever,
635
+ * every later session loaded it, and its binding block was injected into every request
636
+ * of unrelated work months later. The suite stayed green because the test proved the
637
+ * function worked, never that anything called it.
638
+ *
639
+ * Degrade-safe: a plan that cannot be marked done is left alone rather than dropped
640
+ * from memory, because the in-memory copy is what governs the current work.
641
+ */
642
+ async function settlePlanIfFinished(session, options) {
643
+ if (!session.toolContext.activePlan)
644
+ return;
645
+ if (session.toolContext.planMode)
646
+ return; // still planning: nothing is being carried out
647
+ if (options.signal?.aborted)
648
+ return; // cut off, not concluded — the next turn continues it
649
+ session.toolContext.activePlan = "";
650
+ session.toolContext.activePlanApprovedAt = undefined;
651
+ try {
652
+ await completePlanArtifact(session.toolContext.roots?.[0] ?? session.cwd);
653
+ }
654
+ catch {
655
+ // The file stays active; the session no longer injects it either way.
656
+ }
657
+ }
658
+ async function respondTurn(session, options = {}) {
659
+ // Make sure the provider serving the selected model is loaded before anything
660
+ // in this turn reaches for it. Cached after the first call, so this is free on
661
+ // every subsequent turn, and it keeps `activeDriver()` safe to call synchronously
662
+ // from here down (including from inside a tool).
663
+ await ensureDriver(session.modelConfig.model);
664
+ // Pick up a governance file the USER edited by hand since the last turn. One stat
665
+ // pass over a few small directories, taken here because a turn is the only moment
666
+ // governance is consulted — so it is fresh exactly where it is used, with no watcher
667
+ // to own, poll or tear down. See refreshGovernance.
668
+ await refreshGovernance(session);
669
+ // Resume an approved plan from disk, once per session (undefined = unchecked).
670
+ // A plan approved last session is still the agreed scope this session — that is
671
+ // the point of it being an artifact — and the user deleting the file (or its
672
+ // status flipping) is a complete off switch, honored here by loading nothing.
673
+ if (session.toolContext.activePlan === undefined) {
674
+ const artifact = await loadPlanArtifact(session.cwd).catch(() => null);
675
+ session.toolContext.activePlan = artifact?.plan ?? "";
676
+ session.toolContext.activePlanApprovedAt = artifact?.approvedAt;
677
+ }
678
+ const planMode = session.toolContext.planMode ?? false;
679
+ // Built-in tools plus whatever the connected MCP servers offer. An MCP tool is
680
+ // dispatched, displayed and gated by exactly the same machinery as a built-in — the
681
+ // merge here and the lookup fallback below are the entire integration.
682
+ let readOnlyTurn = planMode || session.toolContext.readOnlyTools === true;
683
+ // ONE frozen view of the MCP catalog for the whole turn, used for BOTH the advertised
684
+ // list and dispatch. Reading live state twice let a server die (or announce a changed
685
+ // tool list) between the two, so the model could be refused a tool we had just told it
686
+ // it had. It also pins the exact `tools` bytes across the turn's steps, which is what
687
+ // keeps the provider's cached prefix intact while the tool loop runs.
688
+ let mcpTurn = session.toolContext.mcp?.snapshot(readOnlyTurn);
689
+ // Recomputed PER STEP, not once per turn: a large catalog is held behind
690
+ // `find_mcp_tools`, and a tool the model just searched for has to be callable on the
691
+ // very next step or the search was a lie. When nothing is deferred (the common case)
692
+ // this returns identical bytes every step, so the cached prefix is untouched.
693
+ // Rebuilt per step rather than once per turn, because an approved plan LIFTS plan
694
+ // mode mid-turn and the model has to receive the tools it was just granted. When
695
+ // nothing changes this returns identical bytes every step, so the provider's cached
696
+ // prefix is untouched — the same argument that already applies to deferred MCP tools.
697
+ const stepTools = () => {
698
+ const ro = (session.toolContext.planMode ?? false) || session.toolContext.readOnlyTools === true;
699
+ if (ro !== readOnlyTurn) {
700
+ // The MCP catalog is re-snapshotted too, or approving a plan would grant the
701
+ // built-in editing tools while leaving every MCP action hidden until next turn.
702
+ readOnlyTurn = ro;
703
+ mcpTurn = session.toolContext.mcp?.snapshot(ro);
704
+ }
705
+ return [
706
+ ...toolSchemas({
707
+ planMode: session.toolContext.planMode ?? false,
708
+ readOnlyOnly: session.toolContext.readOnlyTools,
709
+ // Lets `relevantWhen` tools (use_skill) check the live session, so a tool with
710
+ // nothing to act on is not advertised and a skill created mid-session brings
711
+ // it back next turn.
712
+ ctx: session.toolContext,
713
+ }),
714
+ ...(mcpTurn?.exposedSchemas() ?? []),
715
+ ];
716
+ };
717
+ const lookup = (name) => findTool(name) ?? mcpTurn?.asTool(name);
718
+ const stepLimit = options.maxSteps ?? STEP_BUDGET;
719
+ // Sinks the spawn_subagent tool reuses (it only ever gets the ToolContext, not the
720
+ // Session): fork a scoped child, forward the child's usage to this turn's meter,
721
+ // and share this turn's abort signal so Esc stops a sub-agent too.
722
+ session.toolContext.forkChild = (task, opts) => forkSession(session, task, opts);
723
+ session.toolContext.reportUsage = (u) => options.onEvent?.({ type: "usage", ...u });
724
+ // The raw event sink, so spawn_subagent can surface a child's nested activity
725
+ // (its lifecycle + tagged tool calls) up this same stream instead of running dark.
726
+ session.toolContext.emitEvent = options.onEvent;
727
+ session.toolContext.abortSignal = options.signal;
728
+ // WORKING-DIRECTORY RESET. Each turn starts at the project root — the working
729
+ // directory is already set to the correct project directory automatically. Within a
730
+ // turn cd still persists (so a multi-step command sequence works), but it never
731
+ // carries a stale `cd` into the next
732
+ // turn — the bug where `cd src-tauri` run in two turns became `…/src-tauri/src-tauri`.
733
+ // The primary root (session.cwd) is fixed; only toolContext.cwd moves.
734
+ session.toolContext.cwd = session.cwd;
735
+ // TASK-BOUNDARY SWEEP. If the previous turn finished a task (a todo list completed)
736
+ // and this new message opens a DIFFERENT one (not a "continue"), close the finished
737
+ // task out now — sweep its tool results and status recaps down hard — so a weaker
738
+ // model can't drift back to already-done work. This is the fix for "the model went
739
+ // back to a task from 6 turns ago." Cheap (no model call); the live working set keeps
740
+ // current file content regardless.
741
+ if (session.taskJustCompleted && !isContinuation(lastUserText(session))) {
742
+ const swept = microcompact(session.transcript, KEEP_LAST_N_BOUNDARY);
743
+ if (swept.cleared > 0 || swept.recapsCleared > 0) {
744
+ session.transcript = swept.entries;
745
+ // Silent by design — closing out a finished task is background housekeeping, not
746
+ // something the user should watch scroll by.
747
+ }
748
+ }
749
+ session.taskJustCompleted = false;
750
+ // SESSION MEMORY. At a natural break (turn start), if the transcript has grown enough
751
+ // since the last refresh, update the maintained "state of this session" notes. They
752
+ // live outside the transcript, so compaction never erodes them — which is what lets a
753
+ // session run indefinitely without slowly losing the thread. One cheap call, gated so
754
+ // it fires rarely; degrade-safe.
755
+ await sweepSessionMemory(session, options);
756
+ // Background shells that finished since last turn — surfaced to the model once.
757
+ const bgEvents = await backgroundEventNotes(session);
758
+ // Per-task guards: cost/time ceilings (opt-in) alongside the step budget. Every
759
+ // call's usage is summed so the ceiling reflects the whole task.
760
+ const limits = taskLimits();
761
+ const startedAt = Date.now();
762
+ const usages = [];
763
+ // Verification-gate bookkeeping for this turn: did the model change any file,
764
+ // did it ever run a check, and have we already nudged once (one-shot).
765
+ let mutatedThisTurn = false;
766
+ let verifiedThisTurn = false;
767
+ let verifyNudged = false;
768
+ // Re-scope guard: once the model completes a WHOLE todo list, spinning up a
769
+ // fresh one and pressing on within the same turn is self-assigned scope the user
770
+ // never asked for (the "did the task three times" runaway). This flips true when
771
+ // a todo list is fully completed; a new pending list afterward triggers a pause.
772
+ let completedAList = false;
773
+ // Background-poll guard: consecutive steps that did nothing but poll a still-running
774
+ // background shell. Once past the allowance, stop the wait-loop (the model won't
775
+ // stop on the prose nudge alone). Any step that does real work resets it to 0.
776
+ let bgPollStreak = 0;
777
+ // Repeat-failure breaker: consecutive steps that failed the SAME way (identical error
778
+ // signature). A model can grind the same broken command for dozens of steps; once the
779
+ // streak crosses REPEAT_FAIL_LIMIT we interrupt with the fact that it is repeating
780
+ // itself, and only stop the turn if it does it again afterwards. `repeatFailNudged`
781
+ // resets whenever the failure changes, so each distinct loop gets one interrupt.
782
+ // Overflow recovery fires at most once per turn — see the overflow branch below.
783
+ let overflowRecovered = false;
784
+ let repeatFailStreak = 0;
785
+ let repeatFailNudged = false;
786
+ // Single edits per file across the whole turn, and whether the batching reminder has
787
+ // already fired. One reminder per turn: it is a nudge, not a rule to enforce twice.
788
+ const singleEditsByFile = new Map();
789
+ let batchEditNudged = false;
790
+ // Narration budget: one nudge per turn, and the turn's earlier prose to compare against.
791
+ let narrationNudged = false;
792
+ const narratedBefore = [];
793
+ // Judged next to the prose, pushed after the tool results — see the gate below.
794
+ let pendingNarrationFault = null;
795
+ let lastFailSig = null;
796
+ let lastFailOutput = "";
797
+ // Reply gate: ONE rewrite per turn. `overlongReplyAt` is where the rejected draft sits
798
+ // in the transcript, so it and its instruction can be spliced back out once the
799
+ // rewrite lands — history should hold what the user actually saw, not the draft.
800
+ let replyRegated = false;
801
+ let overlongReplyAt = null;
802
+ // Seal whatever files this turn edits into one restorable checkpoint (/undo),
803
+ // no matter how the turn ends (finish, pause, interrupt, throw). Labeled with
804
+ // the request that drove it. No-op when nothing was edited.
805
+ const turnLabel = lastUserText(session);
806
+ // Fold this turn's cost into the session total on the way out, however the turn ends.
807
+ // In the `finally` rather than the success path on purpose: an interrupted or failed
808
+ // turn still spent the tokens it spent, and a spend figure that quietly omits the
809
+ // expensive turn you cancelled is worse than none. Undefined when nothing was billed.
810
+ const recordSpend = () => {
811
+ const summary = summarizeTask(usages, session.modelConfig.model);
812
+ if (summary)
813
+ session.spend = addTurn(session.spend ?? emptySpend(), summary);
814
+ // Keep the PER-CALL split, not just the turn's totals.
815
+ //
816
+ // Because "where did those tokens go?" is the question that keeps getting asked, and
817
+ // a session total cannot answer it. A turn that billed 36K is six calls or one, and
818
+ // a provider that cached 40% did so evenly across every call or completely on three
819
+ // of them — those are different problems with different fixes, and the totals look
820
+ // identical for all of them. Six numbers per call, capped, so a long session cannot
821
+ // grow the meta file without bound.
822
+ session.callLog = [...(session.callLog ?? []), ...usages.map((u) => toCallRecord(u, session.modelConfig.model))].slice(-CALL_LOG_LIMIT);
823
+ };
824
+ try {
825
+ const reply = await runTurn();
826
+ // END-OF-TURN sweep. The turn-start check above works one turn behind: it can only
827
+ // see what happened before this turn ran, so a session whose LAST turn did the real
828
+ // work ended with notes that never mentioned it (or none at all). Sweeping here is
829
+ // the "write a note before the session can end" fix, without needing a process-exit
830
+ // hook — a turn boundary is the only moment we reliably get. The token gate means
831
+ // this and the turn-start check can never both fire for the same growth.
832
+ //
833
+ // Deliberately NOT in the `finally`: that path also runs on abort and on throw, and
834
+ // a user pressing Esc should not be charged for a background model call.
835
+ if (!options.signal?.aborted)
836
+ await sweepSessionMemory(session, options);
837
+ return reply;
838
+ }
839
+ finally {
840
+ recordSpend();
841
+ const before = session.toolContext.checkpoints?.list().length ?? 0;
842
+ session.toolContext.checkpoints?.seal(turnLabel);
843
+ // Say that a restore point exists. It was made silently, so `/undo` was a feature
844
+ // you had to already know about — and the moment to learn it is the moment there is
845
+ // something to undo, not after you have lost it.
846
+ }
847
+ // The turn's model↔tool loop. Kept as a closure so the try/finally above owns
848
+ // every exit path; it reads the flags/usages declared in the enclosing scope.
849
+ async function runTurn() {
850
+ for (let step = 0; step < stepLimit; step++) {
851
+ if (options.signal?.aborted)
852
+ return interrupted(session);
853
+ // Stop before another (billable) call if a cost/time ceiling is hit — pause
854
+ // losslessly, exactly like the step budget, so the user can raise it and resume.
855
+ const limitReason = taskLimitReason(summarizeTask(usages, session.modelConfig.model), Date.now() - startedAt, limits);
856
+ if (limitReason)
857
+ return pauseTask(session, options, `hit the ${limitReason}`);
858
+ await maybeCompact(session, options);
859
+ // NO working-set block is built or sent. It used to be: the current contents of
860
+ // every active file, rebuilt each step and injected at the tail — up to 12K tokens
861
+ // re-sent, uncached, on EVERY model call. Nothing about where it sat in the request
862
+ // could fix that, because content is appended to the conversation before it on every
863
+ // step, so prefix caching can never reach it. An eight-step turn paid for it eight
864
+ // times; a forty-step task would pay forty.
865
+ //
866
+ // File contents reach the model the same way every other observation does: as a tool
867
+ // result in the conversation, once, where the append-only shape means the provider
868
+ // caches it and it is never re-billed. Freshness after an edit is a RE-READ problem
869
+ // (read_file returns full content whenever mtime/size moved) rather than a reason to
870
+ // re-send everything continuously.
871
+ //
872
+ // `workingSetFull` / `workingSetSpans` are deliberately left UNSET. Every consumer
873
+ // reads them with `?.`, so they all degrade to "the model has not been shown this",
874
+ // which is now the truth. Leaving them populated would make read_file tell the model
875
+ // a file is already on screen when nothing put it there.
876
+ // The other half of "what can the model still see": full reads still sitting in the
877
+ // transcript. Derived here, AFTER any compaction above, so it can never disagree
878
+ // with the bytes this step is about to send. This is what makes a stored presence
879
+ // bit — and the ledger surgery that used to keep one honest — unnecessary.
880
+ session.toolContext.transcriptFull = fullReadPaths(session.transcript, (p) => {
881
+ try {
882
+ return resolvePath(session.toolContext, p);
883
+ }
884
+ catch {
885
+ return undefined;
886
+ }
887
+ });
888
+ let result;
889
+ // The transcript half of what we are about to send, measured the same way the
890
+ // compaction bars measure it — so the provider's reported total minus this is the
891
+ // real size of everything else in the prompt.
892
+ const sentTranscriptTokens = estimateEntriesTokens(session.transcript);
893
+ // Every root, not just the primary: a file in a folder added with /include should
894
+ // pick up that folder's notes the same way one in the main project does. Deduped by
895
+ // path, because roots can nest.
896
+ const active = selectActiveFiles(session.toolContext.reads, ACTIVE_FILES_FOR_NOTES).map((a) => a.path);
897
+ const seenNotes = new Set();
898
+ const dirNotes = [];
899
+ for (const root of rootsOf(session.toolContext)) {
900
+ for (const note of await directoryNotesFor(root, active)) {
901
+ if (seenNotes.has(note.path))
902
+ continue;
903
+ seenNotes.add(note.path);
904
+ dirNotes.push(note);
905
+ }
906
+ }
907
+ const request = buildRequest(session, bgEvents, stepTools(), await loadImagePayloads(session), dirNotes);
908
+ // Did the cacheable prefix survive since the last call? A break re-bills the system
909
+ // prompt and every tool schema at full price, silently — nothing fails, the reply is
910
+ // normal, and the only evidence is the bill. Reported so an UNEXPLAINED one is
911
+ // visible while it is happening, instead of being reconstructed from a session file
912
+ // after the user has paid for it. See dynamo/cacheBreak.ts.
913
+ const print = prefixPrint(session.modelConfig.model, request.system, request.tools ?? [], request.messages);
914
+ const broke = session.prefixPrint ? diffPrefix(session.prefixPrint, print) : null;
915
+ session.prefixPrint = print;
916
+ // Shed the oldest whole rounds and retry, ONCE per turn. Shared by both ways a
917
+ // provider can refuse an over-long conversation, because the remedy is identical
918
+ // and having two copies of it is how they drift apart.
919
+ //
920
+ // Whole rounds, because a round is the only split the wire format guarantees is
921
+ // safe: every tool result is resolved before the next assistant turn, so a group
922
+ // starting at an assistant carries its own results. Cutting by entry count can
923
+ // sever a call from its result and turn a request that was merely too long into
924
+ // one that is malformed.
925
+ const shedAndRetry = async () => {
926
+ if (overflowRecovered)
927
+ return false;
928
+ const shed = dropOldestRounds(session.transcript);
929
+ if (!shed)
930
+ return false;
931
+ overflowRecovered = true;
932
+ session.transcript = shed;
933
+ await options.persist?.();
934
+ options.onActivity?.("conversation was too long — dropped the oldest turns and retried", {
935
+ context: true,
936
+ });
937
+ return true;
938
+ };
939
+ try {
940
+ // Stamped BEFORE the call, not after: what matters for the cache is when the
941
+ // request was sent, and a long-running turn would otherwise make the gap look
942
+ // shorter than it was.
943
+ session.lastCallAt = Date.now();
944
+ result = await streamModel(request, options);
945
+ }
946
+ catch (error) {
947
+ if (isAbort(error))
948
+ return interrupted(session);
949
+ // The other half of overflow, and the half that used to be fatal. Two of the
950
+ // thirteen providers report an over-long conversation as a finish reason on a
951
+ // successful response, which the branch below already recovers. Every other one
952
+ // REJECTS the request, and a rejection arrives here as a thrown error that
953
+ // `providerError.ts` rightly treats as our bug and surfaces loudly. For length
954
+ // specifically it is not our bug and it is recoverable, so it gets the same
955
+ // remedy rather than ending the turn. See drivers/contextOverflow.ts.
956
+ if (isContextOverflowError(error) && (await shedAndRetry()))
957
+ continue;
958
+ throw error;
959
+ }
960
+ const { content, toolCalls } = result;
961
+ // Every model call's usage counts toward the task total — a task (one turn)
962
+ // may span several calls across tool rounds, and the UI sums them.
963
+ emitUsage(result, options);
964
+ if (result.usage) {
965
+ usages.push(result.usage);
966
+ // Measure, don't guess. The provider just told us exactly how big the prompt was;
967
+ // subtracting the transcript we measured on the way out leaves the fixed overhead
968
+ // the bars were blind to. Recomputed every call, so it tracks a growing tool
969
+ // catalog or working set instead of being a constant someone chose once.
970
+ if (result.usage.promptTokens > 0) {
971
+ session.contextOverhead = {
972
+ tokens: measuredOverhead(result.usage.promptTokens, sentTranscriptTokens),
973
+ model: session.modelConfig.model,
974
+ };
975
+ }
976
+ }
977
+ // The provider can end a turn for reasons that are NOT "finished answering".
978
+ // Without checking, a reply cut off at the output ceiling looks identical to a
979
+ // complete one and the loop carries on with half an answer.
980
+ if (result.stop && result.stop !== "end") {
981
+ // Overflow is RECOVERABLE, and used not to be. The provider said the conversation
982
+ // no longer fits; the turn then ended and the user was told to compact by hand,
983
+ // mid-task, having already paid for the refused call. Shedding the oldest rounds
984
+ // makes it a hiccup instead of a stop. Once per turn: if it is still too long
985
+ // afterwards, retrying again is a loop and autocompact is the right instrument.
986
+ if (result.stop === "overflow" && (await shedAndRetry()))
987
+ continue;
988
+ const note = stopReasonNote(result.stop);
989
+ if (content.trim())
990
+ session.transcript.push({ role: "assistant", content });
991
+ await options.persist?.();
992
+ return pauseTask(session, options, note);
993
+ }
994
+ // No tool calls → the model is done. Record the reply.
995
+ if (toolCalls.length === 0) {
996
+ session.transcript.push({ role: "assistant", content });
997
+ await options.persist?.(); // durable: the reply is on disk before we return
998
+ // Verification gate: it edited files but never checked them. Nudge once and
999
+ // let it continue — a fact-based reminder, not a decision about the code.
1000
+ // Live, not the value captured at the top: an approved plan lifts plan mode
1001
+ // mid-turn, and the work that follows has to be verified like any other.
1002
+ if (VERIFY_GATE && !session.toolContext.planMode && mutatedThisTurn && !verifiedThisTurn && !verifyNudged) {
1003
+ verifyNudged = true;
1004
+ session.transcript.push({ role: "user", content: VERIFY_NUDGE, synthetic: true });
1005
+ continue;
1006
+ }
1007
+ // Reply gate. The prompt has asked for this budget in three wordings and a model
1008
+ // mid-flow still answers a finished job with a page, so here it is enforced rather
1009
+ // than requested: the draft is rejected, the model rewrites it, and the rewrite is
1010
+ // what the user sees. ONE retry — a gate that can fire twice is a loop.
1011
+ if (!replyRegated) {
1012
+ const fault = replyFault(content, mutatedThisTurn);
1013
+ if (fault) {
1014
+ replyRegated = true;
1015
+ overlongReplyAt = session.transcript.length - 1; // the draft pushed just above
1016
+ session.transcript.push({ role: "user", content: replyRewrite(fault), synthetic: true });
1017
+ // The draft has been streaming into the UI's buffer, unrendered. Drop it, or
1018
+ // the rewrite would append to it and the user would read both.
1019
+ options.onEvent?.({ type: "replyReset" });
1020
+ continue;
1021
+ }
1022
+ }
1023
+ // The rewrite landed. Drop the rejected draft and its instruction so what is saved
1024
+ // (and resumed, and compacted) is the answer that was actually given.
1025
+ if (overlongReplyAt !== null) {
1026
+ session.transcript.splice(overlongReplyAt, 2);
1027
+ overlongReplyAt = null;
1028
+ await options.persist?.();
1029
+ }
1030
+ return content;
1031
+ }
1032
+ // Record the assistant's tool request so the conversation stays well-formed.
1033
+ const records = toolCalls.map((call) => ({
1034
+ ...(call.meta ? { meta: call.meta } : {}),
1035
+ id: call.id,
1036
+ name: call.name,
1037
+ arguments: call.arguments,
1038
+ }));
1039
+ session.transcript.push({ role: "assistant", content, toolCalls: records });
1040
+ // Durable BEFORE running the tools: if the machine dies mid-tool, the resume path
1041
+ // sees these dangling tool_calls and reconciles them (reconcileInterruptedTools).
1042
+ await options.persist?.();
1043
+ // Narration gate, part one: JUDGE here, where this message's prose and the turn's
1044
+ // earlier prose are both in hand. Do NOT push anything yet — an assistant message
1045
+ // carrying tool_calls must be followed immediately by a tool message per call, and
1046
+ // slipping a nudge in between makes the request invalid (DeepSeek 400: "must be
1047
+ // followed by tool messages responding to each tool_call_id"). The nudge is queued
1048
+ // and pushed after the results land, which is where the other nudges already fire.
1049
+ if (!narrationNudged && content.trim()) {
1050
+ pendingNarrationFault = narrationFault(content, narratedBefore);
1051
+ narratedBefore.push(content);
1052
+ }
1053
+ // Announce every tool the model chose, in its order, BEFORE running any —
1054
+ // the UI's reveal queue paces them and a slow tool (test/run) can show a live
1055
+ // "running" state until its end event lands.
1056
+ for (const call of toolCalls) {
1057
+ options.onEvent?.({ type: "tool", phase: "start", id: call.id, name: call.name, args: parseArgs(call.arguments) });
1058
+ }
1059
+ // Concurrency-safe calls run in PARALLEL; the rest run one at a time, in order
1060
+ // (parallel edits to one file race, and an edit must see the last write). A call
1061
+ // is concurrency-safe when the tool says so for THESE args (isConcurrencySafe) —
1062
+ // e.g. a read-only sub-agent, which lets the model fan out research — otherwise
1063
+ // the default is: read-only ⇒ safe, mutating ⇒ serial.
1064
+ const concurrencySafe = (call) => {
1065
+ const tool = lookup(call.name);
1066
+ return tool ? callIsConcurrencySafe(tool, parseArgs(call.arguments)) : false;
1067
+ };
1068
+ const parallelCalls = toolCalls.filter(concurrencySafe);
1069
+ const serialCalls = toolCalls.filter((call) => !concurrencySafe(call));
1070
+ const runCall = async (call) => {
1071
+ // Esc: once the turn is aborted, no further tool may START. The step loop only
1072
+ // re-checks BETWEEN steps, so without this gate the rest of a batch still runs
1073
+ // after the interrupt — and a `run_in_background` command in that batch would
1074
+ // outlive the turn entirely, leaving a process the user thought they cancelled.
1075
+ // Placed here, at the single execution choke point, so it covers both the
1076
+ // parallel and serial lanes and every tool uniformly.
1077
+ if (options.signal?.aborted) {
1078
+ return {
1079
+ call,
1080
+ output: "Not run: the turn was interrupted before this tool started.",
1081
+ summary: "interrupted",
1082
+ isError: true,
1083
+ detail: undefined,
1084
+ };
1085
+ }
1086
+ const tool = lookup(call.name);
1087
+ if (!tool) {
1088
+ // A name the model invented. The row renders as "Unknown tool(index_results)"
1089
+ // (see toolDisplay), and the model gets the near misses so it can correct on
1090
+ // the next step instead of guessing again at a bare "unknown tool".
1091
+ // Built-ins only: an MCP tool is always `mcp__server__tool`, which is never a
1092
+ // near miss for a plain name, so including them would only add noise.
1093
+ return { call, output: unknownToolError(call.name, TOOLS.map((t) => t.name)), summary: `unknown tool '${call.name}'`, isError: true, detail: undefined, fullContentOf: undefined };
1094
+ }
1095
+ // The mirror of the rule below, for tools that exist BECAUSE planning is
1096
+ // happening. `planOnly` is only a schema FILTER, so nothing stopped a model from
1097
+ // calling one outside plan mode — and exit_plan is read-only, so neither refusal
1098
+ // below caught it either. Approving from there set the session up to return to
1099
+ // planning at the end of the turn, putting the user in a mode they never chose.
1100
+ //
1101
+ // It is a real call to make, not a hypothetical: the tool list the model is
1102
+ // holding was built at the start of the step, so the step right after approval
1103
+ // still has exit_plan in it.
1104
+ if (tool.planOnly && !session.toolContext.planMode) {
1105
+ return {
1106
+ call,
1107
+ output: `Refused: '${call.name}' is only for ending a planning session, and you are not in plan mode. ` +
1108
+ `If your plan was already approved, carry on with the work instead.`,
1109
+ summary: `blocked outside plan mode`,
1110
+ isError: true,
1111
+ detail: undefined,
1112
+ };
1113
+ }
1114
+ // Belt-and-suspenders for plan mode: the schema filter already hides mutating
1115
+ // tools, but if the model calls one anyway, refuse it instead of running it.
1116
+ // Live, not the captured value. Reading the stale one here would refuse the
1117
+ // editing tools the user had just approved, for the rest of the turn.
1118
+ if (session.toolContext.planMode && !tool.readOnly) {
1119
+ return {
1120
+ call,
1121
+ output: `Refused: '${call.name}' changes files or state, but you're in plan mode. Present your plan instead; the user will approve and switch out of plan mode to carry it out.`,
1122
+ summary: `blocked in plan mode`,
1123
+ isError: true,
1124
+ detail: undefined,
1125
+ };
1126
+ }
1127
+ // A read-only sub-agent: same schema-hiding + refusal, without the plan framing.
1128
+ if (session.toolContext.readOnlyTools && !tool.readOnly) {
1129
+ return {
1130
+ call,
1131
+ output: `Refused: '${call.name}' changes files or state, but this sub-agent is read-only. Report your findings instead.`,
1132
+ summary: `blocked (read-only sub-agent)`,
1133
+ isError: true,
1134
+ detail: undefined,
1135
+ };
1136
+ }
1137
+ // Sentinel mode: confirm every mutating action with the human first. Gated
1138
+ // here (the single execution choke point) so it covers every mutating tool
1139
+ // uniformly — including subagent edits. Fails safe: no approval channel, or an
1140
+ // unclear answer, refuses rather than runs.
1141
+ const ctx = session.toolContext;
1142
+ if (!tool.readOnly && ctx.guarded && !ctx.guardAllowed?.has(call.name)) {
1143
+ const args = parseArgs(call.arguments);
1144
+ // The question is one line; WHAT is about to happen rides as detail, which the
1145
+ // CLI prints into the transcript. A gate the user cannot read is a gate they
1146
+ // learn to wave through.
1147
+ const choice = ctx.requestApproval
1148
+ ? await ctx.requestApproval(guardQuestion(), guardOptions(call.name), guardDetail(call.name, args), "Permission Request", GUARD_REFUSAL_INPUT)
1149
+ : undefined;
1150
+ const decision = interpretGuardChoice(choice, call.name);
1151
+ if (decision === "refuse") {
1152
+ // A refusal that carries the user's own direction is worth far more than a
1153
+ // bare no: it turns a dead end into the next instruction, without costing a
1154
+ // round trip to ask what they meant.
1155
+ const said = choice ? readFreeText(choice) : null;
1156
+ return {
1157
+ call,
1158
+ output: said ? guardRefusalWith(said) : GUARD_REFUSAL,
1159
+ summary: `declined ${call.name}`,
1160
+ isError: true,
1161
+ detail: undefined,
1162
+ };
1163
+ }
1164
+ // Scoped to this KIND of action, never to everything. See guardOptions.
1165
+ if (decision === "allow-kind") {
1166
+ ctx.guardAllowed = new Set([...(ctx.guardAllowed ?? []), call.name]);
1167
+ }
1168
+ }
1169
+ // A tool must never be able to unwind the turn by throwing, and this is the only
1170
+ // place that can guarantee it.
1171
+ //
1172
+ // The cost of the gap was out of all proportion to its likelihood. By the time a
1173
+ // tool runs, the assistant entry carrying `tool_calls` has been pushed AND
1174
+ // persisted, so a rejection escaping here ends the turn with tool calls that have
1175
+ // no results. That is not merely a lost turn: the provider requires every
1176
+ // tool_call_id to be answered, so EVERY later request in that live session is
1177
+ // malformed. The transcript is repaired on load (reconcileInterruptedTools), which
1178
+ // means the damage lasts exactly until the user restarts — the worst shape for a
1179
+ // fault, because the fix is invisible and the session looks broken.
1180
+ //
1181
+ // Probed before writing this: all 28 tools were called with five wrong argument
1182
+ // types, with no arguments, and with ten Windows path shapes that make `fs` throw
1183
+ // (reserved device names, invalid characters, a null byte, an over-long name).
1184
+ // Zero threw — every one returned an error result. So this catches nothing today
1185
+ // and is deliberately defence in depth: it removes a single point of failure
1186
+ // rather than fixing an observed bug, and the next tool added inherits it.
1187
+ let result;
1188
+ try {
1189
+ result = await tool.execute(parseArgs(call.arguments), session.toolContext);
1190
+ }
1191
+ catch (error) {
1192
+ // An abort is the user, not a fault: let it travel so the loop's own handling
1193
+ // reports an interruption instead of a broken tool.
1194
+ if (isAbort(error))
1195
+ throw error;
1196
+ return { call, ...toolFailureResult(call.name, error), detail: undefined };
1197
+ }
1198
+ return {
1199
+ call,
1200
+ output: result.output,
1201
+ summary: result.summary,
1202
+ isError: result.isError,
1203
+ detail: result.detail,
1204
+ detailKind: result.detailKind,
1205
+ quiet: result.quiet,
1206
+ fullContentOf: result.fullContentOf,
1207
+ images: result.images,
1208
+ displayKind: result.displayKind,
1209
+ displayName: result.displayName,
1210
+ };
1211
+ };
1212
+ // Emit each tool's END the instant IT finishes — not batched after the whole
1213
+ // turn — so the UI can resolve that row promptly (and show it already-expanded
1214
+ // rather than a header that pops its output in later). Transcript order is still
1215
+ // the model's call order (the sort below); only the UI events go out eagerly.
1216
+ const runAndEmit = async (call) => {
1217
+ const r = await runCall(call);
1218
+ options.onEvent?.({
1219
+ type: "tool",
1220
+ phase: "end",
1221
+ id: r.call.id,
1222
+ name: r.call.name,
1223
+ summary: r.summary ?? r.call.name,
1224
+ error: r.isError ?? false,
1225
+ detail: r.detail,
1226
+ ...(r.detailKind ? { detailKind: r.detailKind } : {}),
1227
+ ...(r.quiet ? { quiet: true } : {}),
1228
+ ...(r.displayKind ? { displayKind: r.displayKind } : {}),
1229
+ ...(r.displayName ? { displayName: r.displayName } : {}),
1230
+ });
1231
+ return r;
1232
+ };
1233
+ const results = await Promise.all(parallelCalls.map(runAndEmit));
1234
+ for (const call of serialCalls) {
1235
+ results.push(await runAndEmit(call));
1236
+ }
1237
+ // Hand results back in the model's original call order (start events were emitted
1238
+ // in that order too), no matter which lane each call ran in.
1239
+ const callOrder = new Map(toolCalls.map((c, i) => [c.id, i]));
1240
+ results.sort((a, b) => (callOrder.get(a.call.id) ?? 0) - (callOrder.get(b.call.id) ?? 0));
1241
+ // Track the verification-gate facts: a successful edit/write to a file with a
1242
+ // runtime surface counts as a mutation that needs checking — a docs-only edit
1243
+ // (MINDWEAVE.md, a README) does NOT, so the gate never fires on it. A diagnostics/
1244
+ // build/test check counts ONLY when it PASSED. A failing check (non-zero exit /
1245
+ // isError) is not verification — it means work remains, so the gate must stay
1246
+ // unsatisfied and nudge again rather than let a red build finish.
1247
+ for (const r of results) {
1248
+ if (!r.isError && mutationNeedsVerification(r.call.name, parseArgs(r.call.arguments)))
1249
+ mutatedThisTurn = true;
1250
+ if (!r.isError && isVerification(r.call.name, parseArgs(r.call.arguments)))
1251
+ verifiedThisTurn = true;
1252
+ // A write to MINDWEAVE.md means the frozen copy in the cached system prompt is
1253
+ // behind the file. Noted, NOT acted on: re-reading it here would rewrite the
1254
+ // system prompt string and throw away the whole cached prefix mid-turn. The
1255
+ // model just wrote the content so it already has it; the prefix catches up at
1256
+ // the next compaction, where the cache is being discarded anyway.
1257
+ if (!r.isError && touchesProjectMemory(r.call.name, parseArgs(r.call.arguments))) {
1258
+ session.projectMemoryStale = true;
1259
+ }
1260
+ }
1261
+ for (const result of results) {
1262
+ // The end event already went out eagerly (runAndEmit) the moment this tool
1263
+ // finished; here we only record it into the transcript, in call order.
1264
+ session.transcript.push({
1265
+ role: "tool",
1266
+ toolCallId: result.call.id,
1267
+ content: result.output,
1268
+ // Display fields, stored so a resumed session replays the exact same row
1269
+ // (summary line + diff/detail). Ignored when building the wire request.
1270
+ ...(result.summary ? { summary: result.summary } : {}),
1271
+ ...(result.detail ? { detail: result.detail } : {}),
1272
+ ...(result.isError ? { isError: true } : {}),
1273
+ // Presence, as recorded by the tool that knows: this result IS the whole
1274
+ // content of that file. Not display — the presence derivation reads it.
1275
+ ...(result.fullContentOf ? { fullContentOf: result.fullContentOf } : {}),
1276
+ });
1277
+ }
1278
+ await options.persist?.(); // durable: tool results recorded, transcript well-formed
1279
+ // A plan approved with a FRESH CONTEXT. Everything the planning turn accumulated —
1280
+ // the files opened to understand the problem, the searches that went nowhere — has
1281
+ // done its job, and none of it is needed to carry the plan out.
1282
+ //
1283
+ // Done HERE, and nowhere else, because this is the one point where the assistant
1284
+ // message carrying the exit_plan call and its result can be dropped TOGETHER. A
1285
+ // provider requires every tool_call to be answered; cutting the conversation a step
1286
+ // earlier or later leaves one without the other and every later request in the
1287
+ // session is malformed.
1288
+ if (session.toolContext.planFreshStart) {
1289
+ const plan = session.toolContext.planFreshStart;
1290
+ session.toolContext.planFreshStart = undefined;
1291
+ const before = estimateEntriesTokens(session.transcript);
1292
+ // The planning conversation is KEPT, under the id it was written with, and the
1293
+ // work continues as a new session.
1294
+ //
1295
+ // A session file is rewritten whole on every persist, so without this the very
1296
+ // next save would overwrite the planning transcript with the one message that
1297
+ // replaced it — and the pointer in that message would name a file holding nothing
1298
+ // but the pointer. The id has to move at exactly this moment, which is why the old
1299
+ // path is captured BEFORE the change.
1300
+ const priorPath = transcriptPath(session.cwd, session.id);
1301
+ session.id = randomUUID();
1302
+ session.toolContext.sessionId = session.id;
1303
+ session.transcript = [{ role: "user", content: implementFromScratch(priorPath, plan) }];
1304
+ // The ledger describes what is on screen, and nothing is any more. Left alone it
1305
+ // would tell the model it already holds files that are no longer in front of it —
1306
+ // the same lie a compaction used to leave behind.
1307
+ session.toolContext.reads.clear();
1308
+ // The notes describe a conversation that no longer exists, so they go with it and
1309
+ // start again from nothing. REBASED as well as cleared: "should I update the
1310
+ // notes" asks how far the transcript has grown SINCE the last update, and leaving
1311
+ // that measured against the old long conversation means the difference stays
1312
+ // negative and the notes never update again until the new work exceeds the old
1313
+ // one. A compaction rebases here for exactly the same reason; it keeps its notes
1314
+ // because a summary still describes the work, and this does not.
1315
+ session.sessionMemory = "";
1316
+ session.sessionMemoryEntries = 0;
1317
+ session.sessionMemoryTokens = estimateEntriesTokens(session.transcript);
1318
+ session.sessionMemoryInit = false;
1319
+ await options.persist?.();
1320
+ // Reported through the same channel a compaction uses, because to the user it is
1321
+ // the same event: the conversation just got much shorter and they should be told
1322
+ // by how much rather than watching it happen silently.
1323
+ options.onCompaction?.({
1324
+ before,
1325
+ after: estimateEntriesTokens(session.transcript),
1326
+ window: sharpContextWindow(session.modelConfig.model),
1327
+ });
1328
+ }
1329
+ // Images a tool produced (screenshot) reach the model HERE, as a following user
1330
+ // message, rather than inside the tool result. Two reasons, both hard:
1331
+ //
1332
+ // 1. Wire compatibility. An image inside a tool-result message is fine on
1333
+ // Anthropic and rejected by OpenAI-compatible providers, which is most of
1334
+ // the driver folders. A user message with images is the one shape every
1335
+ // provider already takes — the same path a user's `@file` attachment uses,
1336
+ // so it inherits payload loading, eviction, and token accounting for free.
1337
+ // 2. Ordering. Nothing may sit between an assistant's tool_calls and their
1338
+ // results, so this runs after the loop above, where the other queued pushes
1339
+ // already land (a nudge slipped in mid-run once broke every tool-calling
1340
+ // turn on DeepSeek).
1341
+ //
1342
+ // Whether the picture is SENT or merely named is core's call, made once from a
1343
+ // fact the manifest states — a tool never asks which provider is running.
1344
+ const shots = results.flatMap((r) => r.images ?? []);
1345
+ if (shots.length > 0) {
1346
+ const canSee = manifestForModel(session.modelConfig.model).acceptsImages?.(session.modelConfig.model) ?? false;
1347
+ const names = shots.map((i) => basename(i.path)).join(", ");
1348
+ session.transcript.push({
1349
+ role: "user",
1350
+ content: canSee
1351
+ ? `Here ${shots.length === 1 ? "is the image" : "are the images"} just captured (${names}).`
1352
+ : `${names} was captured and saved, but this model cannot see images, so you are ` +
1353
+ `being told about it rather than shown it. Describe what you expected to verify ` +
1354
+ `and ask the user what they see, or switch to a model with vision using /model.`,
1355
+ synthetic: true,
1356
+ ...(canSee ? { images: shots } : {}),
1357
+ });
1358
+ await options.persist?.();
1359
+ }
1360
+ // Automatic post-edit check. Runs itself rather than relying on the model to call
1361
+ // `diagnostics`, because a tool description asking a model to remember is exactly
1362
+ // the kind of rule that gets ignored under load — the mechanical version is the one
1363
+ // that holds. It also checks the edited files' reverse DEPENDENTS, which is the
1364
+ // failure the per-file tool structurally cannot see: a renamed symbol or changed
1365
+ // signature breaks the CALLER, in a file nobody thought to check.
1366
+ //
1367
+ // Recorded as a synthetic user message, the same shape the screenshot block above
1368
+ // uses, so it lands after the tool results rather than between a tool_calls message
1369
+ // and its results — the ordering that broke every tool-calling turn on DeepSeek once.
1370
+ // Silent when it finds nothing: no server, a slow server and an unreadable path all
1371
+ // look like "no diagnostics", so an all-clear here would be a claim we cannot make.
1372
+ const ripple = await rippleCheck(session.toolContext, results.map((r) => ({ name: r.call.name, args: parseArgs(r.call.arguments), isError: r.isError })), (p) => {
1373
+ try {
1374
+ return resolvePath(session.toolContext, p);
1375
+ }
1376
+ catch {
1377
+ return undefined;
1378
+ }
1379
+ });
1380
+ if (ripple) {
1381
+ session.transcript.push({ role: "user", content: ripple, synthetic: true });
1382
+ await options.persist?.();
1383
+ }
1384
+ // Re-scope guard. A todo_write that clears the list ("all tasks completed")
1385
+ // marks a natural stopping point: the requested work is done. If the model
1386
+ // then opens a NEW list of pending work in the same turn, it's taking on scope
1387
+ // the user didn't ask for — pause losslessly here and hand the wheel back,
1388
+ // rather than letting it rebuild the same thing over and over (a weaker model
1389
+ // won't self-stop the way a stronger one does; this is the deterministic
1390
+ // backstop for that). The decision is a pure fn (verify.ts) so it's unit-tested.
1391
+ const reScope = reScopeCheck(completedAList, results.map((r) => ({ name: r.call.name, summary: r.summary })), session.toolContext.todos);
1392
+ completedAList = reScope.completed;
1393
+ // Remember, for the NEXT turn's boundary sweep, that a task just finished here.
1394
+ session.taskJustCompleted = reScope.completed;
1395
+ if (reScope.pause)
1396
+ return pauseReScope(session, options);
1397
+ // Background-poll guard. A still-running shell's completion is pushed to the model
1398
+ // automatically, so polling it in a loop is pure waste and reads as spam ("still
1399
+ // compiling… let me check again", over and over). Allow a single informative poll,
1400
+ // then stop the wait-loop here — deterministically, because a weaker model doesn't
1401
+ // stop on the prose nudge in the tool result. Nothing is lost: when the shell
1402
+ // finishes, backgroundEventNotes wakes the model to report it.
1403
+ if (isBackgroundPollStep(results.map((r) => ({ name: r.call.name, summary: r.summary })))) {
1404
+ bgPollStreak++;
1405
+ if (bgPollStreak > BG_POLL_ALLOWANCE)
1406
+ return pauseForBackgroundPoll(session, options);
1407
+ }
1408
+ else {
1409
+ bgPollStreak = 0;
1410
+ }
1411
+ // Repeat-failure breaker. If this step failed exactly the way the last one(s) did —
1412
+ // same tools, same error — the model is stuck grinding a broken command instead of
1413
+ // changing course. Keyed on the error MESSAGE, so a run of near-identical commands
1414
+ // that all fail the same way still trips it.
1415
+ //
1416
+ // The first trip does NOT end the turn. Nothing in the conversation tells the model
1417
+ // it is repeating itself, so ending there would kill it for something it could not
1418
+ // see. Instead we inject that fact (with the shell's real cwd, the usual culprit)
1419
+ // and let it diagnose. Repeat it after being told and the turn stops for real.
1420
+ const failSig = stepFailureSignature(results.map((r) => ({ name: r.call.name, output: r.output, isError: !!r.isError })));
1421
+ if (failSig) {
1422
+ if (failSig === lastFailSig) {
1423
+ repeatFailStreak++;
1424
+ }
1425
+ else {
1426
+ // A different failure is a different loop: it gets its own interrupt.
1427
+ repeatFailStreak = 1;
1428
+ repeatFailNudged = false;
1429
+ }
1430
+ lastFailSig = failSig;
1431
+ const failed = results.find((r) => r.isError);
1432
+ lastFailOutput = failed?.output ?? "";
1433
+ const action = repeatFailureStep(repeatFailStreak, REPEAT_FAIL_LIMIT, repeatFailNudged);
1434
+ if (action === "stop")
1435
+ return pauseForRepeatedFailure(session, options, lastFailOutput);
1436
+ if (action === "nudge") {
1437
+ repeatFailNudged = true;
1438
+ const failedLabel = failed
1439
+ ? failedActionLabel(failed.call.name, parseArgs(failed.call.arguments))
1440
+ : "the same step";
1441
+ // A repeat failure DURING an approved plan is the mechanical divergence
1442
+ // signal: the agreed step is not working. The interrupt then orders a stop
1443
+ // and a return to planning, never a sideways improvisation — that is the
1444
+ // plan contract, enforced at the one point the engine can detect it.
1445
+ const inApprovedWork = !!session.toolContext.activePlan && !(session.toolContext.planMode ?? false);
1446
+ session.transcript.push({
1447
+ role: "user",
1448
+ content: inApprovedWork
1449
+ ? planDivergenceStop(failedLabel)
1450
+ : repeatFailureNudge({
1451
+ attempts: repeatFailStreak,
1452
+ action: failedLabel,
1453
+ error: firstErrorLine(lastFailOutput),
1454
+ // Only when `cd` has actually moved us — otherwise it's noise.
1455
+ cwd: session.toolContext.cwd !== session.cwd ? session.toolContext.cwd : undefined,
1456
+ }),
1457
+ synthetic: true,
1458
+ });
1459
+ await options.persist?.();
1460
+ }
1461
+ }
1462
+ else {
1463
+ repeatFailStreak = 0;
1464
+ lastFailSig = null;
1465
+ repeatFailNudged = false;
1466
+ }
1467
+ // Batching gate: it keeps editing ONE file a single change at a time, where one
1468
+ // edit call belonged. Mechanical rather than a line in the tool description,
1469
+ // because the same task with the same descriptions routes correctly on one run and
1470
+ // not the next — prose biases a choice, it cannot make it hold, and this has to hold
1471
+ // on every provider. Nudge once and let the turn continue; nothing is blocked, since
1472
+ // the edits themselves are perfectly valid.
1473
+ // Narration gate, part two: the results are in, so the transcript is valid again
1474
+ // and the queued nudge can land. One per turn; nothing is blocked and nothing the
1475
+ // user already read is rewritten behind them.
1476
+ if (!narrationNudged && pendingNarrationFault) {
1477
+ narrationNudged = true;
1478
+ session.transcript.push({ role: "user", content: narrationNudge(pendingNarrationFault), synthetic: true });
1479
+ pendingNarrationFault = null;
1480
+ await options.persist?.();
1481
+ }
1482
+ if (!batchEditNudged) {
1483
+ for (const [path, n] of sameFileEditCounts(results.map((r) => ({ name: r.call.name, args: parseArgs(r.call.arguments) })))) {
1484
+ singleEditsByFile.set(path, (singleEditsByFile.get(path) ?? 0) + n);
1485
+ }
1486
+ const overused = overusedSingleEdits(singleEditsByFile);
1487
+ if (overused) {
1488
+ batchEditNudged = true;
1489
+ session.transcript.push({
1490
+ role: "user",
1491
+ content: batchEditNudge(overused, singleEditsByFile.get(overused) ?? 0),
1492
+ synthetic: true,
1493
+ });
1494
+ await options.persist?.();
1495
+ }
1496
+ }
1497
+ }
1498
+ // Step ceiling reached without the model finishing. Don't spend another call
1499
+ // forcing a (misleading) wrap-up the way a tools-off final turn would — that
1500
+ // reads as "done" when it isn't. Pause cleanly instead: the transcript, task
1501
+ // list, and working set are all intact, so telling Mindweave to continue resumes
1502
+ // exactly here with nothing lost, and the user stays in control of the spend.
1503
+ return pauseTask(session, options, `reached the step budget of ${stepLimit} tool steps in one turn`);
1504
+ }
1505
+ }
1506
+ /** The most recent user request in the transcript, clipped — labels a checkpoint. */
1507
+ function lastUserText(session) {
1508
+ for (let i = session.transcript.length - 1; i >= 0; i--) {
1509
+ const e = session.transcript[i];
1510
+ if (e.role === "user") {
1511
+ const oneLine = e.content.replace(/\s+/g, " ").trim();
1512
+ return oneLine.length > 60 ? oneLine.slice(0, 57) + "…" : oneLine;
1513
+ }
1514
+ }
1515
+ return "(edits)";
1516
+ }
1517
+ /**
1518
+ * End the turn with a message, recording it AND putting it on the wire.
1519
+ *
1520
+ * The emit is the point. A normal reply reaches the screen as `text` events while
1521
+ * the model streams it; a pause message is composed here, after streaming, so it
1522
+ * has no such path of its own. Without this the transcript and the next model turn
1523
+ * both get the explanation while the user gets a turn that just ends, blank — which
1524
+ * is exactly how a tripped guard came to look like a crash.
1525
+ *
1526
+ * respond()'s return value is deliberately not what the UI renders: sub-agents call
1527
+ * respond() directly and use the return as their report, with no UI attached at all.
1528
+ */
1529
+ function endTurnWith(session, options, msg) {
1530
+ session.transcript.push({ role: "assistant", content: msg });
1531
+ options.onEvent?.({ type: "text", delta: msg });
1532
+ return msg;
1533
+ }
1534
+ /** Lossless hand-back when the model finishes its task list and then starts a new
1535
+ * one in the same turn (the re-scope guard) — a natural checkpoint to let the user
1536
+ * steer instead of the model taking on scope it wasn't asked for. */
1537
+ function pauseReScope(session, options) {
1538
+ return endTurnWith(session, options, "(I've finished the task list for what you asked. I have ideas for taking it " +
1539
+ "further, but I've stopped here so you can steer — rather than piling on new scope " +
1540
+ 'on my own. Tell me which direction you want, or say "keep going" to continue.)');
1541
+ }
1542
+ /** Lossless stop when the model is stuck polling a still-running background shell.
1543
+ * The shell's completion is pushed to the model automatically, so there's nothing
1544
+ * to do but wait — end the turn cleanly instead of looping "still running" checks.
1545
+ * Deliberately worded as a status line to the user, not a "paused" apology. */
1546
+ function pauseForBackgroundPoll(session, options) {
1547
+ return endTurnWith(session, options, "It's still running in the background. I'll stop checking and let you know as soon " +
1548
+ "as it finishes — no need to keep watching.");
1549
+ }
1550
+ /** Lossless stop when the model repeats the same failing step even AFTER being told it is
1551
+ * looping (the breaker's second tier). By this point it has had the error, the repeat
1552
+ * count, and its real working directory, and it still hasn't moved — so hand the wheel
1553
+ * to the user rather than spend more steps on it. */
1554
+ function pauseForRepeatedFailure(session, options, errorOutput) {
1555
+ return endTurnWith(session, options, `I've hit the same failure several times in a row and I'm not making progress, so I've ` +
1556
+ `stopped rather than retry the same thing again. The error was:\n\n${firstErrorLine(errorOutput)}\n\n` +
1557
+ `Tell me how you'd like to proceed, or I can try a different approach.`);
1558
+ }
1559
+ /** Record and return a clean, lossless pause reply (well-formed transcript) when a
1560
+ * guard trips — step budget or a cost/time ceiling. Saying "continue" resumes. */
1561
+ function pauseTask(session, options, reason) {
1562
+ return endTurnWith(session, options, `(Paused — ${reason}. The task isn't finished, but nothing is lost: your progress, ` +
1563
+ `edits, and task list are saved. Say "continue" to pick up exactly where I left off.)`);
1564
+ }
1565
+ /**
1566
+ * Plain-language reason a turn ended early, for the pause message. Kept here (not
1567
+ * in a driver) because it's user-facing copy: every provider maps its own
1568
+ * vocabulary onto the shared StopReason, and the wording is the same either way.
1569
+ */
1570
+ export function stopReasonNote(stop) {
1571
+ switch (stop) {
1572
+ case "truncated":
1573
+ return "the model hit its output limit mid-answer, so the reply above is incomplete";
1574
+ case "refused":
1575
+ return "the provider's safety filter declined this request";
1576
+ case "overflow":
1577
+ return "the conversation no longer fits the model's context window";
1578
+ case "overloaded":
1579
+ return "the provider's infrastructure cut the request off before it finished, so the reply above is incomplete";
1580
+ }
1581
+ }
1582
+ /** One streaming model call: forwards the model's reasoning/answer deltas to the
1583
+ * UI as engine events, and returns the assembled turn (content + tool calls +
1584
+ * usage) for the loop to record. */
1585
+ function streamModel(request, options) {
1586
+ return activeDriver().streamTurn(request, {
1587
+ signal: options.signal,
1588
+ onEvent: (e) => {
1589
+ if (e.type === "reasoning")
1590
+ options.onEvent?.({ type: "reasoning", delta: e.delta });
1591
+ else if (e.type === "text")
1592
+ options.onEvent?.({ type: "text", delta: e.delta });
1593
+ // tool_start / tool_args deltas are not forwarded: the engine emits richer
1594
+ // tool events (with parsed args + result summary) around execution instead.
1595
+ },
1596
+ });
1597
+ }
1598
+ /**
1599
+ * How many calls of per-call usage a session keeps. Enough to cover any turn anyone
1600
+ * would investigate, bounded so the meta file cannot grow with session length.
1601
+ */
1602
+ const CALL_LOG_LIMIT = 200;
1603
+ /** One call's usage, flattened for the session file. Exported so the recording is
1604
+ * testable on its own — persisting a hand-built record proves nothing about what the
1605
+ * engine actually writes. */
1606
+ export function toCallRecord(u, model) {
1607
+ return {
1608
+ at: Date.now(),
1609
+ prompt: u.promptTokens,
1610
+ hit: u.cacheHitTokens,
1611
+ miss: u.cacheMissTokens,
1612
+ out: u.completionTokens,
1613
+ model,
1614
+ };
1615
+ }
1616
+ /** Report a turn's token usage to the UI, if the provider returned it. */
1617
+ function emitUsage(result, options) {
1618
+ if (result.usage) {
1619
+ options.onEvent?.({ type: "usage", ...result.usage });
1620
+ }
1621
+ }
1622
+ /**
1623
+ * Run the compaction cascade if the transcript has grown enough: microcompact
1624
+ * (lossless) first, then autocompact (a summary) if still over the higher bar.
1625
+ */
1626
+ /**
1627
+ * Refresh the maintained "state of this session" notes if the transcript has grown
1628
+ * enough since the last refresh. They live outside the transcript, so compaction never
1629
+ * erodes them — which is what lets a session run indefinitely without losing the thread,
1630
+ * and what a later `read_session` reads to answer "what did we do last time".
1631
+ *
1632
+ * Called at BOTH turn start (so this turn's context carries current notes) and turn end
1633
+ * (so the last turn of a session is never missing from them). One cheap call, token-gated
1634
+ * so it fires rarely and never twice for the same growth. Silent by design: this is
1635
+ * background machinery, not something the user watches. Degrade-safe — a failed update
1636
+ * keeps the last good notes.
1637
+ */
1638
+ async function sweepSessionMemory(session, options) {
1639
+ // Not for a sub-agent. The notes exist so the MAIN conversation survives being
1640
+ // summarised; a child's transcript is thrown away whole the moment it reports back, so
1641
+ // there is nothing for them to carry. Writing them costs a real model call on the
1642
+ // user's key with the child's whole recent transcript as input — measured: a 20-step
1643
+ // research worker reaches the threshold at ~9.8K tokens, and a five-way fan-out paid
1644
+ // that five times, for notes nothing ever read. The child does not even persist them.
1645
+ if ((session.toolContext.subagentDepth ?? 0) > 0)
1646
+ return;
1647
+ const grown = shouldUpdateSessionMemory(estimateEntriesTokens(session.transcript), session.sessionMemoryTokens ?? 0, session.sessionMemoryInit ?? false);
1648
+ if (!grown)
1649
+ return;
1650
+ await updateSessionMemory(session);
1651
+ await options.persist?.(); // durable: the notes sidecar is written by the persister
1652
+ }
1653
+ /**
1654
+ * How much of the context window is in use, in tokens.
1655
+ *
1656
+ * ONE definition, because two would be worse than none: the compaction thresholds fire
1657
+ * on this number and the bars shown to the user are drawn from it, so if the estimate
1658
+ * is off, the display is wrong in exactly the way the decision was — rather than
1659
+ * disagreeing with the machinery it is supposed to explain.
1660
+ *
1661
+ * Everything outside the transcript counts too, because this is about how full the
1662
+ * CONTEXT is, not how long the transcript is. Once a call has reported usage we know
1663
+ * that overhead exactly (system prompt + every tool schema + working set + relevance
1664
+ * map + todos + governor); until then, fall back to the one piece we could always
1665
+ * estimate. MCP schemas are inside the measured figure, so they are only added in the
1666
+ * fallback — counting both would double them.
1667
+ *
1668
+ * A measurement taken on a DIFFERENT model does not transfer: switching provider
1669
+ * changes the tool-schema serialisation and the prompt shape. Falling back is the safe
1670
+ * direction — it under-counts for one call, which fires the bars early rather than
1671
+ * late, and the next call re-measures.
1672
+ */
1673
+ export function contextUsed(session) {
1674
+ const measured = session.contextOverhead;
1675
+ const overhead = measured && measured.model === session.modelConfig.model
1676
+ ? measured.tokens
1677
+ : (session.toolContext.mcp?.estimatedTokens() ?? 0);
1678
+ return estimateEntriesTokens(session.transcript) + overhead;
1679
+ }
1680
+ async function maybeCompact(session, options) {
1681
+ const model = session.modelConfig.model;
1682
+ // Model-anchored bars (env still overrides), so the thresholds are right per model
1683
+ // instead of a fixed number — and a longer/stronger model automatically gets more room.
1684
+ const microBar = envInt("MINDWEAVE_MICROCOMPACT_TOKENS", microCompactThreshold(model));
1685
+ const autoBar = envInt("MINDWEAVE_AUTOCOMPACT_TOKENS", autoCompactThreshold(model));
1686
+ // MCP tool schemas are sent on every turn but live OUTSIDE the transcript, so the bars
1687
+ // could not see them: a 30K-token catalog meant the model was 30K deeper into its real
1688
+ // context than this arithmetic believed, and every threshold fired that much too late.
1689
+ // Counting it here restores the meaning of the bars — they are about how full the
1690
+ // context is, not how long the transcript is.
1691
+ // Everything outside the transcript counts too, because the bars are about how full
1692
+ // the CONTEXT is, not how long the transcript is. Once a call has reported usage we
1693
+ // know that overhead exactly (system prompt + every tool schema + the working set
1694
+ // block + relevance map + todos + governor); until then, fall back to the one piece
1695
+ // we could always estimate. MCP schemas are inside the measured figure, so they are
1696
+ // only added in the fallback — counting both would double them.
1697
+ //
1698
+ // A measurement taken on a DIFFERENT model does not transfer: switching provider
1699
+ // changes the tool-schema serialisation and the prompt shape. Falling back is the
1700
+ // safe direction — it under-counts for one call, which fires the bars early rather
1701
+ // than late, and the next call re-measures.
1702
+ const used = () => contextUsed(session);
1703
+ // Two reasons to microcompact, not one. The bar is about context PRESSURE; the cold
1704
+ // check is about the cache being gone, which removes the only argument for waiting.
1705
+ // See `cacheLikelyCold` — on a warm cache this rewrite costs a 1.25x prefix rebuild,
1706
+ // and once the entry has expired it costs nothing at all.
1707
+ const cold = cacheLikelyCold(session.lastCallAt ?? 0, Date.now(), used(), microBar);
1708
+ if (used() >= microBar || cold) {
1709
+ // Assigned unconditionally, on purpose. Gating this on a hand-picked subset of the
1710
+ // counters meant a pass that only cleared edit INPUTS or only evicted IMAGES did the
1711
+ // work and then threw the result away, and every new kind of clearing had to
1712
+ // remember to add itself here or be silently discarded. `microcompact` already
1713
+ // returns a copy when it changed nothing, so taking the result always is both
1714
+ // correct and the shape that cannot rot.
1715
+ // NO superseded set is passed any more. It used to name the files <working_files>
1716
+ // was carrying whole, whose transcript copies were then redundant and safe to clear
1717
+ // even inside the protected recent window. With that block gone the transcript is
1718
+ // the ONLY place those contents exist, so clearing them would delete the model's
1719
+ // single copy while nothing put it back — the exact context-that-lies failure
1720
+ // removing the block was meant to end.
1721
+ // PROPOSED, not applied. Clearing a tool body rewrites the transcript, and the
1722
+ // transcript is the cached half of the request — so a clear that reclaims a little
1723
+ // is not a small win, it is a loss: the remaining prefix gets rewritten at 1.25x
1724
+ // instead of read at 0.1x, and the break-even can run past a hundred steps. The
1725
+ // arithmetic lives in `clearIsWorthIt`; here we simply measure what this particular
1726
+ // clear would reclaim and let it decide.
1727
+ const proposed = microcompact(session.transcript).entries;
1728
+ const before = estimateEntriesTokens(session.transcript);
1729
+ const after = estimateEntriesTokens(proposed);
1730
+ if (clearIsWorthIt({ before, after, cold, autoBar })) {
1731
+ session.transcript = proposed;
1732
+ // Silent by design — trimming stale context is background machinery.
1733
+ }
1734
+ }
1735
+ if (used() < autoBar)
1736
+ return;
1737
+ // Circuit-breaker: once autocompact has failed MAX_COMPACT_FAILURES times this
1738
+ // session, stop trying (the transcript is likely irrecoverable) rather than burning
1739
+ // a doomed summarizer call every turn.
1740
+ if ((session.compactFailures ?? 0) >= MAX_COMPACT_FAILURES) {
1741
+ // Giving up SILENTLY was the real defect here. The breaker stopped the runaway
1742
+ // retries it was built for and then left the session running unmanaged, past the
1743
+ // bar, with nothing on screen to say so — so the user's next clue was a provider
1744
+ // error they had no way to connect to compaction. Told once, not per step.
1745
+ if (!session.compactGaveUpTold) {
1746
+ session.compactGaveUpTold = true;
1747
+ options.onActivity?.(`compaction failed ${MAX_COMPACT_FAILURES} times and has stopped retrying — ` +
1748
+ `context will keep growing. /compact to try again, or start a new session.`, { context: true });
1749
+ }
1750
+ return;
1751
+ }
1752
+ await autocompact(session, options);
1753
+ }
1754
+ /**
1755
+ * Force a full summarizing compaction now (the `/compact` command), regardless
1756
+ * of size. Safe on a short transcript — it just summarizes what's there.
1757
+ */
1758
+ export async function compactNow(session, options = {}) {
1759
+ // Clear the stale tool bodies BEFORE summarizing. The summarizer is billed on what it
1760
+ // is shown, and a transcript full of superseded file dumps costs real money to have
1761
+ // condensed into one line of "we read some files". The automatic path already does
1762
+ // both in order; the manual one used to jump straight to the expensive half.
1763
+ //
1764
+ // Unconditional, unlike the automatic pass: `clearIsWorthIt` weighs a clear against
1765
+ // the cache rewrite it causes, and a compaction is about to discard that cache
1766
+ // anyway, so the argument for holding back does not apply here.
1767
+ session.transcript = microcompact(session.transcript).entries;
1768
+ await autocompact(session, options);
1769
+ }
1770
+ /**
1771
+ * Replace the old prefix of the transcript with a summary and keep the last N turns
1772
+ * verbatim.
1773
+ *
1774
+ * Two ways to get that summary, cheapest first. The session notes are tried before the
1775
+ * summarizer, because they already ARE a maintained record of the session and cost
1776
+ * nothing; only when they are missing, empty or too stale to cover the prefix is a
1777
+ * model call spent. See `memory/sessionMemoryCompact.ts`.
1778
+ *
1779
+ * The summarizer call is sized to fit by construction rather than by luck: the auto bar
1780
+ * is the window minus the driver's declared output reserve minus turn headroom, so a
1781
+ * transcript that has just crossed it, plus the reserve the reply needs, still sits
1782
+ * inside the window. Verified across every model in the registry, not assumed. (This
1783
+ * comment previously justified the same thing with "DeepSeek's 1M window" and a 90K
1784
+ * trigger, both of which stopped being true when the bars became model-anchored and the
1785
+ * driver lineup grew past two.)
1786
+ */
1787
+ async function autocompact(session, options) {
1788
+ if (session.transcript.length === 0)
1789
+ return;
1790
+ // Measured BEFORE the summarizer runs, with the same arithmetic the thresholds use,
1791
+ // so the bar the user sees is the number the system actually acted on.
1792
+ const before = contextUsed(session);
1793
+ const fail = (why) => {
1794
+ // Keep the full transcript rather than lose it, and count the failure so the
1795
+ // circuit-breaker can stop retrying a doomed compaction. EVERY rejection counts,
1796
+ // not just a thrown error: a summarizer that keeps returning something unusable
1797
+ // burns a model call on every step forever, which is the exact runaway the
1798
+ // breaker exists to stop.
1799
+ session.compactFailures = (session.compactFailures ?? 0) + 1;
1800
+ // And SAY so. A compaction that silently does not happen leaves the session
1801
+ // running past its bar with no sign anything is wrong; the user cannot ask for
1802
+ // /compact, or start a fresh session, over a problem nobody mentioned.
1803
+ options.onActivity?.(`compaction did not succeed (${why}) — the conversation was kept intact, ` +
1804
+ `attempt ${session.compactFailures} of ${MAX_COMPACT_FAILURES}`, { context: true });
1805
+ };
1806
+ // Free first. The notes are a structured, continuously-refreshed record of this
1807
+ // session maintained outside the transcript, which is very nearly what the
1808
+ // summarizer is about to be paid to produce. When they are current enough to cover
1809
+ // the prefix being dropped, spending a model call buys something already owned.
1810
+ // Declines rather than approximates: stale or empty notes fall through.
1811
+ const fromNotes = compactFromSessionMemory(session.transcript, session.sessionMemory, session.sessionMemoryEntries, envInt("MINDWEAVE_AUTOCOMPACT_TOKENS", autoCompactThreshold(session.modelConfig.model)), contextUsed(session) - estimateEntriesTokens(session.transcript));
1812
+ if (fromNotes) {
1813
+ session.transcript = fromNotes.entries;
1814
+ // The notes now describe everything before the tail they were spliced in front of.
1815
+ session.sessionMemoryEntries = 1;
1816
+ session.sessionMemoryTokens = estimateEntriesTokens(session.transcript);
1817
+ await finishCompaction(session, options, before);
1818
+ return;
1819
+ }
1820
+ let summary;
1821
+ try {
1822
+ // Summaries don't need reasoning — use the chosen model with thinking off.
1823
+ const turn = await activeDriver().toolTurn({
1824
+ system: SUMMARY_SYSTEM_PROMPT,
1825
+ messages: [
1826
+ {
1827
+ role: "user",
1828
+ content: `${formatTranscriptForSummary(session.transcript)}\n\n${summaryRequest(options.compactFocus)}`,
1829
+ },
1830
+ ],
1831
+ model: { ...session.modelConfig, thinking: false },
1832
+ });
1833
+ // The reply is untrusted: a cut-off or all-scratchpad summary must not be allowed
1834
+ // to replace the conversation. See usableSummary.
1835
+ // Compaction is not free, and the user did not ask for it. Reporting its usage
1836
+ // is what keeps the meter honest: a turn that happened to trip the bar spends
1837
+ // a whole extra summarisation call, and leaving that out made the figure short
1838
+ // by exactly the work nobody could see.
1839
+ if (turn.usage)
1840
+ options.onEvent?.({ type: "usage", ...turn.usage });
1841
+ const usable = usableSummary(turn.content, turn.stop);
1842
+ if (!usable)
1843
+ return void fail(turn.stop && turn.stop !== "end" ? `the summary came back ${turn.stop}` : "the summary was unusable");
1844
+ summary = usable;
1845
+ }
1846
+ catch (error) {
1847
+ return void fail(providerMessage(detailOf(error)) || "the summarizer call failed");
1848
+ }
1849
+ // A summary replaces the transcript prefix, so file contents read before it are gone.
1850
+ // Nothing re-injects them: the working-set block that used to do so was removed for
1851
+ // costing up to 12K per model call. The model re-reads what it still needs, which
1852
+ // read_file allows because the summary also clears the presence set the dedup checks.
1853
+ session.transcript = spliceSummary(session.transcript, summary, KEEP_LAST_N);
1854
+ // The notes no longer describe the transcript they were measured against, and the
1855
+ // summary now covers everything before the kept tail.
1856
+ session.sessionMemoryEntries = 1;
1857
+ session.sessionMemoryTokens = estimateEntriesTokens(session.transcript);
1858
+ await finishCompaction(session, options, before);
1859
+ }
1860
+ /**
1861
+ * The half of a compaction that is the same however the new transcript was produced.
1862
+ *
1863
+ * Shared by the summarizer path and the session-notes path deliberately: every one of
1864
+ * these steps is a consequence of "the transcript was just rewritten", not of how it
1865
+ * was rewritten, and the two paths silently disagreeing about which of them ran is a
1866
+ * defect that would only show up as an unexplained cache warning or a stale memory
1867
+ * file weeks later.
1868
+ */
1869
+ async function finishCompaction(session, options, before) {
1870
+ session.compactFailures = 0; // a clean compaction resets the breaker
1871
+ await restoreAfterCompaction(session);
1872
+ // Re-read the governor unconditionally here. The prompt is being rebuilt from scratch
1873
+ // at this point, so it is the natural moment to rebuild what it is made of — and it is
1874
+ // the one path that does not depend on the stat check being right about anything.
1875
+ await refreshGovernance(session, true);
1876
+ // Report it. Compaction is the one context operation worth showing: it REWRITES the
1877
+ // conversation, so a user who is not told will later wonder why the model forgot the
1878
+ // middle of it. Reported for the automatic pass as well as `/compact`.
1879
+ options.onCompaction?.({
1880
+ before,
1881
+ after: contextUsed(session),
1882
+ window: sharpContextWindow(session.modelConfig.model),
1883
+ });
1884
+ // The prefix we are about to send bears no resemblance to the last one, and that is
1885
+ // the POINT rather than a problem. Dropping the stored print means the next step has
1886
+ // nothing to diff against and stays quiet, instead of announcing a cache reset the
1887
+ // user cannot act on and did not cause. Only an UNEXPLAINED break is worth a line.
1888
+ session.prefixPrint = undefined;
1889
+ // A compaction rewrites the transcript, so any MINDWEAVE.md edit the model was
1890
+ // relying on having written is now summarized away — and the prompt cache is being
1891
+ // discarded for this request regardless. Both reasons point the same way: this is
1892
+ // the moment to pick the file back up, and it costs nothing extra here.
1893
+ await reloadProjectMemory(session).catch(() => { });
1894
+ }
1895
+ /**
1896
+ * Reconcile the read ledger with the transcript, and put the working files back.
1897
+ *
1898
+ * Order matters and is the whole design. The ledger is SNAPSHOTTED, then CLEARED, then
1899
+ * repopulated only by the files actually restored — so afterwards it describes exactly
1900
+ * what the model can see, no more. Clearing is the correctness half and it happens
1901
+ * whether or not a single byte is restored: `ctx.reads` survives a compaction that
1902
+ * deleted the contents it describes, and a read-before-edit gate consulting a stale
1903
+ * ledger tells the model it has a file that is no longer on screen.
1904
+ *
1905
+ * Restoring is the smoothness half and is allowed to fail quietly. A file that has been
1906
+ * deleted, or grown past its share of the budget, simply is not put back; the model
1907
+ * reads it again, which is exactly what it would have done anyway.
1908
+ */
1909
+ async function restoreAfterCompaction(session) {
1910
+ const ctx = session.toolContext;
1911
+ const reads = ctx.reads;
1912
+ if (!reads || reads.size === 0)
1913
+ return;
1914
+ const snapshot = new Map(reads);
1915
+ // Nothing is carried forward for rule scoping any more. A scoped rule records that it
1916
+ // FIRED at the moment a matching path was touched, and that name is never removed —
1917
+ // so a compaction, which is only about what is on screen, cannot un-apply it. The
1918
+ // earlier fix copied every path forward to re-derive the same answer every step.
1919
+ // The correctness half. Unconditional, and before anything that can throw.
1920
+ reads.clear();
1921
+ const budget = restoreBudgetFor(envInt("MINDWEAVE_AUTOCOMPACT_TOKENS", autoCompactThreshold(session.modelConfig.model)));
1922
+ if (budget <= 0)
1923
+ return;
1924
+ // What the kept tail still shows. Re-sending a file the model can already see costs
1925
+ // its full length and buys nothing.
1926
+ const visible = fullReadPaths(session.transcript, (p) => {
1927
+ try {
1928
+ return resolvePath(ctx, p);
1929
+ }
1930
+ catch {
1931
+ return undefined;
1932
+ }
1933
+ });
1934
+ const picked = selectForRestore(snapshot, visible, (path) =>
1935
+ // MINDWEAVE.md is reloaded from disk by `reloadProjectMemory` on this same path, so
1936
+ // restoring it here would put the same bytes in twice.
1937
+ /(^|[\\/])MINDWEAVE\.md$/i.test(path));
1938
+ if (picked.length === 0)
1939
+ return;
1940
+ const restored = [];
1941
+ let spent = 0;
1942
+ for (const { path } of picked) {
1943
+ if (spent >= budget)
1944
+ break;
1945
+ try {
1946
+ const stat = await fsp.stat(path);
1947
+ // Cheap pre-filter on BYTES before reading: a file far past its share should not
1948
+ // be pulled into memory only to be discarded.
1949
+ if (estimateTokensForChars(stat.size) > Math.min(RESTORE_MAX_TOKENS_PER_FILE, budget - spent))
1950
+ continue;
1951
+ const content = await fsp.readFile(path, "utf8");
1952
+ if (!content.trim())
1953
+ continue;
1954
+ const cost = estimateTokens(content);
1955
+ // A file that would bust the remaining budget is skipped rather than truncated:
1956
+ // half a file restored under a heading that says "the file you were working in"
1957
+ // is the context-that-lies failure this whole path exists to end.
1958
+ if (spent + cost > budget)
1959
+ continue;
1960
+ restored.push({ path, content });
1961
+ spent += cost;
1962
+ // The ledger may claim this file again, because the model can now genuinely see
1963
+ // it. Recorded from the CURRENT stat, so the freshness gate compares against what
1964
+ // was just read rather than what was read before the compaction.
1965
+ const record = snapshot.get(path);
1966
+ reads.set(path, {
1967
+ mtimeMs: stat.mtimeMs,
1968
+ size: stat.size,
1969
+ full: true,
1970
+ touchedAt: Date.now(),
1971
+ ...(record?.focus ? { focus: record.focus } : {}),
1972
+ });
1973
+ }
1974
+ catch {
1975
+ /* a file that cannot be read now is simply not restored */
1976
+ }
1977
+ }
1978
+ if (restored.length === 0)
1979
+ return;
1980
+ // Placed immediately after the summary rather than at the end. Both positions render
1981
+ // as a user message and the codebase already emits consecutive ones (background
1982
+ // events do), but index 1 is the only position that cannot interact with tool
1983
+ // pairing in the kept tail no matter what the tail happens to end with.
1984
+ session.transcript.splice(1, 0, { role: "user", content: renderRestored(restored), synthetic: true });
1985
+ }
1986
+ /**
1987
+ * Did this call write the project's MINDWEAVE.md?
1988
+ *
1989
+ * Matched on the path's basename rather than resolved against the session root: the
1990
+ * model may pass it relative, absolute, or through a workspace root, and the cost of a
1991
+ * false positive is one extra re-read at the next compaction, while the cost of a false
1992
+ * negative is a stale project memory carried into the next session.
1993
+ */
1994
+ export function touchesProjectMemory(name, args) {
1995
+ if (name !== "edit" && name !== "write_file" && name !== "replace_symbol_body")
1996
+ return false;
1997
+ const path = typeof args.path === "string" ? args.path : "";
1998
+ return /(^|[\\/])MINDWEAVE\.md$/i.test(path.trim());
1999
+ }
2000
+ /** Parse a tool call's raw JSON arguments; malformed payload → {} so the tool
2001
+ * returns its own clear error rather than crashing the loop. */
2002
+ function parseArgs(raw) {
2003
+ try {
2004
+ const parsed = raw ? JSON.parse(raw) : {};
2005
+ return parsed && typeof parsed === "object" ? parsed : {};
2006
+ }
2007
+ catch {
2008
+ return {};
2009
+ }
2010
+ }
2011
+ //# sourceMappingURL=engine.js.map