mindweave 2.5.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/README.md +50 -114
  2. package/dist/cli/App.js +578 -303
  3. package/dist/cli/App.js.map +1 -1
  4. package/dist/cli/analytics.js +27 -18
  5. package/dist/cli/analytics.js.map +1 -1
  6. package/dist/cli/attachments.js +34 -0
  7. package/dist/cli/attachments.js.map +1 -1
  8. package/dist/cli/bannerFit.js +70 -0
  9. package/dist/cli/bannerFit.js.map +1 -0
  10. package/dist/cli/bootstrap.js +41 -3
  11. package/dist/cli/bootstrap.js.map +1 -1
  12. package/dist/cli/commands.js +4 -1
  13. package/dist/cli/commands.js.map +1 -1
  14. package/dist/cli/components/ApprovalBox.js +3 -2
  15. package/dist/cli/components/ApprovalBox.js.map +1 -1
  16. package/dist/cli/components/BlockView.js +27 -6
  17. package/dist/cli/components/BlockView.js.map +1 -1
  18. package/dist/cli/components/FirstRunFrame.js +2 -1
  19. package/dist/cli/components/FirstRunFrame.js.map +1 -1
  20. package/dist/cli/components/KeyManager.js +5 -4
  21. package/dist/cli/components/KeyManager.js.map +1 -1
  22. package/dist/cli/components/KeySetup.js +29 -13
  23. package/dist/cli/components/KeySetup.js.map +1 -1
  24. package/dist/cli/components/MarathonBox.js +64 -0
  25. package/dist/cli/components/MarathonBox.js.map +1 -0
  26. package/dist/cli/components/McpMinitabs.js +8 -7
  27. package/dist/cli/components/McpMinitabs.js.map +1 -1
  28. package/dist/cli/components/Picker.js +4 -3
  29. package/dist/cli/components/Picker.js.map +1 -1
  30. package/dist/cli/components/PromptInput.js +47 -8
  31. package/dist/cli/components/PromptInput.js.map +1 -1
  32. package/dist/cli/components/PulseDot.js +42 -0
  33. package/dist/cli/components/PulseDot.js.map +1 -0
  34. package/dist/cli/components/SubagentView.js +9 -6
  35. package/dist/cli/components/SubagentView.js.map +1 -1
  36. package/dist/cli/components/TipLine.js +2 -1
  37. package/dist/cli/components/TipLine.js.map +1 -1
  38. package/dist/cli/components/ToolGroup.js +65 -49
  39. package/dist/cli/components/ToolGroup.js.map +1 -1
  40. package/dist/cli/components/ToolLine.js +20 -19
  41. package/dist/cli/components/ToolLine.js.map +1 -1
  42. package/dist/cli/components/TrustGate.js +2 -1
  43. package/dist/cli/components/TrustGate.js.map +1 -1
  44. package/dist/cli/components/minitabs.js +3 -2
  45. package/dist/cli/components/minitabs.js.map +1 -1
  46. package/dist/cli/desktopApp.js +45 -0
  47. package/dist/cli/desktopApp.js.map +1 -0
  48. package/dist/cli/help.js +1 -0
  49. package/dist/cli/help.js.map +1 -1
  50. package/dist/cli/inkOutputPatch.js +285 -0
  51. package/dist/cli/inkOutputPatch.js.map +1 -0
  52. package/dist/cli/inkSpeed.js +29 -0
  53. package/dist/cli/inkSpeed.js.map +1 -0
  54. package/dist/cli/inkSpeedLoader.js +47 -0
  55. package/dist/cli/inkSpeedLoader.js.map +1 -0
  56. package/dist/cli/inputText.js +30 -0
  57. package/dist/cli/inputText.js.map +1 -0
  58. package/dist/cli/keyManager.js +2 -1
  59. package/dist/cli/keyManager.js.map +1 -1
  60. package/dist/cli/keyPrefs.js +90 -0
  61. package/dist/cli/keyPrefs.js.map +1 -0
  62. package/dist/cli/keySetup.js +5 -2
  63. package/dist/cli/keySetup.js.map +1 -1
  64. package/dist/cli/marathonUi.js +113 -0
  65. package/dist/cli/marathonUi.js.map +1 -0
  66. package/dist/cli/markdown.js +63 -11
  67. package/dist/cli/markdown.js.map +1 -1
  68. package/dist/cli/modes.js +3 -3
  69. package/dist/cli/modes.js.map +1 -1
  70. package/dist/cli/prodEnv.js +43 -0
  71. package/dist/cli/prodEnv.js.map +1 -0
  72. package/dist/cli/revealPace.js +5 -5
  73. package/dist/cli/revealPace.js.map +1 -1
  74. package/dist/cli/revealQueue.js +120 -0
  75. package/dist/cli/revealQueue.js.map +1 -0
  76. package/dist/cli/theme.js +47 -0
  77. package/dist/cli/theme.js.map +1 -0
  78. package/dist/cli/toolDisplay.js +112 -18
  79. package/dist/cli/toolDisplay.js.map +1 -1
  80. package/dist/cli/transcript.js +1 -1
  81. package/dist/cli/transcript.js.map +1 -1
  82. package/dist/cli/virtualWindow.js +14 -2
  83. package/dist/cli/virtualWindow.js.map +1 -1
  84. package/dist/cli/wheelAccel.js +73 -0
  85. package/dist/cli/wheelAccel.js.map +1 -0
  86. package/dist/cli/widestLinePatch.js +48 -0
  87. package/dist/cli/widestLinePatch.js.map +1 -0
  88. package/dist/cli/workingVerb.js +88 -28
  89. package/dist/cli/workingVerb.js.map +1 -1
  90. package/dist/cli/wrap.js +1 -1
  91. package/dist/cli/wrap.js.map +1 -1
  92. package/dist/core/contextSettings.js +84 -0
  93. package/dist/core/contextSettings.js.map +1 -0
  94. package/dist/core/limitsView.js +40 -0
  95. package/dist/core/limitsView.js.map +1 -0
  96. package/dist/core/mcpServers.js +164 -0
  97. package/dist/core/mcpServers.js.map +1 -0
  98. package/dist/core/permissions.js +209 -0
  99. package/dist/core/permissions.js.map +1 -0
  100. package/dist/core/providerKeys.js +187 -0
  101. package/dist/core/providerKeys.js.map +1 -0
  102. package/dist/core/rulesSkills.js +142 -0
  103. package/dist/core/rulesSkills.js.map +1 -0
  104. package/dist/core/spendView.js +90 -0
  105. package/dist/core/spendView.js.map +1 -0
  106. package/dist/core/turnRunner.js +453 -60
  107. package/dist/core/turnRunner.js.map +1 -1
  108. package/dist/drivers/anthropic/client.js +24 -5
  109. package/dist/drivers/anthropic/client.js.map +1 -1
  110. package/dist/drivers/anthropic/manifest.js +15 -2
  111. package/dist/drivers/anthropic/manifest.js.map +1 -1
  112. package/dist/drivers/cerebras/manifest.js +6 -2
  113. package/dist/drivers/cerebras/manifest.js.map +1 -1
  114. package/dist/drivers/deepseek/manifest.js +12 -3
  115. package/dist/drivers/deepseek/manifest.js.map +1 -1
  116. package/dist/drivers/glm/manifest.js +3 -1
  117. package/dist/drivers/glm/manifest.js.map +1 -1
  118. package/dist/drivers/groq/manifest.js +5 -2
  119. package/dist/drivers/groq/manifest.js.map +1 -1
  120. package/dist/drivers/ollama/catalog.js +71 -0
  121. package/dist/drivers/ollama/catalog.js.map +1 -0
  122. package/dist/drivers/ollama/client.js +164 -0
  123. package/dist/drivers/ollama/client.js.map +1 -0
  124. package/dist/drivers/ollama/endpoint.js +42 -0
  125. package/dist/drivers/ollama/endpoint.js.map +1 -0
  126. package/dist/drivers/ollama/index.js +5 -0
  127. package/dist/drivers/ollama/index.js.map +1 -0
  128. package/dist/drivers/ollama/manifest.js +64 -0
  129. package/dist/drivers/ollama/manifest.js.map +1 -0
  130. package/dist/drivers/openai/client.js +10 -4
  131. package/dist/drivers/openai/client.js.map +1 -1
  132. package/dist/drivers/openai/manifest.js +46 -12
  133. package/dist/drivers/openai/manifest.js.map +1 -1
  134. package/dist/drivers/registry.js +11 -0
  135. package/dist/drivers/registry.js.map +1 -1
  136. package/dist/drivers/retryPolicy.js +26 -1
  137. package/dist/drivers/retryPolicy.js.map +1 -1
  138. package/dist/dynamo/auxModel.js +54 -0
  139. package/dist/dynamo/auxModel.js.map +1 -0
  140. package/dist/dynamo/engine.js +380 -99
  141. package/dist/dynamo/engine.js.map +1 -1
  142. package/dist/dynamo/guard.js +10 -0
  143. package/dist/dynamo/guard.js.map +1 -1
  144. package/dist/dynamo/liveMeter.js +25 -6
  145. package/dist/dynamo/liveMeter.js.map +1 -1
  146. package/dist/dynamo/marathon.js +409 -0
  147. package/dist/dynamo/marathon.js.map +1 -0
  148. package/dist/dynamo/marathonPrompt.js +45 -0
  149. package/dist/dynamo/marathonPrompt.js.map +1 -0
  150. package/dist/dynamo/model.js +6 -2
  151. package/dist/dynamo/model.js.map +1 -1
  152. package/dist/dynamo/toolBatches.js +58 -0
  153. package/dist/dynamo/toolBatches.js.map +1 -0
  154. package/dist/dynamo/usageLimits.js +446 -0
  155. package/dist/dynamo/usageLimits.js.map +1 -0
  156. package/dist/governor/freshness.js +10 -7
  157. package/dist/governor/freshness.js.map +1 -1
  158. package/dist/governor/index.js +76 -9
  159. package/dist/governor/index.js.map +1 -1
  160. package/dist/governor/write.js +43 -14
  161. package/dist/governor/write.js.map +1 -1
  162. package/dist/index.js +18 -151
  163. package/dist/index.js.map +1 -1
  164. package/dist/main.js +162 -0
  165. package/dist/main.js.map +1 -0
  166. package/dist/mcp/manager.js +18 -0
  167. package/dist/mcp/manager.js.map +1 -1
  168. package/dist/memory/allMetas.js +69 -0
  169. package/dist/memory/allMetas.js.map +1 -0
  170. package/dist/memory/compaction.js +69 -14
  171. package/dist/memory/compaction.js.map +1 -1
  172. package/dist/memory/earlier.js +15 -0
  173. package/dist/memory/earlier.js.map +1 -0
  174. package/dist/memory/profile.js +93 -0
  175. package/dist/memory/profile.js.map +1 -0
  176. package/dist/memory/rewind.js +262 -0
  177. package/dist/memory/rewind.js.map +1 -0
  178. package/dist/memory/session.js +42 -10
  179. package/dist/memory/session.js.map +1 -1
  180. package/dist/memory/sessionMemory.js +48 -13
  181. package/dist/memory/sessionMemory.js.map +1 -1
  182. package/dist/memory/sessionMemoryCompact.js +1 -1
  183. package/dist/memory/sessionMemoryCompact.js.map +1 -1
  184. package/dist/memory/sessionTitle.js +50 -0
  185. package/dist/memory/sessionTitle.js.map +1 -0
  186. package/dist/memory/store.js +42 -1
  187. package/dist/memory/store.js.map +1 -1
  188. package/dist/memory/types.js.map +1 -1
  189. package/dist/tools/askUser.js +14 -0
  190. package/dist/tools/askUser.js.map +1 -1
  191. package/dist/tools/backgroundShells.js +208 -5
  192. package/dist/tools/backgroundShells.js.map +1 -1
  193. package/dist/tools/browser.js +176 -0
  194. package/dist/tools/browser.js.map +1 -0
  195. package/dist/tools/cdp.js +165 -0
  196. package/dist/tools/cdp.js.map +1 -0
  197. package/dist/tools/checkpoints.js +208 -5
  198. package/dist/tools/checkpoints.js.map +1 -1
  199. package/dist/tools/deferredNative.js +2 -0
  200. package/dist/tools/deferredNative.js.map +1 -1
  201. package/dist/tools/exitPlan.js +4 -0
  202. package/dist/tools/exitPlan.js.map +1 -1
  203. package/dist/tools/hiddenDesktop.js +273 -0
  204. package/dist/tools/hiddenDesktop.js.map +1 -0
  205. package/dist/tools/listeningPorts.js +134 -0
  206. package/dist/tools/listeningPorts.js.map +1 -0
  207. package/dist/tools/mcpSearch.js +8 -0
  208. package/dist/tools/mcpSearch.js.map +1 -1
  209. package/dist/tools/mindweaveStatus.js +1 -1
  210. package/dist/tools/mindweaveStatus.js.map +1 -1
  211. package/dist/tools/registry.js +3 -0
  212. package/dist/tools/registry.js.map +1 -1
  213. package/dist/tools/runCommand.js +107 -6
  214. package/dist/tools/runCommand.js.map +1 -1
  215. package/dist/tools/screenshot.js +4 -6
  216. package/dist/tools/screenshot.js.map +1 -1
  217. package/dist/tools/screenshotWin.js +26 -9
  218. package/dist/tools/screenshotWin.js.map +1 -1
  219. package/dist/tools/stateCheckpoint.js +128 -0
  220. package/dist/tools/stateCheckpoint.js.map +1 -0
  221. package/dist/tools/todo.js +6 -0
  222. package/dist/tools/todo.js.map +1 -1
  223. package/dist/tools/ui.js +1295 -0
  224. package/dist/tools/ui.js.map +1 -0
  225. package/dist/tools/uiHide.js +356 -0
  226. package/dist/tools/uiHide.js.map +1 -0
  227. package/dist/tools/uiPage.js +861 -0
  228. package/dist/tools/uiPage.js.map +1 -0
  229. package/dist/tools/uiWin.js +506 -0
  230. package/dist/tools/uiWin.js.map +1 -0
  231. package/dist/tools/webFetch.js +32 -25
  232. package/dist/tools/webFetch.js.map +1 -1
  233. package/dist/tools/webSearch.js +12 -0
  234. package/dist/tools/webSearch.js.map +1 -1
  235. package/package.json +78 -78
