mixdog 0.9.1 → 0.9.3

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 (301) hide show
  1. package/package.json +9 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -0,0 +1,469 @@
1
+ /**
2
+ * src/tui/lib/voice-recorder.mjs — TUI-local voice install/toggle orchestration.
3
+ *
4
+ * Step1 (/voice command): owns the `voice.enabled` config flag and drives the
5
+ * managed whisper.cpp + ffmpeg runtime install (voice-runtime-fetcher.mjs) the
6
+ * first time voice is turned ON.
7
+ *
8
+ * Step2 (Ctrl+Space recorder): startRecording/stopRecording/cancelRecording
9
+ * drive a small idle -> recording -> transcribing -> idle state machine.
10
+ * Recording spawns ffmpeg against a DirectShow ("dshow") capture device (the
11
+ * only capture API implemented — mixdog's managed voice runtime targets
12
+ * win32; the enumerate/record commands below are DirectShow-specific).
13
+ * Transcription reuses the SAME whisper-server.mjs singleton manager the
14
+ * channels pipeline uses (ensureReady + transcribe), so a TUI-triggered
15
+ * transcription and a Discord-attachment transcription share one warm
16
+ * whisper-server child instead of racing two.
17
+ *
18
+ * Config load is a STATIC import here (approved design decision) — unlike
19
+ * theme.mjs's dist-aware dynamic import of config.mjs, this file is not on the
20
+ * TUI's hot boot path and esbuild bundling config.mjs's relative dependency
21
+ * graph (plugin-paths/atomic-file/user-data-guard + the keychain .cjs via
22
+ * createRequire) into src/tui/dist/index.mjs is harmless: config.mjs's
23
+ * createRequire('../../lib/keychain-cjs.cjs') resolves relative to
24
+ * import.meta.url, and src/tui/dist/ sits at the same depth-from-src as
25
+ * src/runtime/shared/ (2 levels), so the relative path still lands on
26
+ * src/lib/keychain-cjs.cjs after bundling.
27
+ *
28
+ * The whisper.cpp/ffmpeg runtime installer (voice-runtime-fetcher.mjs) and
29
+ * the whisper-server manager (whisper-server.mjs) are BOTH loaded lazily via
30
+ * dynamic import — neither should load into memory unless a user actually
31
+ * asks for voice.
32
+ */
33
+ import { spawn, spawnSync } from 'node:child_process';
34
+ import { cpus, tmpdir } from 'node:os';
35
+ import { join } from 'node:path';
36
+ import { unlinkSync } from 'node:fs';
37
+ import { readSection, updateSection } from '../../runtime/shared/config.mjs';
38
+ import { resolvePluginData } from '../../runtime/shared/plugin-paths.mjs';
39
+
40
+ let _voiceRuntimeFetcherPromise = null;
41
+ function loadVoiceRuntimeFetcher() {
42
+ if (!_voiceRuntimeFetcherPromise) {
43
+ _voiceRuntimeFetcherPromise = import('../../runtime/channels/lib/voice-runtime-fetcher.mjs');
44
+ }
45
+ return _voiceRuntimeFetcherPromise;
46
+ }
47
+
48
+ let _whisperServerPromise = null;
49
+ // Set once loadWhisperServer()'s import resolves, so disposeRecorder() (a
50
+ // SYNC function called from a React unmount cleanup) can check "was the
51
+ // whisper-server manager ever engaged this session" without triggering a
52
+ // fresh dynamic import just to shut down something that was never started.
53
+ let _whisperServerModule = null;
54
+ function loadWhisperServer() {
55
+ if (!_whisperServerPromise) {
56
+ _whisperServerPromise = import('../../runtime/channels/lib/whisper-server.mjs').then((mod) => {
57
+ _whisperServerModule = mod;
58
+ return mod;
59
+ });
60
+ }
61
+ return _whisperServerPromise;
62
+ }
63
+
64
+ // Reentrancy guard: only one install (ensureWhisperRuntime/ensureWhisperModel/
65
+ // ensureFfmpegRuntime sequence) may run at a time per process.
66
+ let _voiceInstallBusy = false;
67
+
68
+ /** True while a Step1 install sequence is in flight. */
69
+ export function isVoiceInstallBusy() {
70
+ return _voiceInstallBusy;
71
+ }
72
+
73
+ /** Read `voice.enabled` from mixdog-config.json (top-level `voice` section). */
74
+ export function isVoiceEnabled() {
75
+ try {
76
+ return readSection('voice')?.enabled === true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ // Coalesce ensure*'s onProgress ({ phase, downloaded, total } | { phase:'extra', ... })
83
+ // into a sticky, in-place input-hint-line progress bar (setProgressHint) so a
84
+ // multi-hundred-MB model download doesn't spam the notice toast on every
85
+ // chunk. Falls back to the old throttled (2s) toast-spam behavior when the
86
+ // caller (e.g. the channels pipeline) doesn't have a progress-hint slot to
87
+ // render into — only pushNotice is guaranteed to exist everywhere.
88
+ const PROGRESS_BAR_CELLS = 6;
89
+ function renderProgressBarText(phase, downloaded, total) {
90
+ const label = phaseLabel(phase);
91
+ if (total > 0) {
92
+ const ratio = Math.max(0, Math.min(1, downloaded / total));
93
+ const filled = Math.round(ratio * PROGRESS_BAR_CELLS);
94
+ const bar = '▓'.repeat(filled) + '░'.repeat(PROGRESS_BAR_CELLS - filled);
95
+ return `⬇ Voice ${label} ${bar} ${Math.round(ratio * 100)}%`;
96
+ }
97
+ return `⬇ Voice ${label} …`;
98
+ }
99
+ function phaseLabel(phase) {
100
+ if (phase === 'model') return 'model';
101
+ if (phase === 'ffmpeg') return 'ffmpeg';
102
+ if (phase === 'extra') return 'extra data';
103
+ return 'whisper runtime';
104
+ }
105
+ function makeThrottledProgressNotice({ pushNotice, setProgressHint } = {}, intervalMs = 500) {
106
+ let lastEmitAt = 0;
107
+ return (progress = {}) => {
108
+ const now = Date.now();
109
+ if (now - lastEmitAt < intervalMs) return;
110
+ lastEmitAt = now;
111
+ const total = Number(progress.total) || 0;
112
+ const downloaded = Number(progress.downloaded) || 0;
113
+ const text = renderProgressBarText(progress.phase, downloaded, total);
114
+ if (typeof setProgressHint === 'function') {
115
+ setProgressHint(text, 'info');
116
+ return;
117
+ }
118
+ if (typeof pushNotice === 'function') pushNotice(text, 'info');
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Ensure every managed voice-runtime component (whisper binary+server,
124
+ * whisper model, ffmpeg) is installed. Installs ONLY the missing pieces —
125
+ * an already-installed component is never re-fetched. Returns the resolved
126
+ * runtime descriptor (resolveVoiceRuntime shape) once `installed` is true;
127
+ * throws on any ensure* failure (manifest fetch, sha256 mismatch, etc.).
128
+ */
129
+ export async function ensureVoiceRuntimeReady({ dataDir = resolvePluginData(), pushNotice, setProgressHint } = {}) {
130
+ const fetcher = await loadVoiceRuntimeFetcher();
131
+ let runtime = fetcher.resolveVoiceRuntime(dataDir);
132
+ if (runtime.installed) return runtime;
133
+
134
+ const onProgress = makeThrottledProgressNotice({ pushNotice, setProgressHint });
135
+ if (!runtime.binary || !runtime.serverCmd) {
136
+ await fetcher.ensureWhisperRuntime(dataDir, onProgress);
137
+ }
138
+ if (!runtime.model) {
139
+ await fetcher.ensureWhisperModel(dataDir, onProgress);
140
+ }
141
+ if (!runtime.ffmpeg) {
142
+ await fetcher.ensureFfmpegRuntime(dataDir, onProgress);
143
+ }
144
+
145
+ runtime = fetcher.resolveVoiceRuntime(dataDir);
146
+ if (!runtime.installed) {
147
+ throw new Error('voice runtime install did not complete (still missing a required component)');
148
+ }
149
+ return runtime;
150
+ }
151
+
152
+ /**
153
+ * /voice command entry point. OFF -> immediately flips `voice.enabled` to
154
+ * false and notices "Voice OFF" (no install work, no busy gate — a disable
155
+ * must never be blocked by an in-flight install of a DIFFERENT toggle-on).
156
+ * ON -> busy-guarded: checks resolveVoiceRuntime, installs only missing
157
+ * components, then persists `voice.enabled: true` and notices "Voice ON —
158
+ * Ctrl+Space to record". Any ensure* failure notices the cause and leaves
159
+ * `voice.enabled` untouched (still off).
160
+ *
161
+ * Returns the NEW enabled state (true/false) on success, or null when the
162
+ * toggle could not run (install already in flight) or failed.
163
+ */
164
+ export async function toggleVoice({ pushNotice, setProgressHint } = {}) {
165
+ const dataDir = resolvePluginData();
166
+ if (isVoiceEnabled()) {
167
+ // Med-5: updateSection is a synchronous file write (readAllForRmW +
168
+ // atomic write under a file lock) and can throw — a locked/corrupt
169
+ // config, a permissions error, etc. An unguarded throw here would
170
+ // propagate out of the OFF branch uncaught (toggleVoice's try/catch
171
+ // below only wraps the ON path) and crash the caller. Guard it the
172
+ // same way the ON path is guarded, and report failure via pushNotice
173
+ // instead of silently pretending the toggle succeeded.
174
+ try {
175
+ updateSection('voice', (current) => ({ ...current, enabled: false }));
176
+ } catch (err) {
177
+ pushNotice?.(`Voice OFF failed: ${err?.message || err}`, 'error');
178
+ return { ok: false, error: err?.message || String(err) };
179
+ }
180
+ pushNotice?.('Voice OFF', 'info');
181
+ return false;
182
+ }
183
+ if (_voiceInstallBusy) {
184
+ pushNotice?.('Voice install is already running', 'warn');
185
+ return null;
186
+ }
187
+ _voiceInstallBusy = true;
188
+ try {
189
+ await ensureVoiceRuntimeReady({ dataDir, pushNotice, setProgressHint });
190
+ updateSection('voice', (current) => ({ ...current, enabled: true }));
191
+ setProgressHint?.('');
192
+ pushNotice?.('Voice ON — Ctrl+Space to record', 'info');
193
+ return true;
194
+ } catch (err) {
195
+ setProgressHint?.('');
196
+ pushNotice?.(`Voice setup failed: ${err?.message || err}`, 'error');
197
+ return null;
198
+ } finally {
199
+ _voiceInstallBusy = false;
200
+ }
201
+ }
202
+
203
+ // ── Step2: idle -> recording -> transcribing -> idle ────────────────────────
204
+
205
+ const RECORDER_STATE = Object.freeze({
206
+ IDLE: 'idle',
207
+ STARTING: 'starting',
208
+ RECORDING: 'recording',
209
+ TRANSCRIBING: 'transcribing',
210
+ });
211
+
212
+ // Module-scoped: the TUI has exactly one prompt/recorder, so one singleton
213
+ // state machine (mirrors whisper-server.mjs's own single-manager pattern).
214
+ const rec = {
215
+ state: RECORDER_STATE.IDLE,
216
+ child: null, // ffmpeg ChildProcess while RECORDING
217
+ wavPath: null, // tmp wav path for the in-flight/just-finished recording
218
+ };
219
+
220
+ /** Current recorder state — 'idle' | 'recording' | 'transcribing'. */
221
+ export function getRecorderState() {
222
+ return rec.state;
223
+ }
224
+
225
+ function newTmpWavPath() {
226
+ return join(tmpdir(), `mixdog-rec-${Date.now()}.wav`);
227
+ }
228
+
229
+ // Resolve the ffmpeg binary: prefer the managed runtime (Step1 install),
230
+ // fall back to a bare 'ffmpeg' on PATH (spawn resolves PATH itself when no
231
+ // path separator is present), so a user with a system ffmpeg install still
232
+ // gets Ctrl+Space without running /voice's installer.
233
+ async function resolveFfmpegCmd(dataDir) {
234
+ const fetcher = await loadVoiceRuntimeFetcher();
235
+ const managed = fetcher.resolveManagedFfmpegPath(dataDir);
236
+ if (managed) return managed;
237
+ const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['ffmpeg'], { windowsHide: true });
238
+ if (probe.status === 0) return 'ffmpeg';
239
+ return null;
240
+ }
241
+
242
+ // Enumerate DirectShow audio-capture devices via
243
+ // `ffmpeg -hide_banner -list_devices true -f dshow -i dummy`, which ffmpeg
244
+ // (by design) always exits non-zero on while printing the device list to
245
+ // stderr. Returns the first "(audio)" device name, or null when none is
246
+ // found / the probe fails. UTF-8 decoded (device names may be non-ASCII on
247
+ // a localized Windows install).
248
+ function listFirstDshowAudioDevice(ffmpegCmd) {
249
+ const r = spawnSync(ffmpegCmd, ['-hide_banner', '-list_devices', 'true', '-f', 'dshow', '-i', 'dummy'], {
250
+ windowsHide: true,
251
+ encoding: 'utf8',
252
+ });
253
+ const text = `${r.stderr || ''}${r.stdout || ''}`;
254
+ // ffmpeg prints device lines like: "Mic Name" (audio)
255
+ const lines = text.split(/\r?\n/);
256
+ for (const line of lines) {
257
+ const m = /"([^"]+)"\s*\(audio\)/.exec(line);
258
+ if (m) return m[1];
259
+ }
260
+ return null;
261
+ }
262
+
263
+ /**
264
+ * Begin recording. IDLE -> RECORDING. No-op (returns null) unless the
265
+ * recorder is currently idle. Claims the IDLE -> STARTING transition
266
+ * SYNCHRONOUSLY (before any `await`) so a second Ctrl+Space fired while
267
+ * ffmpeg/device resolution is still in flight sees STARTING (not IDLE) and
268
+ * is a no-op rather than racing a second spawn onto the same rec.* fields.
269
+ * Resolves ffmpeg (managed -> PATH fallback), enumerates the first dshow
270
+ * audio-capture device, and spawns ffmpeg writing 16kHz mono PCM to a fresh
271
+ * tmp wav. `-y` overwrites (tmp path is always fresh so this is defensive,
272
+ * not load-bearing). Any failure on the STARTING path (no ffmpeg, no
273
+ * device, spawn throw) reverts to IDLE before returning.
274
+ *
275
+ * Returns `{ ok: true }` on a successful spawn, `{ ok: false, reason }` on
276
+ * any failure (no ffmpeg, no device, spawn error) — the caller (App.jsx)
277
+ * turns `reason` into a user-facing notice — or `null` when the recorder
278
+ * was not IDLE (already starting/recording/transcribing): a silent no-op,
279
+ * matching stopRecording's null-for-wrong-state contract.
280
+ */
281
+ export async function startRecording({ dataDir = resolvePluginData() } = {}) {
282
+ if (rec.state !== RECORDER_STATE.IDLE) return null;
283
+ // Claim the slot BEFORE the first await — see High-1 in the doc comment
284
+ // above. Everything below this line runs against an already-STARTING
285
+ // rec.state, so a concurrent startRecording() call bails out via the
286
+ // guard above instead of racing a second ffmpeg spawn.
287
+ rec.state = RECORDER_STATE.STARTING;
288
+ const ffmpegCmd = await resolveFfmpegCmd(dataDir);
289
+ if (!ffmpegCmd) {
290
+ rec.state = RECORDER_STATE.IDLE;
291
+ return { ok: false, reason: 'ffmpeg not found — run /voice to install it, or install ffmpeg on PATH' };
292
+ }
293
+ const device = listFirstDshowAudioDevice(ffmpegCmd);
294
+ if (!device) {
295
+ rec.state = RECORDER_STATE.IDLE;
296
+ return { ok: false, reason: 'no microphone found (DirectShow audio device enumeration returned none)' };
297
+ }
298
+ const wavPath = newTmpWavPath();
299
+ let child;
300
+ try {
301
+ child = spawn(ffmpegCmd, [
302
+ '-f', 'dshow',
303
+ '-i', `audio=${device}`,
304
+ '-ar', '16000',
305
+ '-ac', '1',
306
+ '-y', wavPath,
307
+ ], { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
308
+ } catch (err) {
309
+ rec.state = RECORDER_STATE.IDLE;
310
+ return { ok: false, reason: `failed to start ffmpeg: ${err?.message || err}` };
311
+ }
312
+ // EPIPE guard (Med-3): a write to ffmpeg's stdin AFTER it has already
313
+ // closed the pipe (e.g. process died right as stopRecording() writes 'q')
314
+ // emits an 'error' on the stream — Node throws uncaught if nothing is
315
+ // listening. Attached once, here, so every future stdin write/close on
316
+ // this child is covered (stopRecording's write included).
317
+ child.stdin?.on('error', () => {});
318
+ // A spawn error surfaces async (e.g. EACCES on the resolved path even
319
+ // though the sync probe above found something on PATH) — fall back to
320
+ // IDLE so a stuck RECORDING state never blocks the next Ctrl+Space.
321
+ child.once('error', () => {
322
+ if (rec.child === child) {
323
+ rec.state = RECORDER_STATE.IDLE;
324
+ rec.child = null;
325
+ rec.wavPath = null;
326
+ }
327
+ });
328
+ rec.state = RECORDER_STATE.RECORDING;
329
+ rec.child = child;
330
+ rec.wavPath = wavPath;
331
+ return { ok: true };
332
+ }
333
+
334
+ // Graceful-stop budget before escalating to a hard kill. ffmpeg needs a
335
+ // moment to flush the wav trailer after receiving 'q' on stdin.
336
+ const RECORDING_STOP_GRACE_MS = 3000;
337
+
338
+ function waitForExit(child, timeoutMs) {
339
+ return new Promise((resolve) => {
340
+ let done = false;
341
+ const finish = (exited) => {
342
+ if (done) return;
343
+ done = true;
344
+ resolve(exited);
345
+ };
346
+ child.once('exit', () => finish(true));
347
+ setTimeout(() => finish(false), timeoutMs).unref?.();
348
+ });
349
+ }
350
+
351
+ /**
352
+ * Stop the in-flight recording. RECORDING -> TRANSCRIBING -> (transcribe) ->
353
+ * IDLE. Sends 'q' on ffmpeg's stdin (its documented graceful-quit key —
354
+ * flushes the wav trailer); if ffmpeg has not exited within
355
+ * RECORDING_STOP_GRACE_MS it is force-killed (the partial wav is still
356
+ * attempted). No-op (returns null) unless currently RECORDING.
357
+ *
358
+ * On success runs ensureReady + transcribe against the SAME whisper-server
359
+ * singleton the channels pipeline uses, using `voice.language` from config
360
+ * (undefined -> whisper auto-detect) and `voice.transcription.threadCount`
361
+ * (falls back to the same quartered-cpu-count default index.mjs uses).
362
+ * Returns `{ ok: true, text }` on success or `{ ok: false, reason }` on any
363
+ * failure; state returns to IDLE either way.
364
+ *
365
+ * Med-1: rec.child is INTENTIONALLY kept set (not nulled) for the entire
366
+ * grace-wait window below — only cleared once the child has actually
367
+ * exited (or been killed). This lets disposeRecorder(), called mid-grace
368
+ * (e.g. the user quits the TUI right after Ctrl+Space-stop), still see and
369
+ * kill the SAME child instead of finding rec.child already null and
370
+ * orphaning a live ffmpeg process.
371
+ */
372
+ export async function stopRecording({ dataDir = resolvePluginData() } = {}) {
373
+ if (rec.state !== RECORDER_STATE.RECORDING || !rec.child) return null;
374
+ const child = rec.child;
375
+ const wavPath = rec.wavPath;
376
+ rec.state = RECORDER_STATE.TRANSCRIBING;
377
+ try {
378
+ try { child.stdin?.write('q'); } catch { /* stdin may already be closed */ }
379
+ const exited = await waitForExit(child, RECORDING_STOP_GRACE_MS);
380
+ if (!exited) {
381
+ try { child.kill('SIGKILL'); } catch { /* best-effort */ }
382
+ await waitForExit(child, RECORDING_STOP_GRACE_MS);
383
+ }
384
+ // Only now is the child provably gone (or we gave up trying) — safe to
385
+ // drop the reference so a concurrent disposeRecorder() no longer needs
386
+ // (and no longer has) anything to kill.
387
+ if (rec.child === child) rec.child = null;
388
+ return await transcribeWav(wavPath, { dataDir });
389
+ } finally {
390
+ if (rec.child === child) rec.child = null;
391
+ rec.state = RECORDER_STATE.IDLE;
392
+ rec.wavPath = null;
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Cancel the in-flight recording. RECORDING -> IDLE (no transcription).
398
+ * Force-kills ffmpeg immediately and deletes the tmp wav. No-op (returns
399
+ * false) unless currently RECORDING.
400
+ */
401
+ export function cancelRecording() {
402
+ if (rec.state !== RECORDER_STATE.RECORDING || !rec.child) return false;
403
+ const child = rec.child;
404
+ const wavPath = rec.wavPath;
405
+ try { child.kill('SIGKILL'); } catch { /* best-effort */ }
406
+ if (wavPath) {
407
+ try { unlinkSync(wavPath); } catch { /* tmp file may not exist yet */ }
408
+ }
409
+ rec.state = RECORDER_STATE.IDLE;
410
+ rec.child = null;
411
+ rec.wavPath = null;
412
+ return true;
413
+ }
414
+
415
+ /**
416
+ * Best-effort teardown for TUI unmount/exit: kills any live ffmpeg child and
417
+ * deletes its tmp wav, regardless of current state (RECORDING or
418
+ * TRANSCRIBING — the latter can still hold a live child mid-stop-grace).
419
+ * Never throws.
420
+ */
421
+ export function disposeRecorder() {
422
+ const child = rec.child;
423
+ const wavPath = rec.wavPath;
424
+ if (child) {
425
+ try { child.kill('SIGKILL'); } catch { /* best-effort */ }
426
+ }
427
+ if (wavPath) {
428
+ try { unlinkSync(wavPath); } catch { /* best-effort */ }
429
+ }
430
+ rec.state = RECORDER_STATE.IDLE;
431
+ rec.child = null;
432
+ rec.wavPath = null;
433
+ // High-2: shut down the whisper-server child too, not just ffmpeg — a
434
+ // TUI-triggered transcription can leave the managed whisper-server.exe
435
+ // process running past TUI exit (it's a long-lived singleton by design,
436
+ // kept warm across transcriptions), which otherwise orphans it. Only
437
+ // meaningful if loadWhisperServer() actually resolved at least once this
438
+ // session (_whisperServerModule set) — nothing to stop otherwise, and we
439
+ // must not trigger a fresh dynamic import from a sync unmount callback.
440
+ if (_whisperServerModule) {
441
+ try { void _whisperServerModule.stopVoiceWhisperServer?.(); } catch { /* best-effort */ }
442
+ }
443
+ }
444
+
445
+ async function transcribeWav(wavPath, { dataDir }) {
446
+ // Med-2: the runtime.installed check is now INSIDE the try so an early
447
+ // return here still hits the finally below and unlinks wavPath — the
448
+ // original early-return (before try) skipped cleanup and leaked the tmp
449
+ // wav on every "voice runtime not installed" path.
450
+ try {
451
+ const fetcher = await loadVoiceRuntimeFetcher();
452
+ const runtime = fetcher.resolveVoiceRuntime(dataDir);
453
+ if (!runtime?.installed) {
454
+ return { ok: false, reason: 'voice runtime not installed — run /voice to install it' };
455
+ }
456
+ const server = await loadWhisperServer();
457
+ const cfg = readSection('voice');
458
+ const cpuCount = (() => { try { return cpus().length; } catch { return 2; } })();
459
+ const threadCount = cfg?.transcription?.threadCount ?? Math.max(1, Math.ceil(cpuCount / 4));
460
+ const language = cfg?.language || undefined;
461
+ await server.ensureReady({ serverCmd: runtime.serverCmd, modelPath: runtime.modelPath, threadCount, host: '127.0.0.1' });
462
+ const text = await server.transcribe(wavPath, { language });
463
+ return { ok: true, text: text.trim() };
464
+ } catch (err) {
465
+ return { ok: false, reason: err?.message || String(err) };
466
+ } finally {
467
+ try { unlinkSync(wavPath); } catch { /* best-effort cleanup */ }
468
+ }
469
+ }