mixdog 0.9.3 → 0.9.5

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 (382) hide show
  1. package/README.md +112 -38
  2. package/package.json +10 -3
  3. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  4. package/scripts/bench/lead-review-tasks.json +20 -0
  5. package/scripts/bench/r4-mixed-tasks.json +20 -0
  6. package/scripts/bench/r5-orchestrated-task.json +7 -0
  7. package/scripts/bench/review-tasks.json +20 -0
  8. package/scripts/bench/round-codex.json +114 -0
  9. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  10. package/scripts/bench/round-mixdog-lead.json +269 -0
  11. package/scripts/bench/round-mixdog.json +126 -0
  12. package/scripts/bench/round-r10-bigsample.json +679 -0
  13. package/scripts/bench/round-r11-codexalign.json +257 -0
  14. package/scripts/bench/round-r13-clientmeta.json +464 -0
  15. package/scripts/bench/round-r14-betafeatures.json +466 -0
  16. package/scripts/bench/round-r15-fulldefault.json +462 -0
  17. package/scripts/bench/round-r16-sessionid.json +466 -0
  18. package/scripts/bench/round-r17-wirebytes.json +456 -0
  19. package/scripts/bench/round-r18-prewarm.json +468 -0
  20. package/scripts/bench/round-r19-clean.json +472 -0
  21. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  22. package/scripts/bench/round-r21-delta-retry.json +473 -0
  23. package/scripts/bench/round-r22-full-probe.json +693 -0
  24. package/scripts/bench/round-r23-itemprobe.json +701 -0
  25. package/scripts/bench/round-r24-shapefix.json +677 -0
  26. package/scripts/bench/round-r25-serial.json +464 -0
  27. package/scripts/bench/round-r26-parallel3.json +671 -0
  28. package/scripts/bench/round-r27-parallel10.json +894 -0
  29. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  30. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  31. package/scripts/bench/round-r30-instid.json +253 -0
  32. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  33. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  34. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  35. package/scripts/bench/round-r34-orchestrated.json +120 -0
  36. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  37. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  38. package/scripts/bench/round-r4-codex.json +114 -0
  39. package/scripts/bench/round-r4-mixed.json +225 -0
  40. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  41. package/scripts/bench/round-r6-codex.json +114 -0
  42. package/scripts/bench/round-r6-solo.json +257 -0
  43. package/scripts/bench/round-r7-full.json +254 -0
  44. package/scripts/bench/round-r8-fulldefault.json +255 -0
  45. package/scripts/bench-run.mjs +251 -32
  46. package/scripts/freevar-smoke.mjs +95 -0
  47. package/scripts/internal-comms-bench.mjs +3 -4
  48. package/scripts/internal-comms-smoke.mjs +10 -9
  49. package/scripts/model-catalog-audit.mjs +209 -0
  50. package/scripts/model-list-sanitize-test.mjs +37 -0
  51. package/scripts/mouse-probe.mjs +45 -0
  52. package/scripts/output-style-bench.mjs +13 -6
  53. package/scripts/output-style-smoke.mjs +4 -4
  54. package/scripts/provider-toolcall-test.mjs +7 -3
  55. package/scripts/recall-bench.mjs +76 -13
  56. package/scripts/recall-quality-cases.json +12 -0
  57. package/scripts/recall-usecase-cases.json +18 -0
  58. package/scripts/session-bench.mjs +152 -6
  59. package/scripts/tool-smoke.mjs +25 -65
  60. package/scripts/tui-render-smoke.mjs +90 -0
  61. package/scripts/webhook-smoke.mjs +208 -0
  62. package/src/agents/debugger/AGENT.md +4 -1
  63. package/src/agents/heavy-worker/AGENT.md +9 -8
  64. package/src/agents/maintainer/AGENT.md +4 -0
  65. package/src/agents/reviewer/AGENT.md +2 -1
  66. package/src/agents/scheduler-task/AGENT.md +2 -3
  67. package/src/agents/webhook-handler/AGENT.md +2 -3
  68. package/src/agents/worker/AGENT.md +10 -7
  69. package/src/app.mjs +12 -1
  70. package/src/headless-role.mjs +7 -1
  71. package/src/lib/rules-builder.cjs +4 -0
  72. package/src/mixdog-session-runtime.mjs +647 -2056
  73. package/src/output-styles/default.md +30 -9
  74. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  75. package/src/output-styles/minimal.md +8 -6
  76. package/src/output-styles/simple.md +21 -7
  77. package/src/rules/agent/00-common.md +6 -3
  78. package/src/rules/agent/30-explorer.md +16 -5
  79. package/src/rules/lead/01-general.md +5 -5
  80. package/src/rules/lead/lead-brief.md +15 -0
  81. package/src/rules/lead/lead-tool.md +6 -15
  82. package/src/rules/shared/01-tool.md +17 -21
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  84. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  85. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  86. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  87. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  88. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  89. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  90. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  91. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
  92. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  93. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
  95. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
  97. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  100. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  101. package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
  102. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
  103. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +105 -11
  104. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +356 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  107. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  108. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  109. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
  110. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +740 -0
  111. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  112. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +349 -2131
  113. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +143 -1002
  114. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +229 -0
  115. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  116. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +465 -0
  117. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  118. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  119. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  120. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  121. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  122. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  123. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  124. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  125. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  126. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  127. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  128. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
  129. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  130. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +275 -0
  132. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  133. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  134. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  135. package/src/runtime/agent/orchestrator/session/loop.mjs +278 -402
  136. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  137. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
  138. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
  139. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  140. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  141. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  142. package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
  143. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  144. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  145. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  146. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  147. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  148. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +35 -44
  149. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  152. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  155. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  156. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
  157. package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
  158. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
  159. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
  161. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  163. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  164. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  165. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  166. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  167. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  168. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  169. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  170. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  171. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  172. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  173. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
  174. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
  175. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  176. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  177. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  178. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  179. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  180. package/src/runtime/channels/backends/discord.mjs +27 -318
  181. package/src/runtime/channels/backends/telegram.mjs +8 -12
  182. package/src/runtime/channels/index.mjs +247 -701
  183. package/src/runtime/channels/lib/backend-dispatch.mjs +46 -0
  184. package/src/runtime/channels/lib/config.mjs +37 -149
  185. package/src/runtime/channels/lib/event-pipeline.mjs +22 -5
  186. package/src/runtime/channels/lib/event-queue.mjs +78 -13
  187. package/src/runtime/channels/lib/inbound-routing.mjs +74 -0
  188. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  189. package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
  190. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  191. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  192. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  193. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  194. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  195. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  196. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  197. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  198. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  199. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  200. package/src/runtime/channels/lib/webhook/deliveries.mjs +313 -0
  201. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  202. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  203. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  204. package/src/runtime/channels/lib/webhook.mjs +43 -616
  205. package/src/runtime/channels/tool-defs.mjs +11 -130
  206. package/src/runtime/memory/index.mjs +210 -1948
  207. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  208. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  209. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  210. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  211. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  212. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  213. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  214. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  215. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  216. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  217. package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
  218. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  219. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  220. package/src/runtime/memory/lib/memory-recall-store.mjs +69 -12
  221. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  222. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  223. package/src/runtime/memory/lib/query-handlers.mjs +802 -0
  224. package/src/runtime/memory/lib/recall-format.mjs +55 -0
  225. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  226. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  227. package/src/runtime/memory/tool-defs.mjs +5 -13
  228. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  229. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  230. package/src/runtime/search/lib/web-tools.mjs +24 -602
  231. package/src/runtime/shared/atomic-file.mjs +26 -1
  232. package/src/runtime/shared/config.mjs +14 -4
  233. package/src/runtime/shared/launcher-control.mjs +2 -2
  234. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  235. package/src/runtime/shared/schedules-store.mjs +13 -3
  236. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  237. package/src/runtime/shared/tool-primitives.mjs +308 -0
  238. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  239. package/src/runtime/shared/tool-surface.mjs +80 -898
  240. package/src/runtime/shared/transcript-writer.mjs +23 -0
  241. package/src/runtime/shared/update-checker.mjs +7 -4
  242. package/src/session-runtime/config-helpers.mjs +119 -2
  243. package/src/session-runtime/config-lifecycle.mjs +232 -0
  244. package/src/session-runtime/cwd-plugins.mjs +226 -0
  245. package/src/session-runtime/mcp-glue.mjs +177 -0
  246. package/src/session-runtime/model-recency.mjs +111 -0
  247. package/src/session-runtime/native-search.mjs +247 -0
  248. package/src/session-runtime/output-styles.mjs +11 -9
  249. package/src/session-runtime/prewarm.mjs +142 -0
  250. package/src/session-runtime/provider-models.mjs +278 -0
  251. package/src/session-runtime/provider-usage.mjs +120 -0
  252. package/src/session-runtime/quick-model-rows.mjs +205 -0
  253. package/src/session-runtime/quick-search-models.mjs +47 -0
  254. package/src/session-runtime/session-hooks.mjs +93 -0
  255. package/src/session-runtime/settings-api.mjs +352 -0
  256. package/src/session-runtime/tool-catalog.mjs +29 -29
  257. package/src/session-runtime/tool-defs.mjs +84 -0
  258. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  259. package/src/session-runtime/workflow.mjs +1 -1
  260. package/src/standalone/agent-tool/helpers.mjs +237 -0
  261. package/src/standalone/agent-tool/notify.mjs +107 -0
  262. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  263. package/src/standalone/agent-tool/render.mjs +152 -0
  264. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  265. package/src/standalone/agent-tool.mjs +138 -669
  266. package/src/standalone/channel-admin.mjs +102 -90
  267. package/src/standalone/channel-worker.mjs +4 -7
  268. package/src/standalone/explore-tool.mjs +64 -14
  269. package/src/standalone/hook-bus/config.mjs +207 -0
  270. package/src/standalone/hook-bus/constants.mjs +90 -0
  271. package/src/standalone/hook-bus/handlers.mjs +481 -0
  272. package/src/standalone/hook-bus/payload.mjs +31 -0
  273. package/src/standalone/hook-bus/rules.mjs +77 -0
  274. package/src/standalone/hook-bus.mjs +77 -870
  275. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  276. package/src/standalone/opencode-go-login.mjs +5 -1
  277. package/src/standalone/provider-admin.mjs +1 -16
  278. package/src/standalone/usage-dashboard.mjs +3 -1
  279. package/src/tui/App.jsx +1059 -8110
  280. package/src/tui/app/app-format.mjs +213 -0
  281. package/src/tui/app/channel-pickers.mjs +508 -0
  282. package/src/tui/app/clipboard.mjs +67 -0
  283. package/src/tui/app/core-memory-picker.mjs +210 -0
  284. package/src/tui/app/extension-pickers.mjs +506 -0
  285. package/src/tui/app/input-parsers.mjs +193 -0
  286. package/src/tui/app/maintenance-pickers.mjs +356 -0
  287. package/src/tui/app/model-options.mjs +334 -0
  288. package/src/tui/app/model-picker.mjs +365 -0
  289. package/src/tui/app/onboarding-steps.mjs +400 -0
  290. package/src/tui/app/project-picker.mjs +247 -0
  291. package/src/tui/app/provider-setup-picker.mjs +580 -0
  292. package/src/tui/app/resume-picker.mjs +55 -0
  293. package/src/tui/app/route-pickers.mjs +419 -0
  294. package/src/tui/app/settings-picker.mjs +489 -0
  295. package/src/tui/app/slash-commands.mjs +101 -0
  296. package/src/tui/app/slash-dispatch.mjs +427 -0
  297. package/src/tui/app/text-layout.mjs +46 -0
  298. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  299. package/src/tui/app/transcript-window.mjs +677 -0
  300. package/src/tui/app/use-mouse-input.mjs +460 -0
  301. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  302. package/src/tui/app/use-transcript-scroll.mjs +512 -0
  303. package/src/tui/app/use-transcript-window.mjs +607 -0
  304. package/src/tui/components/ConfirmBar.jsx +10 -7
  305. package/src/tui/components/Picker.jsx +64 -15
  306. package/src/tui/components/PromptInput.jsx +33 -102
  307. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  308. package/src/tui/components/StatusLine.jsx +69 -15
  309. package/src/tui/components/TextEntryPanel.jsx +11 -0
  310. package/src/tui/components/ToolExecution.jsx +52 -594
  311. package/src/tui/components/TranscriptItem.jsx +105 -0
  312. package/src/tui/components/UsagePanel.jsx +18 -4
  313. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  314. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  315. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  316. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  317. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  318. package/src/tui/display-width.mjs +20 -3
  319. package/src/tui/dist/index.mjs +13553 -12384
  320. package/src/tui/engine/agent-job-feed.mjs +133 -0
  321. package/src/tui/engine/notification-plan.mjs +76 -0
  322. package/src/tui/engine/render-timing.mjs +17 -0
  323. package/src/tui/engine/tool-approval.mjs +94 -0
  324. package/src/tui/engine/tool-card-results.mjs +234 -0
  325. package/src/tui/engine/tool-result-status.mjs +135 -0
  326. package/src/tui/engine.mjs +170 -574
  327. package/src/tui/figures.mjs +5 -0
  328. package/src/tui/index.jsx +65 -1
  329. package/src/tui/input-editing.mjs +2 -2
  330. package/src/tui/markdown/format-token.mjs +4 -1
  331. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  332. package/src/tui/theme.mjs +6 -0
  333. package/src/ui/statusline-agents.mjs +213 -0
  334. package/src/ui/statusline-format.mjs +146 -0
  335. package/src/ui/statusline-segments.mjs +148 -0
  336. package/src/ui/statusline.mjs +77 -501
  337. package/src/ui/tool-card.mjs +0 -1
  338. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  339. package/src/workflows/default/WORKFLOW.md +16 -18
  340. package/src/workflows/sequential/WORKFLOW.md +16 -18
  341. package/vendor/ink/build/display-width.js +19 -3
  342. package/vendor/ink/build/ink.js +112 -7
  343. package/vendor/ink/build/log-update.js +17 -3
  344. package/vendor/ink/build/wrap-text.js +125 -0
  345. package/scripts/_test-folder-dialog.mjs +0 -30
  346. package/scripts/fix-brief-fn.mjs +0 -35
  347. package/scripts/fix-format-tool-surface.mjs +0 -24
  348. package/scripts/fix-tool-exec-visible.mjs +0 -42
  349. package/scripts/patch-agent-brief.mjs +0 -48
  350. package/scripts/patch-app.mjs +0 -21
  351. package/scripts/patch-app2.mjs +0 -18
  352. package/scripts/patch-dist-brief.mjs +0 -96
  353. package/scripts/patch-tool-exec.mjs +0 -70
  354. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  355. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  356. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  357. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  358. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  359. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
  360. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  361. package/src/runtime/channels/lib/holidays.mjs +0 -138
  362. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  363. package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
  364. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  365. package/src/standalone/agent-task-status.test.mjs +0 -76
  366. package/src/tui/components/tool-output-format.test.mjs +0 -399
  367. package/src/tui/display-width.test.mjs +0 -35
  368. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  369. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  370. package/src/tui/input-editing.selection.test.mjs +0 -75
  371. package/src/tui/markdown/format-token.test.mjs +0 -354
  372. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  373. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  374. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  375. package/src/tui/paste-fix.test.mjs +0 -119
  376. package/src/tui/prompt-history-store.test.mjs +0 -52
  377. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  378. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  379. package/src/ui/markdown.test.mjs +0 -70
  380. package/src/ui/statusline-context-label.test.mjs +0 -15
  381. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  382. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -6,6 +6,35 @@ import { mixdogHome } from "../../shared/plugin-paths.mjs";
