verbalcoding 0.2.11 → 0.2.13

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/.env.example +98 -2
  2. package/README.es.md +134 -0
  3. package/README.fr.md +134 -0
  4. package/README.ja.md +134 -0
  5. package/README.ko.md +134 -0
  6. package/README.md +118 -74
  7. package/README.ru.md +134 -0
  8. package/README.zh.md +133 -0
  9. package/app-node/agent_adapters.mjs +37 -5
  10. package/app-node/agent_adapters.test.mjs +27 -1
  11. package/app-node/agent_detect.mjs +73 -0
  12. package/app-node/agent_detect.test.mjs +77 -0
  13. package/app-node/agent_routing.mjs +148 -0
  14. package/app-node/agent_routing.test.mjs +138 -0
  15. package/app-node/agent_turn.mjs +86 -0
  16. package/app-node/agent_turn.test.mjs +109 -0
  17. package/app-node/bridge_context.mjs +73 -0
  18. package/app-node/bridge_context.test.mjs +54 -0
  19. package/app-node/bridge_state.mjs +4 -0
  20. package/app-node/bridge_wireup.test.mjs +462 -0
  21. package/app-node/cli_install.test.mjs +31 -0
  22. package/app-node/cross_agent_routing.test.mjs +78 -0
  23. package/app-node/discord_command_router.mjs +204 -0
  24. package/app-node/discord_command_router.test.mjs +311 -0
  25. package/app-node/discord_voice_setup.mjs +251 -0
  26. package/app-node/discord_voice_setup.test.mjs +86 -0
  27. package/app-node/hermes_profiles.test.mjs +12 -1
  28. package/app-node/install_config.mjs +113 -3
  29. package/app-node/install_config.test.mjs +8 -0
  30. package/app-node/instance_doctor.test.mjs +9 -0
  31. package/app-node/instances.test.mjs +8 -1
  32. package/app-node/main.mjs +513 -1058
  33. package/app-node/mcp_tools.test.mjs +7 -0
  34. package/app-node/notification_handler.mjs +89 -0
  35. package/app-node/notification_handler.test.mjs +187 -0
  36. package/app-node/notify.mjs +73 -0
  37. package/app-node/notify.test.mjs +68 -0
  38. package/app-node/plan_dispatcher.mjs +215 -0
  39. package/app-node/plan_dispatcher.test.mjs +101 -0
  40. package/app-node/plan_mode.mjs +203 -0
  41. package/app-node/plan_mode.test.mjs +231 -0
  42. package/app-node/progress_handler.mjs +220 -0
  43. package/app-node/progress_handler.test.mjs +193 -0
  44. package/app-node/progress_speech.mjs +54 -32
  45. package/app-node/progress_speech.test.mjs +12 -3
  46. package/app-node/project_sessions.mjs +5 -2
  47. package/app-node/project_sessions.test.mjs +7 -0
  48. package/app-node/research_mode.mjs +282 -0
  49. package/app-node/research_mode.test.mjs +264 -0
  50. package/app-node/restart_notice.mjs +3 -0
  51. package/app-node/restart_notice.test.mjs +11 -0
  52. package/app-node/session_ontology.mjs +271 -0
  53. package/app-node/session_ontology.test.mjs +130 -0
  54. package/app-node/smart_progress.mjs +94 -0
  55. package/app-node/smart_progress.test.mjs +66 -0
  56. package/app-node/stream_sentencer.mjs +91 -0
  57. package/app-node/stream_sentencer.test.mjs +129 -0
  58. package/app-node/streaming_tts_queue.mjs +52 -0
  59. package/app-node/streaming_tts_queue.test.mjs +64 -0
  60. package/app-node/stt_whisper.mjs +24 -0
  61. package/app-node/stt_whisper.test.mjs +32 -0
  62. package/app-node/text_routing.mjs +22 -0
  63. package/app-node/text_routing.test.mjs +23 -1
  64. package/app-node/tts_backends.mjs +537 -3
  65. package/app-node/tts_backends.test.mjs +454 -0
  66. package/app-node/tts_player.mjs +164 -0
  67. package/app-node/tts_player.test.mjs +202 -0
  68. package/app-node/tts_runtime.mjs +134 -0
  69. package/app-node/tts_runtime.test.mjs +89 -0
  70. package/app-node/tts_settings.mjs +150 -3
  71. package/app-node/tts_settings.test.mjs +204 -0
  72. package/app-node/tts_voice_config.mjs +136 -2
  73. package/app-node/tts_voice_config.test.mjs +94 -0
  74. package/app-node/utterance_router.mjs +216 -0
  75. package/app-node/utterance_router.test.mjs +236 -0
  76. package/app-node/voice_autojoin.mjs +37 -0
  77. package/app-node/voice_autojoin.test.mjs +59 -0
  78. package/app-node/voice_io.mjs +272 -0
  79. package/app-node/voice_io.test.mjs +102 -0
  80. package/app-node/voice_turn_runner.mjs +449 -0
  81. package/app-node/voice_turn_runner.test.mjs +289 -0
  82. package/docs/CONFIGURATION.md +79 -96
  83. package/docs/FRESH_INSTALL.md +105 -63
  84. package/docs/HARNESSES.md +58 -0
  85. package/docs/HARNESS_AIDER.md +50 -0
  86. package/docs/HARNESS_CLAUDE.md +56 -0
  87. package/docs/HARNESS_CODEX.md +56 -0
  88. package/docs/HARNESS_CURSOR.md +45 -0
  89. package/docs/HARNESS_GEMINI.md +45 -0
  90. package/docs/HARNESS_HERMES.md +57 -0
  91. package/docs/HARNESS_OPENCLAW.md +44 -0
  92. package/docs/HARNESS_OPENCODE.md +44 -0
  93. package/docs/HERMES_VOICE.md +65 -0
  94. package/docs/MULTI_INSTANCE.md +16 -0
  95. package/docs/README.md +50 -0
  96. package/docs/RELEASE.md +42 -19
  97. package/docs/ROADMAP.md +53 -0
  98. package/docs/TROUBLESHOOTING.md +126 -0
  99. package/docs/TTS_BACKENDS.md +227 -0
  100. package/docs/USAGE.md +94 -40
  101. package/docs/assets/figures/verbalcoding-flow.svg +1 -1
  102. package/docs/i18n/AGENTS.es.md +34 -0
  103. package/docs/i18n/AGENTS.fr.md +34 -0
  104. package/docs/i18n/AGENTS.ja.md +34 -0
  105. package/docs/i18n/AGENTS.ko.md +34 -0
  106. package/docs/i18n/AGENTS.ru.md +34 -0
  107. package/docs/i18n/AGENTS.zh.md +34 -0
  108. package/docs/i18n/CONFIGURATION.es.md +25 -0
  109. package/docs/i18n/CONFIGURATION.fr.md +25 -0
  110. package/docs/i18n/CONFIGURATION.ja.md +25 -0
  111. package/docs/i18n/CONFIGURATION.ko.md +25 -0
  112. package/docs/i18n/CONFIGURATION.ru.md +25 -0
  113. package/docs/i18n/CONFIGURATION.zh.md +25 -0
  114. package/docs/i18n/FRESH_INSTALL.es.md +27 -2
  115. package/docs/i18n/FRESH_INSTALL.fr.md +27 -2
  116. package/docs/i18n/FRESH_INSTALL.ja.md +27 -2
  117. package/docs/i18n/FRESH_INSTALL.ko.md +27 -2
  118. package/docs/i18n/FRESH_INSTALL.ru.md +27 -2
  119. package/docs/i18n/FRESH_INSTALL.zh.md +27 -2
  120. package/docs/i18n/HARNESSES.es.md +58 -0
  121. package/docs/i18n/HARNESSES.fr.md +58 -0
  122. package/docs/i18n/HARNESSES.ja.md +58 -0
  123. package/docs/i18n/HARNESSES.ko.md +58 -0
  124. package/docs/i18n/HARNESSES.ru.md +58 -0
  125. package/docs/i18n/HARNESSES.zh.md +58 -0
  126. package/docs/i18n/HARNESS_AIDER.es.md +48 -0
  127. package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
  128. package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
  129. package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
  130. package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
  131. package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
  132. package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
  133. package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
  134. package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
  135. package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
  136. package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
  137. package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
  138. package/docs/i18n/HARNESS_CODEX.es.md +55 -0
  139. package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
  140. package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
  141. package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
  142. package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
  143. package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
  144. package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
  145. package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
  146. package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
  147. package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
  148. package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
  149. package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
  150. package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
  151. package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
  152. package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
  153. package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
  154. package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
  155. package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
  156. package/docs/i18n/HARNESS_HERMES.es.md +54 -0
  157. package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
  158. package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
  159. package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
  160. package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
  161. package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
  162. package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
  163. package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
  164. package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
  165. package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
  166. package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
  167. package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
  168. package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
  169. package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
  170. package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
  171. package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
  172. package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
  173. package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
  174. package/docs/i18n/HERMES_VOICE.es.md +46 -0
  175. package/docs/i18n/HERMES_VOICE.fr.md +46 -0
  176. package/docs/i18n/HERMES_VOICE.ja.md +46 -0
  177. package/docs/i18n/HERMES_VOICE.ko.md +65 -0
  178. package/docs/i18n/HERMES_VOICE.ru.md +46 -0
  179. package/docs/i18n/HERMES_VOICE.zh.md +46 -0
  180. package/docs/i18n/MULTI_INSTANCE.es.md +25 -0
  181. package/docs/i18n/MULTI_INSTANCE.fr.md +25 -0
  182. package/docs/i18n/MULTI_INSTANCE.ja.md +25 -0
  183. package/docs/i18n/MULTI_INSTANCE.ko.md +25 -0
  184. package/docs/i18n/MULTI_INSTANCE.ru.md +25 -0
  185. package/docs/i18n/MULTI_INSTANCE.zh.md +25 -0
  186. package/docs/i18n/README.es.md +20 -134
  187. package/docs/i18n/README.fr.md +20 -134
  188. package/docs/i18n/README.ja.md +20 -134
  189. package/docs/i18n/README.ko.md +20 -133
  190. package/docs/i18n/README.ru.md +20 -134
  191. package/docs/i18n/README.zh.md +20 -133
  192. package/docs/i18n/RELEASE.es.md +26 -1
  193. package/docs/i18n/RELEASE.fr.md +26 -1
  194. package/docs/i18n/RELEASE.ja.md +26 -1
  195. package/docs/i18n/RELEASE.ko.md +26 -1
  196. package/docs/i18n/RELEASE.ru.md +26 -1
  197. package/docs/i18n/RELEASE.zh.md +26 -1
  198. package/docs/i18n/TROUBLESHOOTING.es.md +39 -0
  199. package/docs/i18n/TROUBLESHOOTING.fr.md +39 -0
  200. package/docs/i18n/TROUBLESHOOTING.ja.md +39 -0
  201. package/docs/i18n/TROUBLESHOOTING.ko.md +39 -0
  202. package/docs/i18n/TROUBLESHOOTING.ru.md +39 -0
  203. package/docs/i18n/TROUBLESHOOTING.zh.md +39 -0
  204. package/docs/i18n/USAGE.es.md +25 -0
  205. package/docs/i18n/USAGE.fr.md +25 -0
  206. package/docs/i18n/USAGE.ja.md +25 -0
  207. package/docs/i18n/USAGE.ko.md +25 -0
  208. package/docs/i18n/USAGE.ru.md +25 -0
  209. package/docs/i18n/USAGE.zh.md +25 -0
  210. package/docs/superpowers/plans/2026-05-13-phase1-streaming-pipeline.md +122 -0
  211. package/docs/superpowers/plans/2026-05-13-phase10-push-notifications.md +152 -0
  212. package/docs/superpowers/plans/2026-05-13-phase2-agent-adapters.md +242 -0
  213. package/docs/superpowers/plans/2026-05-13-phase6-smart-progress.md +172 -0
  214. package/docs/superpowers/plans/2026-05-13-phase7-voice-plan-mode.md +108 -0
  215. package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
  216. package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
  217. package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
  218. package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
  219. package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
  220. package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
  221. package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
  222. package/integrations/fireredtts2/mlx_llm.py +183 -0
  223. package/integrations/fireredtts2/synth.py +156 -0
  224. package/integrations/fireredtts2/synth_mlx.py +196 -0
  225. package/integrations/mlxaudio/synth.py +74 -0
  226. package/integrations/neuttsair/synth.py +104 -0
  227. package/integrations/omnivoice/synth.py +110 -0
  228. package/package.json +7 -1
  229. package/scripts/cli.mjs +88 -3
  230. package/scripts/doctor.mjs +115 -4
  231. package/scripts/install.mjs +20 -2
  232. package/scripts/install_fireredtts2.sh +109 -0
  233. package/scripts/install_mlxaudio.sh +34 -0
  234. package/scripts/install_mossttsnano.sh +46 -0
  235. package/scripts/postinstall.mjs +34 -0