@@ -13,8 +13,11 @@
13
13
  * the read-only tool exactly like any other tool call — so this whole function
14
14
  * can later move to a server unchanged, with tools executing on the client.
15
15
  */
16
+ import { readProfile, profilePrompt } from "../memory/profile.js";
17
+ import { narrationShown, noteReads } from "../cli/toolDisplay.js";
16
18
  import { activeDriver, ensureDriver, manifestForModel } from "../drivers/registry.js";
17
19
  import { summarizeTask, taskLimitReason } from "./pricing.js";
20
+ import { limitGateReason, noteUsage, refreshUsageLimits, takeLimitWarnings } from "./usageLimits.js";
18
21
  import { addTurn, emptySpend } from "./spend.js";
19
22
  import { mutationNeedsVerification, isVerification, reScopeCheck, isBackgroundPollStep, stepFailureSignature, repeatFailureStep, repeatFailureNudge, failedActionLabel, firstErrorLine, sameFileEditCounts, overusedSingleEdits, batchEditNudge, narrationFault, narrationNudge, unknownToolError, replyFault, replyRewrite, VERIFY_NUDGE } from "./verify.js";
20
23
  import { guardOptions, GUARD_REFUSAL, GUARD_REFUSAL_INPUT, guardRefusalWith, guardQuestion, guardDetail, interpretGuardChoice } from "./guard.js";
@@ -23,19 +26,24 @@ import { findTool, toolSchemas, TOOLS } from "../tools/registry.js";
23
26
  import { deferredToolsIndex } from "../tools/deferredNative.js";
24
27
  import { prefixPrint, diffPrefix, cacheCallLine, writeCacheLog } from "./cacheBreak.js";
25
28
  import { commandShellLabel } from "../tools/runCommand.js";
29
+ import { uiLiveState } from "../tools/ui.js";
30
+ import { isAgenticOnlyRefusal, withAuxModel } from "./auxModel.js";
31
+ import { marathonBlock } from "./marathonPrompt.js";
26
32
  import { basePrompt } from "./prompt.js";
27
33
  import { basename } from "node:path";
28
34
  import { randomUUID } from "node:crypto";
29
35
  import { promises as fsp } from "node:fs";
30
36
  import { relativize, resolvePath, rootLabel, rootsOf } from "../tools/paths.js";
31
37
  import { renderRules, renderSkillCatalog, reloadGovernance, governanceStamp, rescope } from "../governor/index.js";
32
- import { forkSession, reloadProjectMemory } from "../memory/session.js";
38
+ import { writesAgentState, snapshotAgentState, recordAgentStateChanges } from "../tools/stateCheckpoint.js";
39
+ import { forkSession, reconcileInterruptedTools, reloadProjectMemory, repairToolCallOrder } from "../memory/session.js";
33
40
  import { selectActiveFiles } from "../memory/workingSet.js";
34
41
  import { directoryNotesFor } from "../memory/projectNotes.js";
35
42
  import { rippleCheck } from "../tools/editRipple.js";
36
43
  import { fullReadPaths } from "../memory/presence.js";
