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.
- package/LICENSE +190 -0
- package/README.md +171 -14
- package/dist/alternator/chassis/cache.js +47 -0
- package/dist/alternator/chassis/cache.js.map +1 -0
- package/dist/alternator/chassis/domRefs.js +53 -0
- package/dist/alternator/chassis/domRefs.js.map +1 -0
- package/dist/alternator/chassis/extractWorker.js +65 -0
- package/dist/alternator/chassis/extractWorker.js.map +1 -0
- package/dist/alternator/chassis/graph.js +221 -0
- package/dist/alternator/chassis/graph.js.map +1 -0
- package/dist/alternator/chassis/index.js +463 -0
- package/dist/alternator/chassis/index.js.map +1 -0
- package/dist/alternator/chassis/isolation.js +336 -0
- package/dist/alternator/chassis/isolation.js.map +1 -0
- package/dist/alternator/chassis/langCases.js +69 -0
- package/dist/alternator/chassis/langCases.js.map +1 -0
- package/dist/alternator/chassis/lsp.js +469 -0
- package/dist/alternator/chassis/lsp.js.map +1 -0
- package/dist/alternator/chassis/markup.js +262 -0
- package/dist/alternator/chassis/markup.js.map +1 -0
- package/dist/alternator/chassis/provision.js +198 -0
- package/dist/alternator/chassis/provision.js.map +1 -0
- package/dist/alternator/chassis/rank.js +72 -0
- package/dist/alternator/chassis/rank.js.map +1 -0
- package/dist/alternator/chassis/servers.js +215 -0
- package/dist/alternator/chassis/servers.js.map +1 -0
- package/dist/alternator/chassis/treesitter.js +608 -0
- package/dist/alternator/chassis/treesitter.js.map +1 -0
- package/dist/alternator/chassis/types.js +24 -0
- package/dist/alternator/chassis/types.js.map +1 -0
- package/dist/alternator/lane.js +67 -0
- package/dist/alternator/lane.js.map +1 -0
- package/dist/cli/App.js +2586 -0
- package/dist/cli/App.js.map +1 -0
- package/dist/cli/altScreen.js +67 -0
- package/dist/cli/altScreen.js.map +1 -0
- package/dist/cli/approvalChannel.js +72 -0
- package/dist/cli/approvalChannel.js.map +1 -0
- package/dist/cli/attachments.js +188 -0
- package/dist/cli/attachments.js.map +1 -0
- package/dist/cli/bootstrap.js +279 -0
- package/dist/cli/bootstrap.js.map +1 -0
- package/dist/cli/chatAnchor.js +94 -0
- package/dist/cli/chatAnchor.js.map +1 -0
- package/dist/cli/commandArgs.js +79 -0
- package/dist/cli/commandArgs.js.map +1 -0
- package/dist/cli/commandRoute.js +78 -0
- package/dist/cli/commandRoute.js.map +1 -0
- package/dist/cli/compaction.js +69 -0
- package/dist/cli/compaction.js.map +1 -0
- package/dist/cli/components/ApprovalBox.js +100 -0
- package/dist/cli/components/ApprovalBox.js.map +1 -0
- package/dist/cli/components/BlockView.js +128 -0
- package/dist/cli/components/BlockView.js.map +1 -0
- package/dist/cli/components/FirstRunFrame.js +41 -0
- package/dist/cli/components/FirstRunFrame.js.map +1 -0
- package/dist/cli/components/KeyManager.js +197 -0
- package/dist/cli/components/KeyManager.js.map +1 -0
- package/dist/cli/components/KeySetup.js +125 -0
- package/dist/cli/components/KeySetup.js.map +1 -0
- package/dist/cli/components/MenuFrame.js +24 -0
- package/dist/cli/components/MenuFrame.js.map +1 -0
- package/dist/cli/components/Picker.js +82 -0
- package/dist/cli/components/Picker.js.map +1 -0
- package/dist/cli/components/PromptInput.js +509 -0
- package/dist/cli/components/PromptInput.js.map +1 -0
- package/dist/cli/components/SubagentView.js +86 -0
- package/dist/cli/components/SubagentView.js.map +1 -0
- package/dist/cli/components/ToolGroup.js +60 -0
- package/dist/cli/components/ToolGroup.js.map +1 -0
- package/dist/cli/components/ToolLine.js +123 -0
- package/dist/cli/components/ToolLine.js.map +1 -0
- package/dist/cli/components/TrustGate.js +37 -0
- package/dist/cli/components/TrustGate.js.map +1 -0
- package/dist/cli/costView.js +40 -0
- package/dist/cli/costView.js.map +1 -0
- package/dist/cli/frameRate.js +30 -0
- package/dist/cli/frameRate.js.map +1 -0
- package/dist/cli/framebuffer/paint.js +165 -0
- package/dist/cli/framebuffer/paint.js.map +1 -0
- package/dist/cli/framebuffer/parse.js +266 -0
- package/dist/cli/framebuffer/parse.js.map +1 -0
- package/dist/cli/framebuffer/screen.js +196 -0
- package/dist/cli/framebuffer/screen.js.map +1 -0
- package/dist/cli/framebuffer/writer.js +139 -0
- package/dist/cli/framebuffer/writer.js.map +1 -0
- package/dist/cli/groupReveal.js +51 -0
- package/dist/cli/groupReveal.js.map +1 -0
- package/dist/cli/help.js +55 -0
- package/dist/cli/help.js.map +1 -0
- package/dist/cli/inputView.js +105 -0
- package/dist/cli/inputView.js.map +1 -0
- package/dist/cli/keyManager.js +65 -0
- package/dist/cli/keyManager.js.map +1 -0
- package/dist/cli/keySetup.js +35 -0
- package/dist/cli/keySetup.js.map +1 -0
- package/dist/cli/keyStore.js +73 -0
- package/dist/cli/keyStore.js.map +1 -0
- package/dist/cli/markdown.js +460 -0
- package/dist/cli/markdown.js.map +1 -0
- package/dist/cli/messageQueue.js +96 -0
- package/dist/cli/messageQueue.js.map +1 -0
- package/dist/cli/modes.js +90 -0
- package/dist/cli/modes.js.map +1 -0
- package/dist/cli/mouse.js +78 -0
- package/dist/cli/mouse.js.map +1 -0
- package/dist/cli/pasteAssembler.js +44 -0
- package/dist/cli/pasteAssembler.js.map +1 -0
- package/dist/cli/pathComplete.js +39 -0
- package/dist/cli/pathComplete.js.map +1 -0
- package/dist/cli/perfLog.js +90 -0
- package/dist/cli/perfLog.js.map +1 -0
- package/dist/cli/reveal.js +32 -0
- package/dist/cli/reveal.js.map +1 -0
- package/dist/cli/revealPace.js +59 -0
- package/dist/cli/revealPace.js.map +1 -0
- package/dist/cli/sessionCarry.js +19 -0
- package/dist/cli/sessionCarry.js.map +1 -0
- package/dist/cli/smoothScroll.js +74 -0
- package/dist/cli/smoothScroll.js.map +1 -0
- package/dist/cli/startupArgs.js +96 -0
- package/dist/cli/startupArgs.js.map +1 -0
- package/dist/cli/terminalRestore.js +34 -0
- package/dist/cli/terminalRestore.js.map +1 -0
- package/dist/cli/toolDisplay.js +219 -0
- package/dist/cli/toolDisplay.js.map +1 -0
- package/dist/cli/toolItems.js +65 -0
- package/dist/cli/toolItems.js.map +1 -0
- package/dist/cli/transcript.js +351 -0
- package/dist/cli/transcript.js.map +1 -0
- package/dist/cli/trust.js +79 -0
- package/dist/cli/trust.js.map +1 -0
- package/dist/cli/updateCheck.js +107 -0
- package/dist/cli/updateCheck.js.map +1 -0
- package/dist/cli/version.js +39 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/cli/viewport.js +179 -0
- package/dist/cli/viewport.js.map +1 -0
- package/dist/cli/virtualWindow.js +103 -0
- package/dist/cli/virtualWindow.js.map +1 -0
- package/dist/cli/workingVerb.js +37 -0
- package/dist/cli/workingVerb.js.map +1 -0
- package/dist/cli/wrap.js +145 -0
- package/dist/cli/wrap.js.map +1 -0
- package/dist/drivers/anthropic/cachePoints.js +77 -0
- package/dist/drivers/anthropic/cachePoints.js.map +1 -0
- package/dist/drivers/anthropic/client.js +432 -0
- package/dist/drivers/anthropic/client.js.map +1 -0
- package/dist/drivers/anthropic/index.js +10 -0
- package/dist/drivers/anthropic/index.js.map +1 -0
- package/dist/drivers/anthropic/manifest.js +221 -0
- package/dist/drivers/anthropic/manifest.js.map +1 -0
- package/dist/drivers/cerebras/client.js +69 -0
- package/dist/drivers/cerebras/client.js.map +1 -0
- package/dist/drivers/cerebras/index.js +5 -0
- package/dist/drivers/cerebras/index.js.map +1 -0
- package/dist/drivers/cerebras/manifest.js +140 -0
- package/dist/drivers/cerebras/manifest.js.map +1 -0
- package/dist/drivers/clientId.js +41 -0
- package/dist/drivers/clientId.js.map +1 -0
- package/dist/drivers/contextOverflow.js +77 -0
- package/dist/drivers/contextOverflow.js.map +1 -0
- package/dist/drivers/deepseek/client.js +168 -0
- package/dist/drivers/deepseek/client.js.map +1 -0
- package/dist/drivers/deepseek/index.js +16 -0
- package/dist/drivers/deepseek/index.js.map +1 -0
- package/dist/drivers/deepseek/inlineTools.js +46 -0
- package/dist/drivers/deepseek/inlineTools.js.map +1 -0
- package/dist/drivers/deepseek/manifest.js +166 -0
- package/dist/drivers/deepseek/manifest.js.map +1 -0
- package/dist/drivers/gemini/client.js +38 -0
- package/dist/drivers/gemini/client.js.map +1 -0
- package/dist/drivers/gemini/index.js +5 -0
- package/dist/drivers/gemini/index.js.map +1 -0
- package/dist/drivers/gemini/manifest.js +150 -0
- package/dist/drivers/gemini/manifest.js.map +1 -0
- package/dist/drivers/glm/client.js +90 -0
- package/dist/drivers/glm/client.js.map +1 -0
- package/dist/drivers/glm/index.js +9 -0
- package/dist/drivers/glm/index.js.map +1 -0
- package/dist/drivers/glm/manifest.js +183 -0
- package/dist/drivers/glm/manifest.js.map +1 -0
- package/dist/drivers/groq/client.js +80 -0
- package/dist/drivers/groq/client.js.map +1 -0
- package/dist/drivers/groq/index.js +5 -0
- package/dist/drivers/groq/index.js.map +1 -0
- package/dist/drivers/groq/manifest.js +160 -0
- package/dist/drivers/groq/manifest.js.map +1 -0
- package/dist/drivers/kimi/client.js +82 -0
- package/dist/drivers/kimi/client.js.map +1 -0
- package/dist/drivers/kimi/index.js +9 -0
- package/dist/drivers/kimi/index.js.map +1 -0
- package/dist/drivers/kimi/manifest.js +168 -0
- package/dist/drivers/kimi/manifest.js.map +1 -0
- package/dist/drivers/meta/client.js +30 -0
- package/dist/drivers/meta/client.js.map +1 -0
- package/dist/drivers/meta/index.js +5 -0
- package/dist/drivers/meta/index.js.map +1 -0
- package/dist/drivers/meta/manifest.js +101 -0
- package/dist/drivers/meta/manifest.js.map +1 -0
- package/dist/drivers/minimax/client.js +36 -0
- package/dist/drivers/minimax/client.js.map +1 -0
- package/dist/drivers/minimax/index.js +5 -0
- package/dist/drivers/minimax/index.js.map +1 -0
- package/dist/drivers/minimax/manifest.js +120 -0
- package/dist/drivers/minimax/manifest.js.map +1 -0
- package/dist/drivers/mistral/client.js +54 -0
- package/dist/drivers/mistral/client.js.map +1 -0
- package/dist/drivers/mistral/index.js +5 -0
- package/dist/drivers/mistral/index.js.map +1 -0
- package/dist/drivers/mistral/manifest.js +118 -0
- package/dist/drivers/mistral/manifest.js.map +1 -0
- package/dist/drivers/openai/client.js +290 -0
- package/dist/drivers/openai/client.js.map +1 -0
- package/dist/drivers/openai/index.js +9 -0
- package/dist/drivers/openai/index.js.map +1 -0
- package/dist/drivers/openai/manifest.js +133 -0
- package/dist/drivers/openai/manifest.js.map +1 -0
- package/dist/drivers/openaiCompat/wire.js +493 -0
- package/dist/drivers/openaiCompat/wire.js.map +1 -0
- package/dist/drivers/partialTurn.js +23 -0
- package/dist/drivers/partialTurn.js.map +1 -0
- package/dist/drivers/providerError.js +181 -0
- package/dist/drivers/providerError.js.map +1 -0
- package/dist/drivers/qwen/client.js +93 -0
- package/dist/drivers/qwen/client.js.map +1 -0
- package/dist/drivers/qwen/index.js +9 -0
- package/dist/drivers/qwen/index.js.map +1 -0
- package/dist/drivers/qwen/manifest.js +146 -0
- package/dist/drivers/qwen/manifest.js.map +1 -0
- package/dist/drivers/registry.js +177 -0
- package/dist/drivers/registry.js.map +1 -0
- package/dist/drivers/retryPolicy.js +129 -0
- package/dist/drivers/retryPolicy.js.map +1 -0
- package/dist/drivers/searchBlocks.js +61 -0
- package/dist/drivers/searchBlocks.js.map +1 -0
- package/dist/drivers/types.js +2 -0
- package/dist/drivers/types.js.map +1 -0
- package/dist/drivers/xai/client.js +63 -0
- package/dist/drivers/xai/client.js.map +1 -0
- package/dist/drivers/xai/index.js +5 -0
- package/dist/drivers/xai/index.js.map +1 -0
- package/dist/drivers/xai/manifest.js +137 -0
- package/dist/drivers/xai/manifest.js.map +1 -0
- package/dist/dynamo/_probe.tmp.js +20 -0
- package/dist/dynamo/_probe.tmp.js.map +1 -0
- package/dist/dynamo/cacheBreak.js +88 -0
- package/dist/dynamo/cacheBreak.js.map +1 -0
- package/dist/dynamo/contextWindow.js +243 -0
- package/dist/dynamo/contextWindow.js.map +1 -0
- package/dist/dynamo/engine.js +2011 -0
- package/dist/dynamo/engine.js.map +1 -0
- package/dist/dynamo/guard.js +132 -0
- package/dist/dynamo/guard.js.map +1 -0
- package/dist/dynamo/liveMeter.js +58 -0
- package/dist/dynamo/liveMeter.js.map +1 -0
- package/dist/dynamo/model.js +172 -0
- package/dist/dynamo/model.js.map +1 -0
- package/dist/dynamo/planArtifact.js +125 -0
- package/dist/dynamo/planArtifact.js.map +1 -0
- package/dist/dynamo/pricing.js +140 -0
- package/dist/dynamo/pricing.js.map +1 -0
- package/dist/dynamo/prompt.js +264 -0
- package/dist/dynamo/prompt.js.map +1 -0
- package/dist/dynamo/spend.js +45 -0
- package/dist/dynamo/spend.js.map +1 -0
- package/dist/dynamo/verify.js +483 -0
- package/dist/dynamo/verify.js.map +1 -0
- package/dist/governor/forbidden.js +181 -0
- package/dist/governor/forbidden.js.map +1 -0
- package/dist/governor/freshness.js +83 -0
- package/dist/governor/freshness.js.map +1 -0
- package/dist/governor/frontmatter.js +38 -0
- package/dist/governor/frontmatter.js.map +1 -0
- package/dist/governor/glob.js +75 -0
- package/dist/governor/glob.js.map +1 -0
- package/dist/governor/index.js +87 -0
- package/dist/governor/index.js.map +1 -0
- package/dist/governor/rules.js +80 -0
- package/dist/governor/rules.js.map +1 -0
- package/dist/governor/scope.js +91 -0
- package/dist/governor/scope.js.map +1 -0
- package/dist/governor/skills.js +176 -0
- package/dist/governor/skills.js.map +1 -0
- package/dist/governor/types.js +16 -0
- package/dist/governor/types.js.map +1 -0
- package/dist/governor/write.js +182 -0
- package/dist/governor/write.js.map +1 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/catalog.js +232 -0
- package/dist/mcp/catalog.js.map +1 -0
- package/dist/mcp/config.js +116 -0
- package/dist/mcp/config.js.map +1 -0
- package/dist/mcp/configWrite.js +254 -0
- package/dist/mcp/configWrite.js.map +1 -0
- package/dist/mcp/connection.js +504 -0
- package/dist/mcp/connection.js.map +1 -0
- package/dist/mcp/deferred.js +104 -0
- package/dist/mcp/deferred.js.map +1 -0
- package/dist/mcp/discover.js +128 -0
- package/dist/mcp/discover.js.map +1 -0
- package/dist/mcp/manager.js +679 -0
- package/dist/mcp/manager.js.map +1 -0
- package/dist/mcp/paramHeaders.js +163 -0
- package/dist/mcp/paramHeaders.js.map +1 -0
- package/dist/mcp/prompts.js +136 -0
- package/dist/mcp/prompts.js.map +1 -0
- package/dist/mcp/protocol.js +171 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/resources.js +119 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/resultStore.js +180 -0
- package/dist/mcp/resultStore.js.map +1 -0
- package/dist/mcp/subscriptions.js +74 -0
- package/dist/mcp/subscriptions.js.map +1 -0
- package/dist/mcp/transport/headerValue.js +40 -0
- package/dist/mcp/transport/headerValue.js.map +1 -0
- package/dist/mcp/transport/http.js +321 -0
- package/dist/mcp/transport/http.js.map +1 -0
- package/dist/mcp/transport/stdio.js +234 -0
- package/dist/mcp/transport/stdio.js.map +1 -0
- package/dist/mcp/transport/types.js +27 -0
- package/dist/mcp/transport/types.js.map +1 -0
- package/dist/mcp/trust.js +98 -0
- package/dist/mcp/trust.js.map +1 -0
- package/dist/mcp/trustStore.js +55 -0
- package/dist/mcp/trustStore.js.map +1 -0
- package/dist/memory/autoMemory.js +340 -0
- package/dist/memory/autoMemory.js.map +1 -0
- package/dist/memory/compaction.js +513 -0
- package/dist/memory/compaction.js.map +1 -0
- package/dist/memory/images.js +173 -0
- package/dist/memory/images.js.map +1 -0
- package/dist/memory/presence.js +59 -0
- package/dist/memory/presence.js.map +1 -0
- package/dist/memory/projectNotes.js +247 -0
- package/dist/memory/projectNotes.js.map +1 -0
- package/dist/memory/restore.js +72 -0
- package/dist/memory/restore.js.map +1 -0
- package/dist/memory/session.js +472 -0
- package/dist/memory/session.js.map +1 -0
- package/dist/memory/sessionMemory.js +142 -0
- package/dist/memory/sessionMemory.js.map +1 -0
- package/dist/memory/sessionMemoryCompact.js +136 -0
- package/dist/memory/sessionMemoryCompact.js.map +1 -0
- package/dist/memory/store.js +217 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/memory/types.js +14 -0
- package/dist/memory/types.js.map +1 -0
- package/dist/memory/workingSet.js +383 -0
- package/dist/memory/workingSet.js.map +1 -0
- package/dist/project/context.js +425 -0
- package/dist/project/context.js.map +1 -0
- package/dist/tools/approval.js +189 -0
- package/dist/tools/approval.js.map +1 -0
- package/dist/tools/askUser.js +103 -0
- package/dist/tools/askUser.js.map +1 -0
- package/dist/tools/atomicWrite.js +151 -0
- package/dist/tools/atomicWrite.js.map +1 -0
- package/dist/tools/backgroundShells.js +468 -0
- package/dist/tools/backgroundShells.js.map +1 -0
- package/dist/tools/captureSweep.js +77 -0
- package/dist/tools/captureSweep.js.map +1 -0
- package/dist/tools/chassisMux.js +92 -0
- package/dist/tools/chassisMux.js.map +1 -0
- package/dist/tools/checkpoints.js +328 -0
- package/dist/tools/checkpoints.js.map +1 -0
- package/dist/tools/codeIntel.js +267 -0
- package/dist/tools/codeIntel.js.map +1 -0
- package/dist/tools/deferredNative.js +121 -0
- package/dist/tools/deferredNative.js.map +1 -0
- package/dist/tools/detail.js +135 -0
- package/dist/tools/detail.js.map +1 -0
- package/dist/tools/diagnostics.js +174 -0
- package/dist/tools/diagnostics.js.map +1 -0
- package/dist/tools/edit.js +167 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/editCore.js +283 -0
- package/dist/tools/editCore.js.map +1 -0
- package/dist/tools/editFile.js +183 -0
- package/dist/tools/editFile.js.map +1 -0
- package/dist/tools/editRipple.js +126 -0
- package/dist/tools/editRipple.js.map +1 -0
- package/dist/tools/editTarget.js +131 -0
- package/dist/tools/editTarget.js.map +1 -0
- package/dist/tools/editWindow.js +49 -0
- package/dist/tools/editWindow.js.map +1 -0
- package/dist/tools/eol.js +56 -0
- package/dist/tools/eol.js.map +1 -0
- package/dist/tools/exitPlan.js +175 -0
- package/dist/tools/exitPlan.js.map +1 -0
- package/dist/tools/focus.js +42 -0
- package/dist/tools/focus.js.map +1 -0
- package/dist/tools/gitignore.js +148 -0
- package/dist/tools/gitignore.js.map +1 -0
- package/dist/tools/glob.js +134 -0
- package/dist/tools/glob.js.map +1 -0
- package/dist/tools/governorTools.js +258 -0
- package/dist/tools/governorTools.js.map +1 -0
- package/dist/tools/grep.js +450 -0
- package/dist/tools/grep.js.map +1 -0
- package/dist/tools/guard.js +220 -0
- package/dist/tools/guard.js.map +1 -0
- package/dist/tools/killTree.js +215 -0
- package/dist/tools/killTree.js.map +1 -0
- package/dist/tools/listDir.js +143 -0
- package/dist/tools/listDir.js.map +1 -0
- package/dist/tools/mcpAdd.js +176 -0
- package/dist/tools/mcpAdd.js.map +1 -0
- package/dist/tools/mcpResources.js +116 -0
- package/dist/tools/mcpResources.js.map +1 -0
- package/dist/tools/mcpSearch.js +123 -0
- package/dist/tools/mcpSearch.js.map +1 -0
- package/dist/tools/multiEdit.js +168 -0
- package/dist/tools/multiEdit.js.map +1 -0
- package/dist/tools/paths.js +225 -0
- package/dist/tools/paths.js.map +1 -0
- package/dist/tools/posixShell.js +123 -0
- package/dist/tools/posixShell.js.map +1 -0
- package/dist/tools/readFile.js +371 -0
- package/dist/tools/readFile.js.map +1 -0
- package/dist/tools/readSymbol.js +145 -0
- package/dist/tools/readSymbol.js.map +1 -0
- package/dist/tools/registry.js +120 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/replaceSymbol.js +141 -0
- package/dist/tools/replaceSymbol.js.map +1 -0
- package/dist/tools/results.js +15 -0
- package/dist/tools/results.js.map +1 -0
- package/dist/tools/ripgrep.js +145 -0
- package/dist/tools/ripgrep.js.map +1 -0
- package/dist/tools/runCommand.js +633 -0
- package/dist/tools/runCommand.js.map +1 -0
- package/dist/tools/saveMemory.js +102 -0
- package/dist/tools/saveMemory.js.map +1 -0
- package/dist/tools/screenshot.js +200 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/screenshotWin.js +227 -0
- package/dist/tools/screenshotWin.js.map +1 -0
- package/dist/tools/search.js +121 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/sessionTools.js +187 -0
- package/dist/tools/sessionTools.js.map +1 -0
- package/dist/tools/shellCheckpoint.js +123 -0
- package/dist/tools/shellCheckpoint.js.map +1 -0
- package/dist/tools/shellLint.js +102 -0
- package/dist/tools/shellLint.js.map +1 -0
- package/dist/tools/shellTools.js +203 -0
- package/dist/tools/shellTools.js.map +1 -0
- package/dist/tools/spanCore.js +78 -0
- package/dist/tools/spanCore.js.map +1 -0
- package/dist/tools/subagent.js +224 -0
- package/dist/tools/subagent.js.map +1 -0
- package/dist/tools/subagentReport.js +114 -0
- package/dist/tools/subagentReport.js.map +1 -0
- package/dist/tools/tempSweep.js +118 -0
- package/dist/tools/tempSweep.js.map +1 -0
- package/dist/tools/todo.js +117 -0
- package/dist/tools/todo.js.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/untrusted.js +46 -0
- package/dist/tools/untrusted.js.map +1 -0
- package/dist/tools/useSkill.js +62 -0
- package/dist/tools/useSkill.js.map +1 -0
- package/dist/tools/walk.js +152 -0
- package/dist/tools/walk.js.map +1 -0
- package/dist/tools/web.js +61 -0
- package/dist/tools/web.js.map +1 -0
- package/dist/tools/webFetch.js +444 -0
- package/dist/tools/webFetch.js.map +1 -0
- package/dist/tools/webSearch.js +130 -0
- package/dist/tools/webSearch.js.map +1 -0
- package/dist/tools/workspace.js +212 -0
- package/dist/tools/workspace.js.map +1 -0
- package/dist/tools/workspaceDiscover.js +216 -0
- package/dist/tools/workspaceDiscover.js.map +1 -0
- package/dist/tools/writeFile.js +162 -0
- package/dist/tools/writeFile.js.map +1 -0
- 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
|