@@ -0,0 +1,216 @@
1
+ // Voice utterance dispatch chain: tts/lang/clone command handlers,
2
+ // routing state, adapter selection, and barge-in interrupt. handleRecording
3
+ // (Phase 7a) and plan-mode dispatch (Phase 7b) moved out to their own
4
+ // modules — this module is now pure command dispatch.
5
+ //
6
+ // createUtteranceRouter(deps) returns the small handlers main.mjs and
7
+ // voice_turn_runner consume.
8
+
9
+ export function createUtteranceRouter(deps) {
10
+ const {
11
+ bridge,
12
+ log,
13
+ warn,
14
+ agentAdapter,
15
+ settings,
16
+ projectSessionContextText,
17
+ createBridgeAgentAdapter,
18
+ buildAgentSettings,
19
+ commandIsInstalled,
20
+ shellSplit,
21
+ sendText,
22
+ speakText,
23
+ TTS_VOICE_CONFIG_PATH,
24
+ ensureTtsVoiceConfig,
25
+ updateTtsVoiceConfig,
26
+ writeTtsVoiceConfig,
27
+ applyVoiceConfigToProcessEnv,
28
+ ensureSelectedTtsBackendInstalled,
29
+ rebuildTtsRuntimeSettings,
30
+ voiceCommandFromTranscript,
31
+ voiceChangedText,
32
+ voiceLanguageCommandFromTranscript,
33
+ voiceCloneCommandFromText,
34
+ voiceCloneCapture,
35
+ notifyVoiceCloneSampleGapIfNeeded,
36
+ languageChangedText,
37
+ applyRuntimeLanguage,
38
+ persistEnvValues,
39
+ discardVoiceInputQueues,
40
+ path,
41
+ ROOT,
42
+ } = deps;
43
+
44
+ function adapterForProjectSession(session) {
45
+ if (!session) return agentAdapter;
46
+ const key = session.slug || session.name;
47
+ if (!bridge.agentAdaptersBySession.has(key)) {
48
+ bridge.agentAdaptersBySession.set(key, createBridgeAgentAdapter({
49
+ ...settings.agent,
50
+ label: `${settings.agent.label} · ${session.name}`,
51
+ sessionFile: session.sessionFile,
52
+ cwd: session.workdir,
53
+ projectContext: projectSessionContextText(session),
54
+ }));
55
+ }
56
+ return bridge.agentAdaptersBySession.get(key);
57
+ }
58
+
59
+ function routingStateFor(channelKey) {
60
+ const key = String(channelKey || 'default');
61
+ let state = bridge.routingStateByChannel.get(key);
62
+ if (!state) {
63
+ state = {
64
+ activeRouting: { backend: settings.agent.backend, sticky: false },
65
+ lastUsedBackend: settings.agent.backend,
66
+ lastResolvedDecisions: {},
67
+ pendingFallbackPrompt: null,
68
+ recentUtterances: [],
69
+ };
70
+ bridge.routingStateByChannel.set(key, state);
71
+ }
72
+ return state;
73
+ }
74
+
75
+ function recordUtterance(channelKey, text) {
76
+ if (!text) return;
77
+ const state = routingStateFor(channelKey);
78
+ state.recentUtterances.push(text);
79
+ while (state.recentUtterances.length > 4) state.recentUtterances.shift();
80
+ }
81
+
82
+ function clearTransientRouting(channelKey) {
83
+ const state = routingStateFor(channelKey);
84
+ state.pendingFallbackPrompt = null;
85
+ if (!state.activeRouting?.sticky) {
86
+ state.activeRouting = { backend: settings.agent.backend, sticky: false };
87
+ }
88
+ }
89
+
90
+ function adapterForBackend(backend, session = null) {
91
+ const normalized = String(backend || '').toLowerCase();
92
+ if (!normalized || normalized === settings.agent.backend) {
93
+ return session ? adapterForProjectSession(session) : agentAdapter;
94
+ }
95
+ const key = `${normalized}::${session ? (session.slug || session.name) : '_default'}`;
96
+ if (bridge.agentAdaptersByBackend.has(key)) return bridge.agentAdaptersByBackend.get(key);
97
+ let routedSettings;
98
+ try {
99
+ const scrubbed = { ...process.env };
100
+ for (const key of ['AGENT_BACKEND', 'AGENT_LABEL', 'AGENT_COMMAND', 'AGENT_SESSION_FILE']) {
101
+ delete scrubbed[key];
102
+ }
103
+ scrubbed.AGENT_BACKEND = normalized;
104
+ routedSettings = buildAgentSettings({
105
+ ROOT: settings.agent.cwd || process.cwd(),
106
+ env: scrubbed,
107
+ });
108
+ } catch (e) {
109
+ warn(`adapterForBackend: cannot build settings for ${normalized}: ${e?.message || e}`);
110
+ return null;
111
+ }
112
+ if (session) {
113
+ routedSettings = {
114
+ ...routedSettings,
115
+ label: `${routedSettings.label} · ${session.name}`,
116
+ sessionFile: session.sessionFile,
117
+ cwd: session.workdir || routedSettings.cwd,
118
+ projectContext: projectSessionContextText(session),
119
+ };
120
+ }
121
+ const argv = shellSplit(String(routedSettings.command || ''));
122
+ const binary = argv[0];
123
+ if (binary && !commandIsInstalled(binary, { cwd: routedSettings.cwd || settings.agent.cwd || process.cwd() })) {
124
+ warn(`adapterForBackend: ${normalized} binary not found on PATH: ${binary}`);
125
+ return null;
126
+ }
127
+ const adapter = createBridgeAgentAdapter(routedSettings);
128
+ bridge.agentAdaptersByBackend.set(key, adapter);
129
+ return adapter;
130
+ }
131
+
132
+ async function handleTtsVoiceCommand(prompt, signal) {
133
+ const request = voiceCommandFromTranscript(prompt);
134
+ if (!request) return false;
135
+ discardVoiceInputQueues('voice-change');
136
+ let config = ensureTtsVoiceConfig();
137
+ config = updateTtsVoiceConfig(config, request);
138
+ writeTtsVoiceConfig(TTS_VOICE_CONFIG_PATH, config);
139
+ const { selection } = applyVoiceConfigToProcessEnv(config);
140
+ await ensureSelectedTtsBackendInstalled(selection, signal);
141
+ rebuildTtsRuntimeSettings(selection);
142
+ if (selection.voice?.language) settings.voiceLanguage = selection.voice.language;
143
+ persistEnvValues({
144
+ TTS_BACKEND: selection.backend,
145
+ TTS_VOICE_TYPE: selection.voiceType,
146
+ TTS_VOICE: selection.backend === 'edge' ? selection.voice.voice : process.env.TTS_VOICE,
147
+ VOICE_LANGUAGE: settings.voiceLanguage,
148
+ MLXAUDIO_PYTHON: selection.backend === 'mlxaudio' ? (process.env.MLXAUDIO_PYTHON || './.venv-mlxaudio/bin/python') : process.env.MLXAUDIO_PYTHON,
149
+ MLXAUDIO_VOICE: selection.backend === 'mlxaudio' ? (process.env.MLXAUDIO_VOICE || selection.voice?.voice) : process.env.MLXAUDIO_VOICE,
150
+ FIREREDTTS2_COMMAND: selection.backend === 'fireredtts2' ? (process.env.FIREREDTTS2_COMMAND || './.local/bin/fireredtts2') : process.env.FIREREDTTS2_COMMAND,
151
+ FIREREDTTS2_PRETRAINED_DIR: selection.backend === 'fireredtts2' ? (process.env.FIREREDTTS2_PRETRAINED_DIR || 'pretrained_models/FireRedTTS2') : process.env.FIREREDTTS2_PRETRAINED_DIR,
152
+ });
153
+ await speakText(voiceChangedText(selection), signal);
154
+ notifyVoiceCloneSampleGapIfNeeded(selection, signal).catch(e => warn('voice clone gap notice failed', e?.message || e));
155
+ return true;
156
+ }
157
+
158
+ async function handleLanguageCommand(prompt, signal) {
159
+ const request = voiceLanguageCommandFromTranscript(prompt);
160
+ if (!request) return false;
161
+ const preset = applyRuntimeLanguage(request.language);
162
+ await speakText(languageChangedText(preset), signal);
163
+ return true;
164
+ }
165
+
166
+ async function handleVoiceCloneCommand(userId, prompt, signal = null) {
167
+ const command = voiceCloneCommandFromText(prompt);
168
+ if (!command) return false;
169
+ if (command.action === 'cancel') {
170
+ const cancelled = voiceCloneCapture.cancel(userId);
171
+ await sendText(cancelled ? '🎙️ 보이스 클로닝 샘플 캡처를 취소했어.' : '🎙️ 대기 중인 보이스 클로닝 샘플 캡처가 없어.');
172
+ await speakText(cancelled ? '목소리 샘플 녹음 대기를 취소했어.' : '대기 중인 목소리 샘플 녹음은 없어.', signal);
173
+ return true;
174
+ }
175
+ if (command.action === 'status') {
176
+ const current = voiceCloneCapture.current();
177
+ const status = current?.userId === String(userId)
178
+ ? `🎙️ 다음 유효한 음성을 ${path.relative(ROOT, current.targetPath)}에 저장할게.`
179
+ : '🎙️ 지금 대기 중인 보이스 클로닝 샘플 캡처는 없어.';
180
+ await sendText(status);
181
+ await speakText(current?.userId === String(userId) ? '다음에 말하는 목소리를 샘플로 저장할게.' : '대기 중인 목소리 샘플 녹음은 없어.', signal);
182
+ return true;
183
+ }
184
+ const armed = voiceCloneCapture.arm({ userId, source: 'voice-command' });
185
+ await sendText(`🎙️ 보이스 클로닝 샘플 캡처 대기 중. 다음 10초에서 30초 정도 말하면 ${path.relative(ROOT, armed.targetPath)}에 저장할게.`);
186
+ await speakText('좋아. 다음에 10초에서 30초 정도 말하면 그 음성을 목소리 샘플로 저장할게.', signal);
187
+ return true;
188
+ }
189
+
190
+ function interruptCurrentResponse(userId, reason = 'barge-in') {
191
+ if (!bridge.speaking && !bridge.processing) return false;
192
+ const turnId = bridge.activeTurnId;
193
+ if (turnId) bridge.interruptedTurns.add(turnId);
194
+ log('interrupt current response', 'byUser', userId, 'reason', reason, 'speaking', bridge.speaking, 'processing', bridge.processing, 'turn', turnId);
195
+ if (bridge.currentAbortController && !bridge.currentAbortController.signal.aborted) {
196
+ try { bridge.currentAbortController.abort(); } catch (e) { warn('abort current response failed', e?.stack || e); }
197
+ }
198
+ try { bridge.player.stop(true); } catch (e) { warn('stop playback failed', e?.stack || e); }
199
+ bridge.speaking = false;
200
+ bridge.processing = false;
201
+ return true;
202
+ }
203
+
204
+ return {
205
+
206
+ adapterForProjectSession,
207
+ routingStateFor,
208
+ recordUtterance,
209
+ clearTransientRouting,
210
+ adapterForBackend,
211
+ handleTtsVoiceCommand,
212
+ handleLanguageCommand,
213
+ handleVoiceCloneCommand,
214
+ interruptCurrentResponse,
215
+ };
216
+ }
@@ -0,0 +1,236 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createUtteranceRouter } from './utterance_router.mjs';
4
+ import { createBridge } from './bridge_context.mjs';
5
+ import { createAgentTurnLifecycle } from './agent_turn.mjs';
6
+
7
+ function noop() {}
8
+ async function noopAsync() {}
9
+
10
+ function makeDeps(overrides = {}) {
11
+ const bridge = createBridge();
12
+ // handleRecording's finally block touches bridge.bridgeState.deferredSize();
13
+ // give the tests a minimal stub so the cleanup path doesn't NPE.
14
+ bridge.bridgeState = {
15
+ deferredSize: () => 0,
16
+ currentEpoch: () => 1,
17
+ discardQueues: () => 0,
18
+ };
19
+ const agentTurnLifecycle = createAgentTurnLifecycle({ bridge, warn: noop });
20
+ const agentAdapter = {
21
+ label: 'default-agent',
22
+ backend: 'hermes',
23
+ readSessionId: () => null,
24
+ ask: async () => 'mock agent answer',
25
+ };
26
+ return {
27
+ bridge,
28
+ agentTurnLifecycle,
29
+ log: noop, warn: noop, path: { join: (...a) => a.join('/') }, fs: { rm: (_p, _o, cb) => cb && cb() },
30
+ ROOT: '/tmp/vc', TTS_VOICE_CONFIG_PATH: '/tmp/voices.json',
31
+ agentAdapter,
32
+ settings: {
33
+ voiceLanguage: 'ko', transcriptChannelId: 'tx-ch', agent: { backend: 'hermes', label: 'hermes' }, tts: {},
34
+ },
35
+ isPlanEntryUtterance: () => false,
36
+ parsePlanOutput: () => ({ steps: [], decisions: [] }),
37
+ parsePlanVoiceCommand: () => ({ type: 'unknown' }),
38
+ applyPlanCommand: state => state,
39
+ renderFinalPlan: () => '',
40
+ planModePreamble: () => '',
41
+ planExecutionPreamble: () => '',
42
+ parseDecisionAnswer: () => ({ type: 'unknown' }),
43
+ renderDecisionPrompt: d => d?.text || '',
44
+ renderResolvedDecisions: () => '',
45
+ isAgentRoutingDecision: () => false,
46
+ projectSessionContextText: () => '',
47
+ resolveProjectSessionForChannel: () => null,
48
+ createBridgeAgentAdapter: s => ({ label: s?.label || 'fake', backend: s?.backend || 'hermes', ask: async () => '' }),
49
+ buildAgentSettings: () => ({ backend: 'hermes', label: 'hermes' }),
50
+ commandIsInstalled: async () => true,
51
+ shellSplit: s => String(s).split(' '),
52
+ sendText: noopAsync, speakText: noopAsync,
53
+ ensureTtsVoiceConfig: () => ({ backends: {} }),
54
+ updateTtsVoiceConfig: c => c,
55
+ writeTtsVoiceConfig: noop,
56
+ applyVoiceConfigToProcessEnv: () => ({ selection: { backend: 'edge', voiceType: 'female', voice: { language: 'ko', voice: 'ko-KR-SunHiNeural' } } }),
57
+ ensureSelectedTtsBackendInstalled: noopAsync,
58
+ rebuildTtsRuntimeSettings: noop,
59
+ voiceCommandFromTranscript: () => null,
60
+ voiceChangedText: () => 'changed',
61
+ voiceLanguageCommandFromTranscript: () => null,
62
+ voiceCloneCommandFromText: () => null,
63
+ voiceCloneCapture: { arm: () => ({ targetPath: '/tmp/sample.wav' }), cancel: () => false, current: () => null },
64
+ notifyVoiceCloneSampleGapIfNeeded: noopAsync,
65
+ languageChangedText: () => 'language',
66
+ applyRuntimeLanguage: noop,
67
+ persistEnvValues: noop,
68
+ discardVoiceInputQueues: () => 0,
69
+ // Phase 4b deps for handleRecording
70
+ transcribe: async () => 'hey hermes do a thing',
71
+ beginStreamingTurn: () => false,
72
+ endStreamingTurn: noopAsync,
73
+ client: { channels: { cache: new Map() } },
74
+ isAllowed: () => true,
75
+ isAbortError: e => e?.name === 'AbortError',
76
+ sleep: async () => {},
77
+ sendEmbed: async () => true,
78
+ speakImmediateNotice: noopAsync,
79
+ reloadRuntimeLanguageFromEnv: () => ({ changed: false, voiceLanguage: 'ko', whisperLanguage: 'ko' }),
80
+ drainDeferredProcessingUtterances: noopAsync,
81
+ maybeNotifyTaskComplete: noopAsync,
82
+ ontologyStateFor: () => ({ nodeCount: 0, serializeForHandoff: () => '' }),
83
+ captureOntologyFromTurn: noop,
84
+ queueProgressSpeechText: noop,
85
+ stopProgressSpeech: noop,
86
+ agentAnswerHeader: () => 'agent says:',
87
+ emptyAgentAnswer: () => '(empty)',
88
+ formatRecentDiscordContext: () => '',
89
+ formatSttResultMessage: (_lang, _uid, text) => `you said: ${text}`,
90
+ formatSttStartMessage: () => '🎧 listening',
91
+ formatVoiceErrorMessage: (_lang, msg) => `error: ${msg}`,
92
+ formatWakeRejectedMessage: () => 'no wake word',
93
+ spokenResultOnly: (_p, answer) => answer,
94
+ stripWake: t => t,
95
+ acceptsWake: () => true,
96
+ sensitivityChangedSpeech: () => 'sensitivity set',
97
+ sensitivityModeFromTranscript: () => null,
98
+ sensitivityStatusText: () => 'normal',
99
+ setSensitivityMode: () => ({ mode: 'normal', minBytes: 0, minMeanDb: -40, minMaxDb: -20 }),
100
+ isSensitivityOnlyRequest: () => false,
101
+ verboseChangedSpeech: () => 'verbose set',
102
+ verboseModeFromTranscript: () => null,
103
+ verboseStatusText: () => 'verbose off',
104
+ setVerboseProgress: noop,
105
+ isVerboseOnlyRequest: () => false,
106
+ isRoutingOnlyUtterance: () => false,
107
+ parseAgentRoutingCommand: () => ({ type: 'none' }),
108
+ renderAgentPrefix: () => '',
109
+ buildCrossAgentPrompt: ({ prompt }) => prompt,
110
+ buildFallbackDecision: () => ({ slot: 'fallback' }),
111
+ parseResearchCommand: () => ({ type: 'none' }),
112
+ runResearchTurn: async () => ({ status: 'no_backend' }),
113
+ PROGRESS_IDLE_CHECK_MS: 5000,
114
+ PROGRESS_IDLE_NOTICE_INITIAL_MS: 10000,
115
+ PROGRESS_IDLE_NOTICE_LIMIT: 20,
116
+ PROGRESS_IDLE_NOTICE_MAX_MS: 30000,
117
+ PROGRESS_IDLE_NOTICE_MULTIPLIER: 1.8,
118
+ STT_START_VOICE_NOTICE: false,
119
+ ...overrides,
120
+ };
121
+ }
122
+
123
+ test('createUtteranceRouter exposes the expected functions', () => {
124
+ // handleRecording moved to voice_turn_runner in Phase 7a — see
125
+ // voice_turn_runner.test.mjs for its behaviour tests.
126
+ // Plan-mode dispatch moved to plan_dispatcher in Phase 7b — see
127
+ // plan_dispatcher.test.mjs.
128
+ const router = createUtteranceRouter(makeDeps());
129
+ for (const name of [
130
+ 'adapterForProjectSession', 'routingStateFor', 'recordUtterance',
131
+ 'clearTransientRouting', 'adapterForBackend', 'handleTtsVoiceCommand', 'handleLanguageCommand',
132
+ 'handleVoiceCloneCommand', 'interruptCurrentResponse',
133
+ ]) {
134
+ assert.equal(typeof router[name], 'function', `${name} is exposed`);
135
+ }
136
+ });
137
+
138
+ // planChannelKey moved to plan_dispatcher in Phase 7b — see plan_dispatcher.test.mjs.
139
+
140
+ test('routingStateFor lazily creates per-channel state with sensible defaults', () => {
141
+ const deps = makeDeps();
142
+ const { routingStateFor } = createUtteranceRouter(deps);
143
+ const state = routingStateFor('chan-a');
144
+ assert.equal(state.activeRouting.backend, 'hermes');
145
+ assert.equal(state.activeRouting.sticky, false);
146
+ assert.deepEqual(state.lastResolvedDecisions, {});
147
+ assert.equal(state.pendingFallbackPrompt, null);
148
+ assert.deepEqual(state.recentUtterances, []);
149
+ // Same channel returns the same object
150
+ const again = routingStateFor('chan-a');
151
+ assert.equal(state, again);
152
+ });
153
+
154
+ test('recordUtterance pushes to bounded recent buffer per channel', () => {
155
+ const deps = makeDeps();
156
+ const { routingStateFor, recordUtterance } = createUtteranceRouter(deps);
157
+ routingStateFor('c1');
158
+ for (let i = 0; i < 6; i++) recordUtterance('c1', `u${i}`);
159
+ const state = routingStateFor('c1');
160
+ assert.equal(state.recentUtterances.length, 4, 'buffer caps at 4');
161
+ assert.deepEqual(state.recentUtterances, ['u2', 'u3', 'u4', 'u5']);
162
+ });
163
+
164
+ test('clearTransientRouting wipes pending fallback prompt and non-sticky route', () => {
165
+ const deps = makeDeps();
166
+ const { routingStateFor, clearTransientRouting } = createUtteranceRouter(deps);
167
+ const state = routingStateFor('c1');
168
+ state.pendingFallbackPrompt = 'pending';
169
+ state.activeRouting = { backend: 'codex', sticky: false };
170
+ clearTransientRouting('c1');
171
+ const post = routingStateFor('c1');
172
+ assert.equal(post.pendingFallbackPrompt, null);
173
+ assert.equal(post.activeRouting.backend, 'hermes', 'reset to default backend');
174
+ });
175
+
176
+ test('clearTransientRouting leaves sticky routing intact', () => {
177
+ const deps = makeDeps();
178
+ const { routingStateFor, clearTransientRouting } = createUtteranceRouter(deps);
179
+ const state = routingStateFor('c1');
180
+ state.pendingFallbackPrompt = 'pending';
181
+ state.activeRouting = { backend: 'codex', sticky: true };
182
+ clearTransientRouting('c1');
183
+ const post = routingStateFor('c1');
184
+ assert.equal(post.pendingFallbackPrompt, null);
185
+ assert.equal(post.activeRouting.backend, 'codex', 'sticky backend is preserved');
186
+ });
187
+
188
+ test('adapterForProjectSession returns the default adapter when no session', () => {
189
+ const deps = makeDeps();
190
+ const { adapterForProjectSession } = createUtteranceRouter(deps);
191
+ assert.equal(adapterForProjectSession(null), deps.agentAdapter);
192
+ });
193
+
194
+ test('adapterForProjectSession caches per-session adapters', () => {
195
+ const deps = makeDeps();
196
+ const { adapterForProjectSession } = createUtteranceRouter(deps);
197
+ const session = { name: 'My Project', slug: 'my-project', sessionFile: '/tmp/s.json', workdir: '/tmp' };
198
+ const a = adapterForProjectSession(session);
199
+ const b = adapterForProjectSession(session);
200
+ assert.equal(a, b, 'same session returns cached adapter');
201
+ assert.equal(deps.bridge.agentAdaptersBySession.size, 1);
202
+ });
203
+
204
+ // handleRecording behaviour tests moved to voice_turn_runner.test.mjs in
205
+ // Phase 7a, when handleRecording itself moved out of utterance_router.mjs
206
+ // into voice_turn_runner.mjs.
207
+
208
+ // --- cross-module state interactions ------------------------------------
209
+
210
+ test('interruptCurrentResponse flips speaking/processing back to clean state', () => {
211
+ const deps = makeDeps();
212
+ // simulate mid-turn
213
+ deps.bridge.processing = true;
214
+ deps.bridge.speaking = true;
215
+ deps.bridge.activeTurnId = 42;
216
+ const controller = new AbortController();
217
+ deps.bridge.currentAbortController = controller;
218
+ deps.bridge.player = { stop: () => {} };
219
+
220
+ const { interruptCurrentResponse } = createUtteranceRouter(deps);
221
+ const out = interruptCurrentResponse('user-1', 'test-barge-in');
222
+
223
+ assert.equal(out, true, 'interrupt returns true when something was active');
224
+ assert.equal(deps.bridge.speaking, false, 'speaking cleared');
225
+ assert.equal(deps.bridge.processing, false, 'processing cleared');
226
+ assert.ok(deps.bridge.interruptedTurns.has(42), 'interrupted turn tracked');
227
+ assert.equal(controller.signal.aborted, true, 'abort controller fired');
228
+ });
229
+
230
+ test('interruptCurrentResponse is a no-op when nothing is in flight', () => {
231
+ const deps = makeDeps();
232
+ const { interruptCurrentResponse } = createUtteranceRouter(deps);
233
+ assert.equal(interruptCurrentResponse('user-1', 'noop'), false);
234
+ assert.equal(deps.bridge.speaking, false);
235
+ assert.equal(deps.bridge.processing, false);
236
+ });
@@ -0,0 +1,37 @@
1
+ export function isHumanAllowed(member, allowedUsers = new Set()) {
2
+ const userId = String(member?.user?.id || member?.id || '');
3
+ if (!userId || member?.user?.bot) return false;
4
+ return allowedUsers.size === 0 || allowedUsers.has(userId);
5
+ }
6
+
7
+ export function pickOccupiedUserVoiceChannel(guilds = [], allowedUsers = new Set(), { activeVoiceChannelId = '', activeGuildId = '' } = {}) {
8
+ const candidates = [];
9
+ const guildList = [...(guilds || [])];
10
+ for (const guild of guildList) {
11
+ const channels = [...(guild?.channels?.cache?.values?.() || [])];
12
+ for (const channel of channels) {
13
+ if (!channel?.isVoiceBased?.()) continue;
14
+ const members = [...(channel.members?.values?.() || [])];
15
+ if (members.some(member => isHumanAllowed(member, allowedUsers))) {
16
+ candidates.push({ channel, guildId: String(guild?.id || '') });
17
+ }
18
+ }
19
+ }
20
+ if (!candidates.length) return null;
21
+ if (activeVoiceChannelId) {
22
+ const stay = candidates.find(c => String(c.channel?.id || '') === String(activeVoiceChannelId));
23
+ if (stay) return stay.channel;
24
+ }
25
+ if (activeGuildId) {
26
+ const sameGuild = candidates.find(c => c.guildId === String(activeGuildId));
27
+ if (sameGuild) return sameGuild.channel;
28
+ }
29
+ return candidates[0].channel;
30
+ }
31
+
32
+ export function shouldFollowUserVoiceChannel({ singleInstance = true, userId = '', allowedUsers = new Set(), userChannelId = '', activeVoiceChannelId = '' } = {}) {
33
+ if (!singleInstance || !userChannelId) return false;
34
+ const id = String(userId || '');
35
+ if (!id || (allowedUsers.size > 0 && !allowedUsers.has(id))) return false;
36
+ return String(userChannelId) !== String(activeVoiceChannelId || '');
37
+ }
@@ -0,0 +1,59 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ isHumanAllowed,
6
+ pickOccupiedUserVoiceChannel,
7
+ shouldFollowUserVoiceChannel,
8
+ } from './voice_autojoin.mjs';
9
+
10
+ function member(id, { bot = false } = {}) {
11
+ return { user: { id, bot } };
12
+ }
13
+
14
+ function voiceChannel(id, name, members = []) {
15
+ return {
16
+ id,
17
+ name,
18
+ members: new Map(members.map(m => [m.user.id, m])),
19
+ isVoiceBased: () => true,
20
+ };
21
+ }
22
+
23
+ function textChannel(id, name) {
24
+ return { id, name, members: new Map(), isVoiceBased: () => false };
25
+ }
26
+
27
+ function guild(channels) {
28
+ return { channels: { cache: new Map(channels.map(ch => [ch.id, ch])) } };
29
+ }
30
+
31
+ test('pickOccupiedUserVoiceChannel selects an allowed human voice channel before configured names', () => {
32
+ const empty = voiceChannel('v1', 'General', []);
33
+ const occupied = voiceChannel('v2', 'Summer', [member('u1')]);
34
+
35
+ const selected = pickOccupiedUserVoiceChannel([guild([textChannel('t1', 'text'), empty, occupied])], new Set(['u1']));
36
+
37
+ assert.equal(selected, occupied);
38
+ });
39
+
40
+ test('pickOccupiedUserVoiceChannel ignores bots and disallowed humans', () => {
41
+ const botOnly = voiceChannel('v1', 'Bot room', [member('bot1', { bot: true })]);
42
+ const disallowed = voiceChannel('v2', 'Other', [member('u2')]);
43
+
44
+ const selected = pickOccupiedUserVoiceChannel([guild([botOnly, disallowed])], new Set(['u1']));
45
+
46
+ assert.equal(selected, null);
47
+ });
48
+
49
+ test('shouldFollowUserVoiceChannel only follows allowed users in single-instance mode', () => {
50
+ assert.equal(shouldFollowUserVoiceChannel({ singleInstance: true, userId: 'u1', allowedUsers: new Set(['u1']), userChannelId: 'voice-a', activeVoiceChannelId: 'voice-b' }), true);
51
+ assert.equal(shouldFollowUserVoiceChannel({ singleInstance: false, userId: 'u1', allowedUsers: new Set(['u1']), userChannelId: 'voice-a', activeVoiceChannelId: 'voice-b' }), false);
52
+ assert.equal(shouldFollowUserVoiceChannel({ singleInstance: true, userId: 'u2', allowedUsers: new Set(['u1']), userChannelId: 'voice-a', activeVoiceChannelId: 'voice-b' }), false);
53
+ assert.equal(shouldFollowUserVoiceChannel({ singleInstance: true, userId: 'u1', allowedUsers: new Set(['u1']), userChannelId: 'voice-a', activeVoiceChannelId: 'voice-a' }), false);
54
+ });
55
+
56
+ test('isHumanAllowed accepts any non-bot when allowlist is empty', () => {
57
+ assert.equal(isHumanAllowed(member('u1'), new Set()), true);
58
+ assert.equal(isHumanAllowed(member('bot1', { bot: true }), new Set()), false);
59
+ });