6
6
  function cwdToProjectSlug(cwd) {
7
7
  return resolve(cwd).replace(/\\/g, "/").replace(/^([A-Za-z]):/, "$1-").replace(/\//g, "-");
8
8
  }
9
+ // Parent of a live pid never changes for the lifetime of that pid, so a
10
+ // resolved (non-null) parent is cached forever. A null result (pid gone /
11
+ // lookup failed) is cached briefly so a burst of calls during process exit
12
+ // doesn't re-spawn powershell.exe on every tick, but still allows retry.
13
+ // A cached null is only trusted while the pid itself is DEAD: if the pid is
14
+ // still alive, a null parent was a transient lookup failure (e.g. powershell
15
+ // hiccup), and serving it from cache would make every retry inside the
16
+ // first-inbound steal poll (50ms x 500ms window) reuse the failure and time
17
+ // out to the stale binding. Live pid + cached null = treat as cache miss.
18
+ const parentPidCache = new Map(); // pid -> { value: number|null, expiresAt: number|null }
19
+ const NULL_PARENT_TTL_MS = 30_000;
20
+
21
+ function _pidAlive(pid) {
22
+ try { process.kill(pid, 0); return true; } catch { return false; }
23
+ }
24
+ function getParentPidCached(pid, fresh = false) {
25
+ const cached = parentPidCache.get(pid);
26
+ if (!fresh && cached && (cached.expiresAt === null || cached.expiresAt > Date.now())) {
27
+ if (cached.value !== null || !_pidAlive(pid)) return cached.value;
28
+ // fall through: live pid with cached-null parent → retry fresh lookup
29
+ }
30
+ if (fresh && cached && cached.expiresAt === null) return cached.value; // resolved parent never changes
31
+ const value = getParentPid(pid);
32
+ parentPidCache.set(pid, {
33
+ value,
34
+ expiresAt: value === null ? Date.now() + NULL_PARENT_TTL_MS : null
35
+ });
36
+ return value;
37
+ }
9
38
  function getParentPid(pid) {
10
39
  try {
11
40
  if (process.platform === "win32") {
@@ -67,12 +96,35 @@ function isInteractiveSession(session) {
67
96
  if (!session) return false;
68
97
  return session.kind === "interactive" || !session.kind && session.entrypoint === "cli";
69
98
  }
70
- function discoverCurrentClaudeSession() {
71
- let pid = process.ppid;
99
+ // Full parent-PID walk result cached per process.ppid for a short TTL so
100
+ // rapid-fire inbound messages / watchDebounce ticks don't repeat the walk.
101
+ // A cache hit still re-verifies the session file exists (cheap fs read)
102
+ // before being trusted, so staleness is bounded by readSessionRecord truth,
103
+ // not just the TTL. Null (no session found) is intentionally NOT cached:
104
+ // right after channel activate the parent session record may not exist yet,
105
+ // and negative-caching would delay the first transcript steal by up to the
106
+ // TTL. Re-walking on miss is cheap because parentPidCache makes the walk
107
+ // spawn-free.
108
+ const sessionWalkCache = new Map(); // ppid -> { session, expiresAt }
109
+ const SESSION_WALK_TTL_MS = 10_000;
110
+
111
+ function discoverCurrentClaudeSession(options = {}) {
112
+ const fresh = options.fresh === true;
113
+ const rootPid = process.ppid;
114
+ const cached = fresh ? undefined : sessionWalkCache.get(rootPid);
115
+ if (cached && cached.expiresAt > Date.now()) {
116
+ const reverified = readSessionRecord(cached.session.pid);
117
+ if (reverified) return reverified;
118
+ sessionWalkCache.delete(rootPid);
119
+ }
120
+ let pid = rootPid;
72
121
  for (let depth = 0; pid && pid > 1 && depth < 6; depth += 1) {
73
122
  const session = readSessionRecord(pid);
74
- if (session) return session;
75
- pid = getParentPid(pid);
123
+ if (session) {
124
+ sessionWalkCache.set(rootPid, { session, expiresAt: Date.now() + SESSION_WALK_TTL_MS });
125
+ return session;
126
+ }
127
+ pid = getParentPidCached(pid, fresh);
76
128
  }
77
129
  return null;
78
130
  }
@@ -0,0 +1,158 @@
1
+ import { OutputForwarder } from "./output-forwarder.mjs";
2
+
3
+ // Worker/HTTP tool-call dispatch + bridge auto-connect retry wrapper.
4
+ // Extracted verbatim from channels/index.mjs (behavior-preserving). The
5
+ // `handleToolCall` switch is entangled with ~8 runtime-lifecycle functions
6
+ // (claim/refresh ownership, stop owned runtime, transcript rebind, config
7
+ // reload) plus mutable owner state (channelBridgeActive, bridgeRuntimeConnected)
8
+ // and the forwarder. Those are threaded in as a `lifecycle` bag of lazy
9
+ // getters/functions so the module reads the live file-level references at
10
+ // call time — matching the original in-file closure semantics.
11
+ function createToolDispatch({
12
+ getForwarder,
13
+ BACKEND_TOOLS,
14
+ isChannelsDegraded,
15
+ dispatchReply,
16
+ dispatchFetch,
17
+ lifecycle,
18
+ }) {
19
+ const {
20
+ getBridgeRuntimeConnected,
21
+ getChannelBridgeActive,
22
+ setChannelBridgeActive,
23
+ writeBridgeState,
24
+ stopServerTyping,
25
+ claimBridgeOwnership,
26
+ refreshBridgeOwnership,
27
+ bindPersistedTranscriptIfAny,
28
+ stopOwnedRuntime,
29
+ reloadRuntimeConfig,
30
+ } = lifecycle;
31
+
32
+ async function handleToolCall(name, args, _signal) {
33
+ if (isChannelsDegraded()) {
34
+ return { content: [{ type: 'text', text: `[channels degraded] ${name} unavailable — restart MCP to recover` }], isError: true }
35
+ }
36
+ let result;
37
+ try {
38
+ switch (name) {
39
+ case "reply":
40
+ result = await dispatchReply(args);
41
+ break;
42
+ case "fetch":
43
+ result = await dispatchFetch(args);
44
+ break;
45
+ case "activate_channel_bridge": {
46
+ const active = args.active === true;
47
+ const wasActive = getChannelBridgeActive();
48
+ setChannelBridgeActive(active);
49
+ writeBridgeState(active);
50
+ if (active) {
51
+ // Force-takeover semantics: activation ALWAYS claims the seat
52
+ // (last-wins overwrite of active-instance.json) and refreshes the
53
+ // output-forwarder binding onto THIS session — even when the
54
+ // bridge was already active. `/remote` relies on this to re-occupy
55
+ // after another session stole ownership, and to re-pin forwarding
56
+ // to the current session transcript.
57
+ claimBridgeOwnership(wasActive ? "re-activate takeover" : "bridge activated");
58
+ try {
59
+ await refreshBridgeOwnership({ restoreBinding: true });
60
+ // An already-connected owner returns early from
61
+ // startOwnedRuntime(), so rebind explicitly to follow the
62
+ // current (parent-chain) session transcript.
63
+ if (getBridgeRuntimeConnected()) await bindPersistedTranscriptIfAny();
64
+ } catch (e) {
65
+ process.stderr.write(`mixdog: bridge activate refresh failed (non-fatal): ${e?.message || e}\n`);
66
+ }
67
+ }
68
+ if (!active && wasActive) {
69
+ stopServerTyping();
70
+ // Tear down the owner-side runtime so Discord/scheduler/webhook/
71
+ // event-pipeline don't keep running on a deactivated bridge.
72
+ try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
73
+ process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
74
+ }
75
+ }
76
+ result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
77
+ break;
78
+ }
79
+ case "reload_config": {
80
+ await reloadRuntimeConfig();
81
+ // Extend reload to the agent module so providers/presets/maintenance
82
+ // hot-reload on the same call (dynamic import: agent/index.mjs does not
83
+ // import channels, so this stays acyclic and tolerant of load order).
84
+ let agentReloadMsg = "";
85
+ if (process.env.MIXDOG_STANDALONE !== '1') {
86
+ try {
87
+ const { reloadAgentConfig } = await import("../../agent/index.mjs");
88
+ await reloadAgentConfig("reload_config tool");
89
+ agentReloadMsg = ", agent providers/presets/maintenance";
90
+ } catch (err) {
91
+ process.stderr.write(`[reload_config] agent reload failed: ${err?.message || String(err)}\n`);
92
+ }
93
+ }
94
+ result = { content: [{ type: "text", text: `config reloaded — schedules, webhooks, events${agentReloadMsg} re-registered` }] };
95
+ break;
96
+ }
97
+ // memory — handled by memory-service.mjs MCP
98
+ default:
99
+ result = {
100
+ content: [{ type: "text", text: `unknown tool: ${name}` }],
101
+ isError: true
102
+ };
103
+ }
104
+ } catch (err) {
105
+ const msg = err instanceof Error ? err.message : String(err);
106
+ result = {
107
+ content: [{ type: "text", text: `${name} failed: ${msg}` }],
108
+ isError: true
109
+ };
110
+ }
111
+ return result;
112
+ }
113
+
114
+ // Bridge auto-connect retry + forwarder-aware tool dispatch wrapper. Used by
115
+ // both the HTTP MCP path (createHttpMcpServer's CallTool handler can call this)
116
+ // and the worker IPC handler at the bottom of index.mjs.
117
+ // Last timestamp a forwardNewText() call was dispatched (debounce for item 4).
118
+ let _lastForwardMs = 0;
119
+
120
+ async function handleToolCallWithBridgeRetry(toolName, args, signal) {
121
+ const forwarder = getForwarder();
122
+ // Debounce: only forward when ≥250 ms have elapsed since the last forward,
123
+ // to avoid one HTTP roundtrip per tool call on rapid-fire sequences.
124
+ const now = Date.now();
125
+ if (now - _lastForwardMs >= 250) {
126
+ _lastForwardMs = now;
127
+ await forwarder.forwardNewText();
128
+ }
129
+ if (BACKEND_TOOLS.has(toolName) && !getBridgeRuntimeConnected()) {
130
+ // Remote-owner startup: ensure this owner's backend is connected.
131
+ for (let i = 0; i < 2 && !getBridgeRuntimeConnected(); i++) {
132
+ try {
133
+ await refreshBridgeOwnership();
134
+ } catch {
135
+ }
136
+ if (!getBridgeRuntimeConnected()) await new Promise((r) => setTimeout(r, 300));
137
+ }
138
+ if (!getBridgeRuntimeConnected()) {
139
+ return {
140
+ content: [{ type: "text", text: `Discord auto-connect failed after retries. Check token and network.` }],
141
+ isError: true
142
+ };
143
+ }
144
+ }
145
+ const result = await handleToolCall(toolName, args, signal);
146
+ const toolLine = OutputForwarder.buildToolLine(toolName, args);
147
+ if (toolLine) {
148
+ // Distinct from the dispatch-log ok line (server-main.mjs): this forwards
149
+ // a human-readable tool summary to Discord for the user, not operator stdout.
150
+ void forwarder.forwardToolLog(toolLine, toolName, args);
151
+ }
152
+ return result;
153
+ }
154
+
155
+ return { handleToolCall, handleToolCallWithBridgeRetry };
156
+ }
157
+
158
+ export { createToolDispatch };
@@ -42,7 +42,7 @@ function isMemoryFile(filePath) {
42
42
  function isHidden(name) {
43
43
  if (HIDDEN_TOOLS.has(name)) return true;
44
44
  if (formatToolSurface(name, {}).label === "Memory") return false;
45
- if (name === "reply" || name === "react" || name === "edit_message" || name === "fetch" || name === "download_attachment") return true;
45
+ if (name === "reply" || name === "fetch") return true;
46
46
  return false;
47
47
  }
48
48
  /**
@@ -174,7 +174,7 @@ function compareTranscriptCandidates(left, right) {
174
174
  if (right.startedAt !== left.startedAt) return right.startedAt - left.startedAt;
175
175
  return (right.claudePid ?? 0) - (left.claudePid ?? 0);
176
176
  }
177
- function detectCurrentSessionTranscript() {
177
+ function detectCurrentSessionTranscript(options = {}) {
178
178
  const candidates = [];
179
179
  const seen = new Set();
180
180
  const addCandidate = (candidate) => {
@@ -185,7 +185,7 @@ function detectCurrentSessionTranscript() {
185
185
  seen.add(key);
186
186
  candidates.push(candidate);
187
187
  };
188
- addCandidate(sessionTranscriptCandidate(discoverCurrentClaudeSession(), "parent-chain"));
188
+ addCandidate(sessionTranscriptCandidate(discoverCurrentClaudeSession(options), "parent-chain"));
189
189
  for (const session of listInteractiveClaudeSessions()) {
190
190
  addCandidate(sessionTranscriptCandidate(session, "sessions-dir"));
191
191
  }
@@ -193,8 +193,8 @@ function detectCurrentSessionTranscript() {
193
193
  candidates.sort(compareTranscriptCandidates);
194
194
  return candidates[0] ?? null;
195
195
  }
196
- function discoverSessionBoundTranscript() {
197
- return detectCurrentSessionTranscript();
196
+ function discoverSessionBoundTranscript(options = {}) {
197
+ return detectCurrentSessionTranscript(options);
198
198
  }
199
199
 
200
200
  export {
@@ -178,13 +178,16 @@ async function _withInstallLock(rootDir, lockName, fn, { pollMs = 250 } = {}) {
178
178
  }
179
179
 
180
180
  async function loadManifest(dataDir) {
181
+ // Bundled manifest FIRST: it always matches the installed mixdog version.
182
+ // A stale cached manifest.json from an older install must never shadow it
183
+ // (caused sha256 mismatches on already-installed machines after upgrades).
184
+ if (existsSync(BUNDLED_MANIFEST_PATH)) {
185
+ return JSON.parse(readFileSync(BUNDLED_MANIFEST_PATH, 'utf8'))
186
+ }
181
187
  const cachedPath = join(dataDir, 'voice-runtime', 'manifest.json')
182
188
  if (existsSync(cachedPath)) {
183
189
  try { return JSON.parse(readFileSync(cachedPath, 'utf8')) } catch {}
184
190
  }
185
- if (existsSync(BUNDLED_MANIFEST_PATH)) {
186
- return JSON.parse(readFileSync(BUNDLED_MANIFEST_PATH, 'utf8'))
187
- }
188
191
  const res = await fetch(MANIFEST_URL, { signal: AbortSignal.timeout(30_000) })
189
192
  if (!res.ok) throw new Error(`[voice-runtime] manifest fetch failed: ${res.status} ${res.statusText}`)
190
193
  const manifest = await res.json()
@@ -0,0 +1,179 @@
1
+ import { spawn } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { createRequire } from "module";
6
+ import { resolveVoiceRuntime } from "./voice-runtime-fetcher.mjs";
7
+ import { ensureReady, transcribe } from "./whisper-server.mjs";
8
+ import { normalizeWhisperLanguage, detectDeviceLanguage } from "./whisper-language.mjs";
9
+
10
+ const _require = createRequire(import.meta.url);
11
+
12
+ // Voice-attachment transcription pipeline. Extracted verbatim from
13
+ // channels/index.mjs (behavior-preserving). `config` is read live through the
14
+ // injected getter so runtime config reloads (reloadRuntimeConfig) keep the
15
+ // same semantics as the original file-level `let config` reference.
16
+ function isVoiceAttachment(contentType) {
17
+ if (typeof contentType !== 'string') return false;
18
+ const ct = contentType.toLowerCase();
19
+ return ct.startsWith("audio/") || ct.startsWith("application/ogg");
20
+ }
21
+
22
+ function runCmd(cmd, args, capture = false) {
23
+ return new Promise((resolve, reject) => {
24
+ const proc = spawn(cmd, args, {
25
+ stdio: capture ? ["ignore", "pipe", "ignore"] : "ignore",
26
+ windowsHide: true
27
+ });
28
+ let out = "";
29
+ if (capture && proc.stdout) proc.stdout.on("data", (d) => {
30
+ out += d;
31
+ });
32
+ proc.on("close", (code) => code === 0 ? resolve(out) : reject(new Error(`${cmd} exit ${code}`)));
33
+ proc.on("error", reject);
34
+ });
35
+ }
36
+
37
+ // Creates the voice-transcription surface bound to a live config getter and
38
+ // data dir. Returns { isVoiceAttachment, transcribeVoice }.
39
+ function createVoiceTranscription({ getConfig, dataDir }) {
40
+ // ── voice.transcription concurrency queue (max=1 by default, config-driven) ──
41
+ const _voiceTranscriptionQueue = (() => {
42
+ let running = 0;
43
+ const pending = [];
44
+ function drain() {
45
+ const limit = getConfig().voice?.transcription?.maxConcurrency ?? 1;
46
+ while (running < limit && pending.length > 0) {
47
+ const { fn, resolve, reject } = pending.shift();
48
+ running++;
49
+ fn().then(resolve, reject).finally(() => { running--; drain(); });
50
+ }
51
+ }
52
+ return function enqueue(fn) {
53
+ return new Promise((resolve, reject) => { pending.push({ fn, resolve, reject }); drain(); });
54
+ };
55
+ })();
56
+
57
+ // ── wav + transcript cache keyed by attachment id ──
58
+ const _voiceWavCache = new Map(); // attachmentId → wavPath
59
+ const _voiceTranscriptCache = new Map(); // attachmentId → transcript string
60
+ const _voiceInflight = new Map(); // attachmentId → Promise<string|null>
61
+ const _voiceFfmpegInflight = new Map(); // attachmentId|wavPath → Promise<void> single-flight ffmpeg
62
+
63
+ async function _probeAudioDurationSec(filePath) {
64
+ try {
65
+ const ffprobePath = (() => { try { return _require('ffprobe-static').path; } catch { return 'ffprobe'; } })();
66
+ return await new Promise((resolve, reject) => {
67
+ const args = ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', filePath];
68
+ let out = '';
69
+ const proc = spawn(ffprobePath, args, { windowsHide: true });
70
+ proc.stdout.on('data', (d) => { out += d; });
71
+ proc.on('close', (code) => { code === 0 ? resolve(parseFloat(out.trim()) || null) : reject(new Error(`ffprobe exit ${code}`)); });
72
+ proc.on('error', reject);
73
+ });
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ async function transcribeVoice(audioPath, { attachmentId } = {}) {
80
+ const config = getConfig();
81
+ // ── size gate (config: voice.transcription.maxFileSizeMB) ──
82
+ const maxSizeBytes = (config.voice?.transcription?.maxFileSizeMB ?? 0) * 1024 * 1024;
83
+ if (maxSizeBytes > 0) {
84
+ try {
85
+ const stat = await fs.promises.stat(audioPath);
86
+ if (stat.size > maxSizeBytes) {
87
+ process.stderr.write(`mixdog: voice.transcription skipped — file too large (${(stat.size / 1024 / 1024).toFixed(1)} MB > ${config.voice.transcription.maxFileSizeMB} MB): ${audioPath}\n`);
88
+ return null;
89
+ }
90
+ } catch { /* stat failure: proceed */ }
91
+ }
92
+ // ── duration gate (config: voice.transcription.maxDurationSec) ──
93
+ const maxDurationSec = config.voice?.transcription?.maxDurationSec ?? 0;
94
+ if (maxDurationSec > 0) {
95
+ const dur = await _probeAudioDurationSec(audioPath);
96
+ if (dur !== null && dur > maxDurationSec) {
97
+ process.stderr.write(`mixdog: voice.transcription skipped — audio too long (${Math.floor(dur)}s > ${maxDurationSec}s): ${audioPath}\n`);
98
+ return null;
99
+ }
100
+ }
101
+ // ── transcript cache hit ──
102
+ if (attachmentId && _voiceTranscriptCache.has(attachmentId)) {
103
+ process.stderr.write(`mixdog: voice.transcription cache hit (${attachmentId})\n`);
104
+ return _voiceTranscriptCache.get(attachmentId);
105
+ }
106
+ if (attachmentId && _voiceInflight.has(attachmentId)) {
107
+ return _voiceInflight.get(attachmentId);
108
+ }
109
+ const p = _voiceTranscriptionQueue(() => _doTranscribeVoice(audioPath, attachmentId));
110
+ if (attachmentId) {
111
+ _voiceInflight.set(attachmentId, p);
112
+ p.catch((err) => {
113
+ try { process.stderr.write(`mixdog: voice.transcription inflight rejection: ${err?.stack || err}\n`); } catch {}
114
+ }).finally(() => _voiceInflight.delete(attachmentId));
115
+ }
116
+ return p;
117
+ }
118
+
119
+ async function _doTranscribeVoice(audioPath, attachmentId) {
120
+ const config = getConfig();
121
+ try {
122
+ const runtime = resolveVoiceRuntime(dataDir);
123
+ if (!runtime?.installed) {
124
+ const missing = [runtime?.binary ? null : 'binary', runtime?.model ? null : 'model', runtime?.ffmpeg ? null : 'ffmpeg'].filter(Boolean).join(' + ');
125
+ throw new Error(`voice runtime not installed (missing: ${missing}) — open the setup wizard and click "Install voice"`);
126
+ }
127
+ const whisperCmd = runtime.whisperCmd;
128
+ const modelPath = runtime.modelPath;
129
+ const ffmpegPath = runtime.ffmpegPath;
130
+ const lang = normalizeWhisperLanguage(config.voice?.language) ?? detectDeviceLanguage();
131
+ const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
132
+ const threadCount = config.voice?.transcription?.threadCount ?? Math.max(1, Math.ceil(_cpuCount / 4));
133
+ // ── wav cache keyed by attachment id ──
134
+ let wavPath;
135
+ if (attachmentId && _voiceWavCache.has(attachmentId)) {
136
+ wavPath = _voiceWavCache.get(attachmentId);
137
+ if (!fs.existsSync(wavPath)) {
138
+ _voiceWavCache.delete(attachmentId);
139
+ wavPath = undefined;
140
+ } else {
141
+ process.stderr.write(`mixdog: voice.transcription wav cache hit (${attachmentId})\n`);
142
+ }
143
+ }
144
+ if (!wavPath) {
145
+ wavPath = audioPath.replace(/\.[^.]+$/, ".wav");
146
+ const sampleRate = config.voice?.transcription?.sampleRate ?? 16000;
147
+ const channels = config.voice?.transcription?.channels ?? 1;
148
+ // Single-flight: parallel callers for the same key share one ffmpeg spawn.
149
+ const _ffmpegKey = attachmentId || wavPath;
150
+ if (_voiceFfmpegInflight.has(_ffmpegKey)) {
151
+ await _voiceFfmpegInflight.get(_ffmpegKey);
152
+ } else {
153
+ const _ffmpegPromise = runCmd(ffmpegPath, ["-i", audioPath, "-ar", String(sampleRate), "-ac", String(channels), "-threads", String(threadCount), "-y", wavPath]);
154
+ _voiceFfmpegInflight.set(_ffmpegKey, _ffmpegPromise);
155
+ try {
156
+ await _ffmpegPromise;
157
+ if (attachmentId) _voiceWavCache.set(attachmentId, wavPath);
158
+ } finally {
159
+ _voiceFfmpegInflight.delete(_ffmpegKey);
160
+ }
161
+ }
162
+ }
163
+ process.stderr.write(`mixdog: voice.transcription start runtime=${runtime.kind} cmd=${path.basename(whisperCmd)}\n`);
164
+ await ensureReady({ serverCmd: runtime.serverCmd, modelPath, threadCount, host: '127.0.0.1' });
165
+ const text = await transcribe(wavPath, { language: lang });
166
+ const result = text.trim() || null;
167
+ if (attachmentId && result) _voiceTranscriptCache.set(attachmentId, result);
168
+ return result;
169
+ } catch (err) {
170
+ if (err?.message?.startsWith('voice runtime not installed')) throw err; // propagate setup errors; caller posts user-visible failure
171
+ process.stderr.write(`mixdog: voice.transcription failed: ${err}\n`);
172
+ return null;
173
+ }
174
+ }
175
+
176
+ return { isVoiceAttachment, transcribeVoice };
177
+ }
178
+
179
+ export { isVoiceAttachment, runCmd, createVoiceTranscription };