37
44
  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";
45
+ import { KEEP_LAST_N, KEEP_LAST_N_BOUNDARY, summaryRequest, SUMMARY_SYSTEM_PROMPT, dropOldestRounds, estimateEntriesTokens, estimateTokens, estimateTokensForChars, formatTranscriptForSummary, isContinuation, microcompact, spliceSummary, summaryForWire, summaryIsHistory, usableSummary, } from "../memory/compaction.js";
46
+ import { MAX_PARALLEL_CALLS, partitionCalls, runLimited } from "./toolBatches.js";
39
47
  import { loadPlanArtifact, completePlanArtifact, renderPlanBlock, planDivergenceStop } from "./planArtifact.js";
40
48
  import { autoCompactThreshold, microCompactThreshold, cacheLikelyCold, clearIsWorthIt, measuredOverhead, sharpContextWindow, } from "./contextWindow.js";
41
49
  import { renderSessionMemory, shouldUpdateSessionMemory, updateSessionMemory } from "../memory/sessionMemory.js";
@@ -43,6 +51,7 @@ import { compactFromSessionMemory } from "../memory/sessionMemoryCompact.js";
43
51
  import { isContextOverflowError } from "../drivers/contextOverflow.js";
44
52
  import { detailOf, providerMessage } from "../drivers/providerError.js";
45
53
  import { transcriptPath } from "../memory/store.js";
54
+ import { replaceTranscript } from "../memory/earlier.js";
46
55
  /** Stop retrying autocompact after this many consecutive failures in a session, so a
47
56
  * transcript that's irrecoverably over the limit can't hammer the summarizer each turn
48
57
  * (a circuit-breaker for runaway retry loops, which can otherwise pile up thousands of doomed retries). */
@@ -55,14 +64,19 @@ const MAX_COMPACT_FAILURES = 3;
55
64
  // line we hold is the thin-prompt boundary: rich on what the harness owns, but
56
65
  // we still do NOT teach engineering judgment (how to debug, how to write code) —
57
66
  // that is the model's job.
58
- export function staticSystemPrompt(projectContext, projectMemory, memoryDir, memoryIndex, governance, workspace, priorSessions = 0) {
67
+ export function staticSystemPrompt(projectContext, projectMemory, memoryDir, memoryIndex, governance, workspace, priorSessions = 0, aboutUser = "") {
59
68
  let prompt = basePrompt(commandShellLabel());
69
+ // The user's own profile (memory/profile.ts): name, experience level, reply style.
70
+ if (aboutUser)
71
+ prompt += `
72
+
73
+ ${aboutUser}`;
60
74
  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}
75
+ prompt += `
76
+
77
+ 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:
78
+ <workspace>
79
+ ${workspace}
66
80
  </workspace>`;
67
81
  }
68
82
  // NOTE: the user's standing rules are deliberately NOT rendered here. They live
@@ -73,41 +87,41 @@ ${workspace}
73
87
  // skills are a reference catalog), so they alone get the salience boost. Keeping
74
88
  // them out of the prefix also stops a mid-session `remember_rule` from busting it.
75
89
  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}
90
+ prompt += `
91
+
92
+ 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:
93
+ <forbidden>
94
+ ${governance.forbidden}
81
95
  </forbidden>`;
82
96
  }
83
97
  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}
98
+ prompt += `
99
+
100
+ 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:
101
+ <forbidden_commands>
102
+ ${governance.forbiddenCommands}
89
103
  </forbidden_commands>`;
90
104
  }
91
105
  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}
106
+ prompt += `
107
+
108
+ 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:
109
+ <available_skills>
110
+ ${governance.skills}
97
111
  </available_skills>`;
98
112
  }
99
113
  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):
114
+ prompt += `
115
+
116
+ 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
117
  ${projectContext}`;
104
118
  }
105
119
  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}
120
+ prompt += `
121
+
122
+ The project provides this context in its MINDWEAVE.md — treat it as background facts about this codebase:
123
+ <project_memory>
124
+ ${projectMemory}
111
125
  </project_memory>`;
112
126
  }
113
127
  // Its own past work in this project. The COUNT goes in the prompt (so the model
@@ -117,16 +131,16 @@ ${projectMemory}
117
131
  // and a question about past work gets a real answer instead of a deflection.
118
132
  if (priorSessions > 0) {
119
133
  const s = priorSessions === 1 ? "" : "s";
120
- prompt += `
121
-
134
+ prompt += `
135
+
122
136
  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
137
  }
124
138
  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)"}
139
+ prompt += `
140
+
141
+ 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:
142
+ <memory_index>
143
+ ${memoryIndex || "(empty — nothing has been saved to memory yet)"}
130
144
  </memory_index>`;
131
145
  }
132
146
  // The deferred pool's index. Roughly forty tokens standing in for several hundred of
@@ -134,8 +148,8 @@ ${memoryIndex || "(empty — nothing has been saved to memory yet)"}
134
148
  // missing feature, and the model routes around a capability it actually has.
135
149
  const deferred = deferredToolsIndex();
136
150
  if (deferred) {
137
- prompt += `
138
-
151
+ prompt += `
152
+
139
153
  ${deferred}`;
140
154
  }
141
155
  return prompt;
@@ -158,7 +172,9 @@ export function volatileContext(rules, planMode, sessionMemory, approvedPlan = "
158
172
  /** Notes for the folders being worked in right now (see memory/projectNotes.ts). */
159
173
  directoryNotes = [],
160
174
  /** Where the previous turn left the shell, when this turn started back at the root. */
161
- cwdResetFrom = "") {
175
+ cwdResetFrom = "",
176
+ /** A running Marathon's standing block (see marathonPrompt.ts), or "". */
177
+ marathon = "") {
162
178
  const parts = [];
163
179
  // Each turn starts at the project root, and a model that `cd`-ed into a subfolder last
164
180
  // turn does not know that unless it is told. It was not, and a real session ran
@@ -195,6 +211,10 @@ cwdResetFrom = "") {
195
211
  "you following that plan. Do not write the plan out as an ordinary reply and stop — prose between steps is " +
196
212
  "shortened before the user sees it, so a plan presented that way reaches them in pieces.");
197
213
  }
214
+ // A running Marathon is standing instruction like the approved plan: rendered fresh
215
+ // every request so compaction cannot lose what the run is for.
216
+ if (marathon)
217
+ parts.push(marathon);
198
218
  // The maintained session state — first in the volatile tail so the model reads
199
219
  // "here's where we are" before the map/task list. Survives compaction.
200
220
  const memBlock = renderSessionMemory(sessionMemory);
@@ -482,6 +502,8 @@ async function loadImagePayloads(session) {
482
502
  }));
483
503
  return out;
484
504
  }
505
+ /** What the wire carries in place of an assistant reply that had no words (see buildRequest). */
506
+ export const EMPTY_REPLY_PLACEHOLDER = "(no reply)";
485
507
  function buildRequest(session, tools, imagePayloads = new Map(),
486
508
  /** Notes for the folders in play, resolved by the caller (it has to read disk). */
487
509
  directoryNotes = []) {
@@ -492,7 +514,7 @@ directoryNotes = []) {
492
514
  ? manifestForModel(session.modelConfig.model).imageTypes?.(session.modelConfig.model)
493
515
  : undefined;
494
516
  const messages = [];
495
- for (const e of session.transcript) {
517
+ for (const [i, e] of session.transcript.entries()) {
496
518
  if (e.role === "user" || e.role === "summary") {
497
519
  // Attached images ride with the message, but only while their payload is still
498
520
  // live: microcompaction drops the refs once the turn is old, and a file deleted
@@ -502,7 +524,11 @@ directoryNotes = []) {
502
524
  // A message that arrived mid-turn is framed HERE rather than being stored framed,
503
525
  // so the transcript keeps what the person typed and only the wire carries the
504
526
  // explanation. Deterministic from the entry, so the cached prefix is unaffected.
505
- const said = e.role === "user" && e.arrival ? arrivalNote(e.arrival, e.content) : e.content;
527
+ // A summary is sent as background once the user has written after it, so its
528
+ // hand-off "next step" cannot keep overriding what they actually asked.
529
+ const said = e.role === "summary"
530
+ ? summaryForWire(e.content, summaryIsHistory(e, session.transcript.slice(i + 1)))
531
+ : e.arrival ? arrivalNote(e.arrival, e.content) : e.content;
506
532
  if (refs && refs.length > 0) {
507
533
  const images = [];
508
534
  const missing = [];
@@ -541,8 +567,8 @@ directoryNotes = []) {
541
567
  : []),
542
568
  ...(missing.length > 0 ? [`${missing.join(", ")} could not be read from disk`] : []),
543
569
  ];
544
- const content = notes.length > 0 ? `${said}
545
-
570
+ const content = notes.length > 0 ? `${said}
571
+
546
572
  [${notes.join("; ")}]` : said;
547
573
  messages.push({ role: "user", content, ...(images.length > 0 ? { images } : {}) });
548
574
  continue;
@@ -550,10 +576,15 @@ directoryNotes = []) {
550
576
  messages.push({ role: "user", content: said });
551
577
  }
552
578
  else if (e.role === "assistant") {
579
+ const calls = e.toolCalls && e.toolCalls.length > 0;
553
580
  messages.push({
554
581
  role: "assistant",
555
- content: e.content,
556
- ...(e.toolCalls && e.toolCalls.length > 0 ? { tool_calls: toWire(e.toolCalls) } : {}),
582
+ // A reply with no words and no tool calls (an empty completion, a filtered one) is
583
+ // stored as it came, but several providers reject an empty assistant message and it
584
+ // would be sent on every later request. A placeholder goes on the wire instead; a
585
+ // message that carries tool calls may have no words and is left alone.
586
+ content: calls || e.content.trim() !== "" ? e.content : EMPTY_REPLY_PLACEHOLDER,
587
+ ...(calls ? { tool_calls: toWire(e.toolCalls) } : {}),
557
588
  });
558
589
  }
559
590
  else {
@@ -567,7 +598,7 @@ directoryNotes = []) {
567
598
  // END of the system prompt. The end, because everything before it is the prefix every
568
599
  // session shares and caches against; appending leaves that untouched.
569
600
  const agentPrompt = session.toolContext.agentPrompt;
570
- const base = staticSystemPrompt(session.projectContext, session.projectMemory, session.memoryDir, session.memoryIndex, gov, workspaceText(session), session.priorSessions);
601
+ const base = staticSystemPrompt(session.projectContext, session.projectMemory, session.memoryDir, session.memoryIndex, gov, workspaceText(session), session.priorSessions, profilePrompt(readProfile()));
571
602
  return {
572
603
  system: agentPrompt ? `${base}\n\n${agentPrompt}` : base,
573
604
  messages,
@@ -582,6 +613,11 @@ directoryNotes = []) {
582
613
  // the command's own result says where it is now.
583
614
  session.toolContext.cwdResetFrom && session.toolContext.cwd === session.cwd
584
615
  ? relativize(session.toolContext, session.toolContext.cwdResetFrom)
616
+ : "",
617
+ // Lead agent only: a fork inherits `marathon` by reference, and a verifier told to
618
+ // "keep working toward the goal" would stop being an independent check.
619
+ session.marathon?.status === "running" && (session.toolContext.subagentDepth ?? 0) === 0
620
+ ? marathonBlock(session.marathon.goal, session.marathon.turnsSpent === 0 ? "plan" : "run")
585
621
  : ""),
586
622
  tools,
587
623
  model: session.modelConfig,
@@ -607,14 +643,27 @@ async function backgroundEventNotes(session) {
607
643
  * app", which blamed the user for the agent's own restart and gave the model a second,
608
644
  * contradictory account of the same event.
609
645
  */
610
- export function backgroundEventNote({ info, kind, tail, wake, }) {
646
+ export function backgroundEventNote({ info, kind, tail, wake, ports, }) {
611
647
  // It came up. This is the only positive event a server ever produces, and it is
612
648
  // what lets the model actually deliver the "I'll tell you when it's running" it
613
649
  // was told to say. Nothing has gone wrong, so there is nothing to fix.
650
+ // Something it started opened a port after it was already running: typically the app
651
+ // a build was producing has opened (its window, its debugging port), which is what an
652
+ // agent that ended its turn to "wait for the app" was waiting for.
653
+ if (kind === "opened") {
654
+ const list = (ports ?? []).join(", ");
655
+ return (`[Background shell #${info.id} (\`${info.command}\`) opened port ${list}.]\n` +
656
+ `Recent output:\n${tail || "(no output)"}\n\n` +
657
+ `Something it started is now listening there: usually the app or server you were waiting for has ` +
658
+ `opened. If you were waiting for it, carry on now (for an app's debugging port, call ui with that ` +
659
+ `port). If you were not, there is nothing to do. Nothing is wrong: do not restart it.`);
660
+ }
614
661
  if (kind === "ready") {
615
- return (`[Background shell #${info.id} (\`${info.command}\`) is up and running.]\n` +
662
+ const on = info.listening?.length ? ` (listening on port ${info.listening.join(", ")})` : "";
663
+ return (`[Background shell #${info.id} (\`${info.command}\`) is up and running${on}.]\n` +
616
664
  `Recent output:\n${tail || "(no output)"}\n\n` +
617
- `Tell the user in one short line that it's running. Nothing is wrong — do not investigate, ` +
665
+ `Tell the user in one short line that it's running, and do not repeat anything you already said ` +
666
+ `about it. Nothing is wrong — do not investigate, ` +
618
667
  `do not restart it, and do not change any files because of this. Running only means the ` +
619
668
  `process started: do not describe what it shows or say a change is visible unless you ` +
620
669
  `have actually looked.`);
@@ -702,7 +751,7 @@ export function callIsConcurrencySafe(tool, args) {
702
751
  * an unreadable rules directory must not take the turn down with it — on any failure the
703
752
  * session simply keeps the governance it already had.
704
753
  */
705
- async function refreshGovernance(session, force = false) {
754
+ export async function refreshGovernance(session, force = false) {
706
755
  try {
707
756
  const stamp = await governanceStamp(session.toolContext.cwd);
708
757
  // Skipping when the stamp is unchanged is what keeps our OWN writes from causing a
@@ -763,13 +812,13 @@ export async function respond(session, options = {}) {
763
812
  function implementFromScratch(priorPath, plan) {
764
813
  const path = priorPath;
765
814
  const where = path
766
- ? `
767
-
815
+ ? `
816
+
768
817
  If you need something exact from the planning that produced this — a snippet, an ` +
769
818
  `error message, a path — the full conversation is at: ${path}`
770
819
  : "";
771
- return `Implement the following plan:
772
-
820
+ return `Implement the following plan:
821
+
773
822
  ${plan}${where}`;
774
823
  }
775
824
  /**
@@ -806,11 +855,23 @@ async function respondTurn(session, options = {}) {
806
855
  // every subsequent turn, and it keeps `activeDriver()` safe to call synchronously
807
856
  // from here down (including from inside a tool).
808
857
  await ensureDriver(session.modelConfig.model);
858
+ // Bring the user's usage limits up to date, once per top-level turn. Cheap when they are off
859
+ // (one small file read); a sub-agent's turn is inside this one and does not repeat it.
860
+ if ((session.toolContext.subagentDepth ?? 0) === 0)
861
+ await refreshUsageLimits().catch(() => { });
809
862
  // Pick up a governance file the USER edited by hand since the last turn. One stat
810
863
  // pass over a few small directories, taken here because a turn is the only moment
811
864
  // governance is consulted — so it is fresh exactly where it is used, with no watcher
812
865
  // to own, poll or tear down. See refreshGovernance.
813
866
  await refreshGovernance(session);
867
+ // A history the provider would reject is healed here, before anything is sent. The usual
868
+ // cause is a turn that ended with tool calls still unanswered (a tool that threw, a crash
869
+ // between the call and its result): every later request would then be refused, and the only
870
+ // cure used to be restarting, which repairs the same thing on load. Both repairs return the
871
+ // same array when there is nothing to do, so a healthy session pays one scan per turn.
872
+ const healed = reconcileInterruptedTools(repairToolCallOrder(session.transcript), "the turn ended before this tool returned");
873
+ if (healed !== session.transcript)
874
+ session.transcript = healed;
814
875
  // Resume an approved plan from disk, once per session (undefined = unchecked).
815
876
  // A plan approved last session is still the agreed scope this session — that is
816
877
  // the point of it being an artifact — and the user deleting the file (or its
@@ -865,7 +926,13 @@ async function respondTurn(session, options = {}) {
865
926
  // Session): fork a scoped child, forward the child's usage to this turn's meter,
866
927
  // and share this turn's abort signal so Esc stops a sub-agent too.
867
928
  session.toolContext.forkChild = (task, opts) => forkSession(session, task, opts);
868
- session.toolContext.reportUsage = (u) => options.onEvent?.({ type: "usage", ...u });
929
+ session.toolContext.reportUsage = (u) => {
930
+ options.onEvent?.({ type: "usage", ...u });
931
+ // A sub-agent's calls arrive here as events, but its own turn already counted them toward the
932
+ // limits; only a bare usage (a page-summarising call, say) is new.
933
+ if (!("type" in u))
934
+ countUsage(session, u, options);
935
+ };
869
936
  // The raw event sink, so spawn_subagent can surface a child's nested activity
870
937
  // (its lifecycle + tagged tool calls) up this same stream instead of running dark.
871
938
  session.toolContext.emitEvent = options.onEvent;
@@ -941,6 +1008,7 @@ async function respondTurn(session, options = {}) {
941
1008
  // Narration budget: one nudge per turn, and the turn's earlier prose to compare against.
942
1009
  let narrationNudged = false;
943
1010
  const narratedBefore = [];
1011
+ const readThisTurn = new Set(); // files read so far, for narrationShown
944
1012
  // Judged next to the prose, pushed after the tool results — see the gate below.
945
1013
  let pendingNarrationFault = null;
946
1014
  let lastFailSig = null;
@@ -957,6 +1025,12 @@ async function respondTurn(session, options = {}) {
957
1025
  // no matter how the turn ends (finish, pause, interrupt, throw). Labeled with
958
1026
  // the request that drove it. No-op when nothing was edited.
959
1027
  const turnLabel = lastUserText(session);
1028
+ // The message that opened this turn, stamped now if the save that normally stamps it
1029
+ // has not run yet. The checkpoint below carries that stamp, which is how a rewind to
1030
+ // this message (or any earlier one) finds the file changes to take back with it.
1031
+ const opener = turnOpener(session);
1032
+ if (opener && opener.ts === undefined)
1033
+ opener.ts = Date.now();
960
1034
  // Fold this turn's cost into the session total on the way out, however the turn ends.
961
1035
  // In the `finally` rather than the success path on purpose: an interrupted or failed
962
1036
  // turn still spent the tokens it spent, and a spend figure that quietly omits the
@@ -993,7 +1067,7 @@ async function respondTurn(session, options = {}) {
993
1067
  finally {
994
1068
  recordSpend();
995
1069
  const before = session.toolContext.checkpoints?.list().length ?? 0;
996
- session.toolContext.checkpoints?.seal(turnLabel);
1070
+ session.toolContext.checkpoints?.seal(turnLabel, opener?.ts);
997
1071
  // Say that a restore point exists. It was made silently, so `/undo` was a feature
998
1072
  // you had to already know about — and the moment to learn it is the moment there is
999
1073
  // something to undo, not after you have lost it.
@@ -1007,11 +1081,16 @@ async function respondTurn(session, options = {}) {
1007
1081
  for (let step = 0; stepLimit === undefined || step < stepLimit; step++) {
1008
1082
  if (options.signal?.aborted)
1009
1083
  return interrupted(session);
1084
+ // The user's own usage limit: a step that began under it has finished, so this is the
1085
+ // boundary. Nothing is lost; the work carries on once the window reopens.
1086
+ const heldBack = (session.toolContext.subagentDepth ?? 0) === 0 ? limitGateReason() : null;
1087
+ if (heldBack)
1088
+ return pauseTask(session, options, heldBack, "limit");
1010
1089
  // Stop before another (billable) call if a cost/time ceiling is hit — pause
1011
1090
  // losslessly, exactly like the step budget, so the user can raise it and resume.
1012
1091
  const limitReason = taskLimitReason(summarizeTask(usages, session.modelConfig.model), Date.now() - startedAt, limits);
1013
1092
  if (limitReason)
1014
- return pauseTask(session, options, `hit the ${limitReason}`);
1093
+ return pauseTask(session, options, `hit the ${limitReason}`, "costTimeLimit");
1015
1094
  await maybeCompact(session, options);
1016
1095
  // NO working-set block is built or sent. It used to be: the current contents of
1017
1096
  // every active file, rebuilt each step and injected at the tail — up to 12K tokens
@@ -1111,7 +1190,7 @@ async function respondTurn(session, options = {}) {
1111
1190
  if (!shed)
1112
1191
  return false;
1113
1192
  overflowRecovered = true;
1114
- session.transcript = shed;
1193
+ replaceTranscript(session, shed);
1115
1194
  await options.persist?.();
1116
1195
  options.onActivity?.("conversation was too long — dropped the oldest turns and retried", {
1117
1196
  context: true,
@@ -1144,6 +1223,7 @@ async function respondTurn(session, options = {}) {
1144
1223
  // may span several calls across tool rounds, and the UI sums them.
1145
1224
  emitUsage(result, options);
1146
1225
  if (result.usage) {
1226
+ countUsage(session, result.usage, options);
1147
1227
  usages.push(result.usage);
1148
1228
  usageTimes.push(Date.now());
1149
1229
  writeCacheLog(cacheCallLine({
@@ -1180,7 +1260,7 @@ async function respondTurn(session, options = {}) {
1180
1260
  if (content.trim())
1181
1261
  session.transcript.push({ role: "assistant", content });
1182
1262
  await options.persist?.();
1183
- return pauseTask(session, options, note);
1263
+ return pauseTask(session, options, note, result.stop);
1184
1264
  }
1185
1265
  // No tool calls → the model is done. Record the reply.
1186
1266
  if (toolCalls.length === 0) {
@@ -1246,6 +1326,13 @@ async function respondTurn(session, options = {}) {
1246
1326
  // Durable BEFORE running the tools: if the machine dies mid-tool, the resume path
1247
1327
  // sees these dangling tool_calls and reconciles them (reconcileInterruptedTools).
1248
1328
  await options.persist?.();
1329
+ // Whether the words above lead to anything on screen, before any tool is announced.
1330
+ {
1331
+ const calls = toolCalls.map((c) => ({ name: c.name, args: parseArgs(c.arguments) }));
1332
+ if (content.trim())
1333
+ options.onEvent?.({ type: "narration", shown: narrationShown(calls, readThisTurn) });
1334
+ noteReads(calls, readThisTurn);
1335
+ }
1249
1336
  // Narration gate, part one: JUDGE here, where this message's prose and the turn's
1250
1337
  // earlier prose are both in hand. Do NOT push anything yet — an assistant message
1251
1338
  // carrying tool_calls must be followed immediately by a tool message per call, and
@@ -1259,11 +1346,20 @@ async function respondTurn(session, options = {}) {
1259
1346
  // Announce every tool the model chose, in its order, BEFORE running any —
1260
1347
  // the UI's reveal queue paces them and a slow tool (test/run) can show a live
1261
1348
  // "running" state until its end event lands.
1262
- for (const call of toolCalls) {
1349
+ // A row is announced when its tool actually STARTS, not up front for the whole step.
1350
+ // Announcing every call at once showed a command as running while a Sentinel prompt
1351
+ // for an earlier call was still waiting, and kept rows appearing behind an open
1352
+ // question: work on screen that nobody had approved. A call that never runs (declined,
1353
+ // refused, interrupted) is announced together with its end, so it still gets its row.
1354
+ const announced = new Set();
1355
+ const announce = (call) => {
1356
+ if (announced.has(call.id))
1357
+ return;
1358
+ announced.add(call.id);
1263
1359
  options.onEvent?.({ type: "tool", phase: "start", id: call.id, name: call.name, args: parseArgs(call.arguments) });
1264
- }
1265
- // Concurrency-safe calls run in PARALLEL; the rest run one at a time, in order
1266
- // (parallel edits to one file race, and an edit must see the last write). A call
1360
+ };
1361
+ // Consecutive concurrency-safe calls run in PARALLEL; every other call runs alone, in
1362
+ // order (parallel edits to one file race, and an edit must see the last write). A call
1267
1363
  // is concurrency-safe when the tool says so for THESE args (isConcurrencySafe) —
1268
1364
  // e.g. a read-only sub-agent, which lets the model fan out research — otherwise
1269
1365
  // the default is: read-only ⇒ safe, mutating ⇒ serial.
@@ -1271,8 +1367,9 @@ async function respondTurn(session, options = {}) {
1271
1367
  const tool = lookup(call.name);
1272
1368
  return tool ? callIsConcurrencySafe(tool, parseArgs(call.arguments)) : false;
1273
1369
  };
1274
- const parallelCalls = toolCalls.filter(concurrencySafe);
1275
- const serialCalls = toolCalls.filter((call) => !concurrencySafe(call));
1370
+ // Batches keep the model's ORDER (toolBatches.ts): a read written after an edit has to
1371
+ // see the edit, so read-only calls are only grouped with their neighbours.
1372
+ const batches = partitionCalls(toolCalls, concurrencySafe);
1276
1373
  const runCall = async (call) => {
1277
1374
  // Esc: once the turn is aborted, no further tool may START. The step loop only
1278
1375
  // re-checks BETWEEN steps, so without this gate the rest of a batch still runs
@@ -1345,7 +1442,10 @@ async function respondTurn(session, options = {}) {
1345
1442
  // uniformly — including subagent edits. Fails safe: no approval channel, or an
1346
1443
  // unclear answer, refuses rather than runs.
1347
1444
  const ctx = session.toolContext;
1348
- if (!tool.readOnly && ctx.guarded && !ctx.guardAllowed?.has(call.name)) {
1445
+ // Two kinds of standing "yes": one given during this session (guardAllowed), and
1446
+ // one the user saved in their permissions, for this project or every project.
1447
+ const savedYes = ctx.governance?.sentinelAllow?.includes(call.name) === true;
1448
+ if (!tool.readOnly && ctx.guarded && !ctx.guardAllowed?.has(call.name) && !savedYes) {
1349
1449
  const args = parseArgs(call.arguments);
1350
1450
  // The question is one line; WHAT is about to happen rides as detail, which the
1351
1451
  // CLI prints into the transcript. A gate the user cannot read is a gate they
@@ -1390,21 +1490,61 @@ async function respondTurn(session, options = {}) {
1390
1490
  // Zero threw — every one returned an error result. So this catches nothing today
1391
1491
  // and is deliberately defence in depth: it removes a single point of failure
1392
1492
  // rather than fixing an observed bug, and the next tool added inherits it.
1493
+ announce(call); // approved (or needing no approval): now it is really running
1494
+ // A command waits until its row is actually on screen before it starts, when the caller
1495
+ // paces the screen. Otherwise the screen, which shows one thing at a time, replays a
1496
+ // command that finished long ago and its "running" state is never real. Only commands:
1497
+ // reads and edits take milliseconds, and holding them back would only slow the turn.
1498
+ if (tool.liveRow && options.beforeLiveTool) {
1499
+ await options.beforeLiveTool(call.id);
1500
+ if (options.signal?.aborted) {
1501
+ return {
1502
+ call,
1503
+ output: "Not run: the turn was interrupted before this tool started.",
1504
+ summary: "interrupted",
1505
+ isError: true,
1506
+ detail: undefined,
1507
+ };
1508
+ }
1509
+ }
1393
1510
  let result;
1394
1511
  try {
1395
1512
  // A per-call channel, so a tool that runs for minutes can say what it is doing.
1396
1513
  // Scoped to THIS call rather than hung on the shared context, which every tool in
1397
1514
  // the turn holds the same instance of — with two commands in flight there would be
1398
1515
  // no way to tell whose output was whose.
1399
- result = await tool.execute(parseArgs(call.arguments), session.toolContext, {
1400
- progress: (text) => options.onEvent?.({ type: "tool", phase: "progress", id: call.id, text }),
1401
- });
1516
+ // Memories, rules, skills, permissions and MCP servers are written by their own
1517
+ // helpers, not the edit tools; snapshotting around the call puts them in the same
1518
+ // undo net as project files, so /undo and a rewind take them back too.
1519
+ const stateBefore = session.toolContext.checkpoints && writesAgentState(call.name)
1520
+ ? await snapshotAgentState(session.toolContext)
1521
+ : null;
1522
+ try {
1523
+ result = await tool.execute(parseArgs(call.arguments), session.toolContext, {
1524
+ progress: (text) => options.onEvent?.({ type: "tool", phase: "progress", id: call.id, text }),
1525
+ });
1526
+ }
1527
+ finally {
1528
+ if (stateBefore)
1529
+ await recordAgentStateChanges(session.toolContext, stateBefore);
1530
+ }
1402
1531
  }
1403
1532
  catch (error) {
1404
- // An abort is the user, not a fault: let it travel so the loop's own handling
1405
- // reports an interruption instead of a broken tool.
1406
- if (isAbort(error))
1407
- throw error;
1533
+ // An abort is the user, not a fault, so it is NOT reported as a broken tool. But it
1534
+ // must not travel out of the loop either: the assistant message carrying these calls
1535
+ // is already saved, so a throw here leaves calls with no results, and every later
1536
+ // request in the live session is then malformed until the app is restarted. It is
1537
+ // recorded as an interrupted call instead; when the turn really was stopped the next
1538
+ // pass through the loop sees the abort and ends the turn cleanly.
1539
+ if (isAbort(error)) {
1540
+ return {
1541
+ call,
1542
+ output: "Not completed: this call was interrupted before it finished, so its effect is unknown. Check the current state before relying on it.",
1543
+ summary: "interrupted",
1544
+ isError: true,
1545
+ detail: undefined,
1546
+ };
1547
+ }
1408
1548
  return { call, ...toolFailureResult(call.name, error), detail: undefined };
1409
1549
  }
1410
1550
  return {
@@ -1418,6 +1558,8 @@ async function respondTurn(session, options = {}) {
1418
1558
  quiet: result.quiet,
1419
1559
  fullContentOf: result.fullContentOf,
1420
1560
  images: result.images,
1561
+ web: result.web,
1562
+ ui: result.ui,
1421
1563
  displayKind: result.displayKind,
1422
1564
  displayName: result.displayName,
1423
1565
  awaitsModel: result.awaitsModel,
@@ -1429,6 +1571,7 @@ async function respondTurn(session, options = {}) {
1429
1571
  // the model's call order (the sort below); only the UI events go out eagerly.
1430
1572
  const runAndEmit = async (call) => {
1431
1573
  const r = await runCall(call);
1574
+ announce(r.call); // one that never ran gets its start now, right before its end
1432
1575
  options.onEvent?.({
1433
1576
  type: "tool",
1434
1577
  phase: "end",
@@ -1442,12 +1585,18 @@ async function respondTurn(session, options = {}) {
1442
1585
  ...(r.quiet ? { quiet: true } : {}),
1443
1586
  ...(r.displayKind ? { displayKind: r.displayKind } : {}),
1444
1587
  ...(r.displayName ? { displayName: r.displayName } : {}),
1588
+ ...(r.images?.length ? { images: r.images.map((i) => i.path) } : {}),
1589
+ ...(r.web ? { web: r.web } : {}),
1590
+ ...(r.ui ? { ui: r.ui } : {}),
1445
1591
  });
1446
1592
  return r;
1447
1593
  };
1448
- const results = await Promise.all(parallelCalls.map(runAndEmit));
1449
- for (const call of serialCalls) {
1450
- results.push(await runAndEmit(call));
1594
+ const results = [];
1595
+ for (const batch of batches) {
1596
+ if (batch.parallel)
1597
+ results.push(...(await runLimited(batch.calls, MAX_PARALLEL_CALLS, runAndEmit)));
1598
+ else
1599
+ results.push(await runAndEmit(batch.calls[0]));
1451
1600
  }
1452
1601
  // Hand results back in the model's original call order (start events were emitted
1453
1602
  // in that order too), no matter which lane each call ran in.
@@ -1492,6 +1641,9 @@ async function respondTurn(session, options = {}) {
1492
1641
  ...(result.quiet ? { quiet: true } : {}),
1493
1642
  ...(result.displayName ? { displayName: result.displayName } : {}),
1494
1643
  ...(result.displayKind ? { displayKind: result.displayKind } : {}),
1644
+ ...(result.images?.length ? { imagePaths: result.images.map((i) => i.path) } : {}),
1645
+ ...(result.web ? { web: result.web } : {}),
1646
+ ...(result.ui ? { ui: result.ui } : {}),
1495
1647
  ...(result.isError ? { isError: true } : {}),
1496
1648
  // Presence, as recorded by the tool that knows: this result IS the whole
1497
1649
  // content of that file. Not display — the presence derivation reads it.
@@ -1761,10 +1913,19 @@ async function respondTurn(session, options = {}) {
1761
1913
  // transcript, task list, and working set are all intact, so telling Mindweave to
1762
1914
  // continue resumes exactly here with nothing lost, and the user stays in control of
1763
1915
  // the spend.
1764
- return pauseTask(session, options, `reached the step budget of ${stepLimit} tool steps in one turn`);
1916
+ return pauseTask(session, options, `reached the step budget of ${stepLimit} tool steps in one turn`, "stepBudget");
1765
1917
  }
1766
1918
  }
1767
1919
  /** The most recent user request in the transcript, clipped — labels a checkpoint. */
1920
+ /** The last message the person sent, which is what this turn is answering. */
1921
+ function turnOpener(session) {
1922
+ for (let i = session.transcript.length - 1; i >= 0; i--) {
1923
+ const e = session.transcript[i];
1924
+ if (e.role === "user" && !e.synthetic)
1925
+ return e;
1926
+ }
1927
+ return undefined;
1928
+ }
1768
1929
  function lastUserText(session) {
1769
1930
  for (let i = session.transcript.length - 1; i >= 0; i--) {
1770
1931
  const e = session.transcript[i];
@@ -1796,6 +1957,7 @@ function endTurnWith(session, options, msg) {
1796
1957
  * one in the same turn (the re-scope guard) — a natural checkpoint to let the user
1797
1958
  * steer instead of the model taking on scope it wasn't asked for. */
1798
1959
  function pauseReScope(session, options) {
1960
+ options.onPause?.("reScope");
1799
1961
  return endTurnWith(session, options, "(I've finished the task list for what you asked. I have ideas for taking it " +
1800
1962
  "further, but I've stopped here so you can steer — rather than piling on new scope " +
1801
1963
  'on my own. Tell me which direction you want, or say "keep going" to continue.)');
@@ -1805,6 +1967,7 @@ function pauseReScope(session, options) {
1805
1967
  * to do but wait — end the turn cleanly instead of looping "still running" checks.
1806
1968
  * Deliberately worded as a status line to the user, not a "paused" apology. */
1807
1969
  function pauseForBackgroundPoll(session, options) {
1970
+ options.onPause?.("backgroundPoll");
1808
1971
  return endTurnWith(session, options, "It's still running in the background. I'll stop checking and let you know as soon " +
1809
1972
  "as it finishes — no need to keep watching.");
1810
1973
  }
@@ -1813,13 +1976,22 @@ function pauseForBackgroundPoll(session, options) {
1813
1976
  * count, and its real working directory, and it still hasn't moved — so hand the wheel
1814
1977
  * to the user rather than spend more steps on it. */
1815
1978
  function pauseForRepeatedFailure(session, options, errorOutput) {
1979
+ options.onPause?.("repeatedFailure");
1816
1980
  return endTurnWith(session, options, `I've hit the same failure several times in a row and I'm not making progress, so I've ` +
1817
1981
  `stopped rather than retry the same thing again. The error was:\n\n${firstErrorLine(errorOutput)}\n\n` +
1818
1982
  `Tell me how you'd like to proceed, or I can try a different approach.`);
1819
1983
  }
1820
1984
  /** Record and return a clean, lossless pause reply (well-formed transcript) when a
1821
- * guard trips — step budget or a cost/time ceiling. Saying "continue" resumes. */
1822
- function pauseTask(session, options, reason) {
1985
+ * guard trips — step budget or a cost/time ceiling. Saying "continue" resumes.
1986
+ * `tag` tells the two apart for `onPause`; the prose reason stays a free string since
1987
+ * it already carries the exact number ("the step budget of 40 tool steps"). */
1988
+ function pauseTask(session, options, reason, tag) {
1989
+ options.onPause?.(tag);
1990
+ // A usage limit is not something to argue with by saying "continue": it lifts on its own.
1991
+ if (tag === "limit") {
1992
+ return endTurnWith(session, options, `(Paused — ${reason}. The task isn't finished, but nothing is lost: your progress, edits, and task ` +
1993
+ `list are saved. Carry on once it opens.)`);
1994
+ }
1823
1995
  return endTurnWith(session, options, `(Paused — ${reason}. The task isn't finished, but nothing is lost: your progress, ` +
1824
1996
  `edits, and task list are saved. Say "continue" to pick up exactly where I left off.)`);
1825
1997
  }
@@ -1874,6 +2046,18 @@ export function toCallRecord(u, model, at = Date.now()) {
1874
2046
  model,
1875
2047
  };
1876
2048
  }
2049
+ /**
2050
+ * A finished model call counts toward the user's limits, and a window crossing 80% or 95%
2051
+ * says so once, as a line in the conversation. Only the top-level turn speaks: a sub-agent's
2052
+ * call is counted, and the parent's next call is what reports where things stand.
2053
+ */
2054
+ function countUsage(session, u, options) {
2055
+ noteUsage(session.modelConfig.model, u);
2056
+ if ((session.toolContext.subagentDepth ?? 0) > 0)
2057
+ return;
2058
+ for (const line of takeLimitWarnings())
2059
+ options.onActivity?.(`Usage limit: ${line}`);
2060
+ }
1877
2061
  /** Report a turn's token usage to the UI, if the provider returned it. */
1878
2062
  function emitUsage(result, options) {
1879
2063
  if (result.usage) {
@@ -1908,7 +2092,7 @@ async function sweepSessionMemory(session, options) {
1908
2092
  const grown = shouldUpdateSessionMemory(estimateEntriesTokens(session.transcript), session.sessionMemoryTokens ?? 0, session.sessionMemoryInit ?? false);
1909
2093
  if (!grown)
1910
2094
  return;
1911
- await updateSessionMemory(session);
2095
+ await updateSessionMemory(session, options.signal);
1912
2096
  await options.persist?.(); // durable: the notes sidecar is written by the persister
1913
2097
  }
1914
2098
  /**
@@ -1938,12 +2122,39 @@ export function contextUsed(session) {
1938
2122
  : (session.toolContext.mcp?.estimatedTokens() ?? 0);
1939
2123
  return estimateEntriesTokens(session.transcript) + overhead;
1940
2124
  }
2125
+ /**
2126
+ * The auto-compaction bar actually in force for this session: the user's own override
2127
+ * (Settings > Usage > Context, or `/context limit`) if they set one — project first,
2128
+ * then all-projects, per governor/index.ts's context.json — else Mindweave's own
2129
+ * model-anchored default. The env var still wins over either, same as before this
2130
+ * existed: it is the sandbox/CI escape hatch, not a user-facing setting.
2131
+ */
2132
+ export function effectiveAutoCompactThreshold(session) {
2133
+ // Optional chaining: a real session always has governance (loadGovernance runs at
2134
+ // session start), but a hand-built test fixture may skip it, and a pure accessor
2135
+ // shouldn't crash on that rather than just falling through to the model default.
2136
+ const base = session.governance?.contextAutoCompactTokens ?? autoCompactThreshold(session.modelConfig.model);
2137
+ return envInt("MINDWEAVE_AUTOCOMPACT_TOKENS", base);
2138
+ }
2139
+ /**
2140
+ * How full the context is, against the bar auto-compaction fires at: what a front end
2141
+ * draws as the context meter. The same numbers `maybeCompact` decides on, so a full meter
2142
+ * and a compaction cannot disagree.
2143
+ */
2144
+ export function contextFill(session) {
2145
+ return {
2146
+ used: contextUsed(session),
2147
+ limit: effectiveAutoCompactThreshold(session),
2148
+ window: sharpContextWindow(session.modelConfig.model),
2149
+ };
2150
+ }
1941
2151
  async function maybeCompact(session, options) {
1942
2152
  const model = session.modelConfig.model;
1943
- // Model-anchored bars (env still overrides), so the thresholds are right per model
1944
- // instead of a fixed number — and a longer/stronger model automatically gets more room.
2153
+ // Model-anchored bars (env, then the user's own override, still win), so the
2154
+ // thresholds are right per model instead of a fixed number — and a longer/stronger
2155
+ // model automatically gets more room.
1945
2156
  const microBar = envInt("MINDWEAVE_MICROCOMPACT_TOKENS", microCompactThreshold(model));
1946
- const autoBar = envInt("MINDWEAVE_AUTOCOMPACT_TOKENS", autoCompactThreshold(model));
2157
+ const autoBar = effectiveAutoCompactThreshold(session);
1947
2158
  // MCP tool schemas are sent on every turn but live OUTSIDE the transcript, so the bars
1948
2159
  // could not see them: a 30K-token catalog meant the model was 30K deeper into its real
1949
2160
  // context than this arithmetic believed, and every threshold fired that much too late.
@@ -2048,6 +2259,16 @@ export async function compactNow(session, options = {}) {
2048
2259
  async function autocompact(session, options) {
2049
2260
  if (session.transcript.length === 0)
2050
2261
  return;
2262
+ options.onCompactionStart?.();
2263
+ try {
2264
+ await summarizeAndSplice(session, options);
2265
+ }
2266
+ finally {
2267
+ options.onCompactionEnd?.();
2268
+ }
2269
+ }
2270
+ /** The body of `autocompact`: the notes when they cover the prefix, else the summarizer. */
2271
+ async function summarizeAndSplice(session, options) {
2051
2272
  // Measured BEFORE the summarizer runs, with the same arithmetic the thresholds use,
2052
2273
  // so the bar the user sees is the number the system actually acted on.
2053
2274
  const before = contextUsed(session);
@@ -2069,9 +2290,9 @@ async function autocompact(session, options) {
2069
2290
  // summarizer is about to be paid to produce. When they are current enough to cover
2070
2291
  // the prefix being dropped, spending a model call buys something already owned.
2071
2292
  // Declines rather than approximates: stale or empty notes fall through.
2072
- const fromNotes = compactFromSessionMemory(session.transcript, session.sessionMemory, session.sessionMemoryEntries, envInt("MINDWEAVE_AUTOCOMPACT_TOKENS", autoCompactThreshold(session.modelConfig.model)), contextUsed(session) - estimateEntriesTokens(session.transcript));
2293
+ const fromNotes = compactFromSessionMemory(session.transcript, session.sessionMemory, session.sessionMemoryEntries, effectiveAutoCompactThreshold(session), contextUsed(session) - estimateEntriesTokens(session.transcript));
2073
2294
  if (fromNotes) {
2074
- session.transcript = fromNotes.entries;
2295
+ replaceTranscript(session, fromNotes.entries);
2075
2296
  // The notes now describe everything before the tail they were spliced in front of.
2076
2297
  session.sessionMemoryEntries = 1;
2077
2298
  session.sessionMemoryTokens = estimateEntriesTokens(session.transcript);
@@ -2081,15 +2302,31 @@ async function autocompact(session, options) {
2081
2302
  let summary;
2082
2303
  try {
2083
2304
  // Summaries don't need reasoning — use the chosen model with thinking off.
2084
- const turn = await activeDriver().toolTurn({
2085
- system: SUMMARY_SYSTEM_PROMPT,
2086
- messages: [
2087
- {
2088
- role: "user",
2089
- content: `${formatTranscriptForSummary(session.transcript)}\n\n${summaryRequest(options.compactFocus)}`,
2090
- },
2091
- ],
2092
- model: { ...session.modelConfig, thinking: false },
2305
+ // With reasoning off where the model allows it; see auxModel.ts.
2306
+ //
2307
+ // `ensureDriver` here, not just at the top of the turn: a sub-agent (or another
2308
+ // background aux call) can run its OWN model in between and leaves `activeDriver()`
2309
+ // pointed at THAT provider, which is a plain global, not scoped to this session. A
2310
+ // compaction landing after one then handed this session's model string to the
2311
+ // wrong provider's API, which correctly refused it as a model it had never heard
2312
+ // of ("Unknown Model, please check the model code.") — same failure shape as the
2313
+ // model call sites in respondTurn, which is why only THIS one call was missing it.
2314
+ //
2315
+ // `withTools`: a real (read-only) tool set, attached only if the model already
2316
+ // refused a bare call — see auxModel.ts for why some free models require it.
2317
+ const turn = await withAuxModel(session.modelConfig, async (model, withTools) => {
2318
+ await ensureDriver(model.model);
2319
+ return activeDriver().toolTurn({
2320
+ system: SUMMARY_SYSTEM_PROMPT,
2321
+ messages: [
2322
+ {
2323
+ role: "user",
2324
+ content: `${formatTranscriptForSummary(session.transcript)}\n\n${summaryRequest(options.compactFocus)}`,
2325
+ },
2326
+ ],
2327
+ model,
2328
+ ...(withTools ? { tools: toolSchemas({ readOnlyOnly: true }) } : {}),
2329
+ }, { signal: options.signal });
2093
2330
  });
2094
2331
  // The reply is untrusted: a cut-off or all-scratchpad summary must not be allowed
2095
2332
  // to replace the conversation. See usableSummary.
@@ -2097,21 +2334,38 @@ async function autocompact(session, options) {
2097
2334
  // is what keeps the meter honest: a turn that happened to trip the bar spends
2098
2335
  // a whole extra summarisation call, and leaving that out made the figure short
2099
2336
  // by exactly the work nobody could see.
2100
- if (turn.usage)
2337
+ if (turn.usage) {
2101
2338
  options.onEvent?.({ type: "usage", ...turn.usage });
2339
+ countUsage(session, turn.usage, options);
2340
+ }
2102
2341
  const usable = usableSummary(turn.content, turn.stop);
2103
2342
  if (!usable)
2104
2343
  return void fail(turn.stop && turn.stop !== "end" ? `the summary came back ${turn.stop}` : "the summary was unusable");
2105
2344
  summary = usable;
2106
2345
  }
2107
2346
  catch (error) {
2347
+ // Stopped by the user: not a failed compaction, so nothing to count or report.
2348
+ if (options.signal?.aborted)
2349
+ return;
2350
+ // Some free OpenRouter models gate on the CALLING APP's identity, not the shape of
2351
+ // any one request — real tool schemas (withAuxModel's retry, above) genuinely fix a
2352
+ // model that just needs a non-empty `tools` array, but this specific refusal was
2353
+ // confirmed live to survive that retry too: OpenRouter's own error names the cause
2354
+ // as `"failed_routing_step":"Gate Free Endpoints by Agentic Harness"`, which nothing
2355
+ // Mindweave sends in a request can satisfy. Say so plainly instead of surfacing the
2356
+ // provider's raw JSON, which reads as a Mindweave bug rather than a model limit.
2357
+ if (isAgenticOnlyRefusal(error)) {
2358
+ return void fail(`${session.modelConfig.model} won't serve Mindweave's background calls — some free OpenRouter models restrict ` +
2359
+ `themselves to specific recognised apps, and switching what a request sends can't change that. Pick a ` +
2360
+ `different model for this project, or expect compaction and session notes to stay off on this one.`);
2361
+ }
2108
2362
  return void fail(providerMessage(detailOf(error)) || "the summarizer call failed");
2109
2363
  }
2110
2364
  // A summary replaces the transcript prefix, so file contents read before it are gone.
2111
2365
  // Nothing re-injects them: the working-set block that used to do so was removed for
2112
2366
  // costing up to 12K per model call. The model re-reads what it still needs, which
2113
2367
  // read_file allows because the summary also clears the presence set the dedup checks.
2114
- session.transcript = spliceSummary(session.transcript, summary, KEEP_LAST_N);
2368
+ replaceTranscript(session, spliceSummary(session.transcript, summary, KEEP_LAST_N));
2115
2369
  // The notes no longer describe the transcript they were measured against, and the
2116
2370
  // summary now covers everything before the kept tail.
2117
2371
  session.sessionMemoryEntries = 1;
@@ -2130,6 +2384,12 @@ async function autocompact(session, options) {
2130
2384
  async function finishCompaction(session, options, before) {
2131
2385
  session.compactFailures = 0; // a clean compaction resets the breaker
2132
2386
  await restoreAfterCompaction(session);
2387
+ // What is still running, said right after the summary: the summary is history, and
2388
+ // may not mention a dev server that is still up or an app still open for testing,
2389
+ // and a model that does not know starts them again.
2390
+ const live = liveStateNote(session.toolContext.backgroundShells?.running() ?? [], uiLiveState(session.toolContext));
2391
+ if (live)
2392
+ session.transcript.splice(1, 0, { role: "user", content: live, synthetic: true });
2133
2393
  // Re-read the governor unconditionally here. The prompt is being rebuilt from scratch
2134
2394
  // at this point, so it is the natural moment to rebuild what it is made of — and it is
2135
2395
  // the one path that does not depend on the stat check being right about anything.
@@ -2153,6 +2413,27 @@ async function finishCompaction(session, options, before) {
2153
2413
  // the moment to pick the file back up, and it costs nothing extra here.
2154
2414
  await reloadProjectMemory(session).catch(() => { });
2155
2415
  }
2416
+ /**
2417
+ * The note put after a compaction about what is still running, or null when nothing is
2418
+ * (pure). Background commands are listed with the ports they are listening on, so a
2419
+ * model that has lost the history does not start a second dev server; an app open for
2420
+ * testing is named, with the instruction to look before acting.
2421
+ */
2422
+ export function liveStateNote(shells, ui, now = Date.now()) {
2423
+ const lines = [];
2424
+ for (const sh of shells) {
2425
+ const mins = Math.max(0, Math.round((now - sh.startedAt) / 60_000));
2426
+ const ports = sh.listening?.length ? sh.listening : sh.port ? [sh.port] : [];
2427
+ lines.push(`- Background shell #${sh.id} (\`${sh.command}\`): running for ${mins < 1 ? "under a minute" : `${mins} min`}` +
2428
+ `${ports.length ? `, listening on port ${ports.join(", ")}` : ""}.`);
2429
+ }
2430
+ if (ui)
2431
+ lines.push(`- ${ui}`);
2432
+ if (lines.length === 0)
2433
+ return null;
2434
+ return (`[Still running after the compaction. This is the live state now, not history.]\n${lines.join("\n")}\n` +
2435
+ `Do not start any of these again: they are already running. Use shells to check one, kill_shell to stop it.`);
2436
+ }
2156
2437
  /**
2157
2438
  * Reconcile the read ledger with the transcript, and put the working files back.
2158
2439
  *
@@ -2179,7 +2460,7 @@ async function restoreAfterCompaction(session) {
2179
2460
  // earlier fix copied every path forward to re-derive the same answer every step.
2180
2461
  // The correctness half. Unconditional, and before anything that can throw.
2181
2462
  reads.clear();
2182
- const budget = restoreBudgetFor(envInt("MINDWEAVE_AUTOCOMPACT_TOKENS", autoCompactThreshold(session.modelConfig.model)));
2463
+ const budget = restoreBudgetFor(effectiveAutoCompactThreshold(session));
2183
2464
  if (budget <= 0)
2184
2465
  return;
2185
2466
  // What the kept tail still shows. Re-sending a file the model can already see costs