verbalcoding 0.2.12 → 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 (169) hide show
  1. package/.env.example +74 -4
  2. package/README.es.md +3 -1
  3. package/README.fr.md +3 -1
  4. package/README.ja.md +3 -1
  5. package/README.ko.md +4 -2
  6. package/README.md +4 -2
  7. package/README.ru.md +3 -1
  8. package/README.zh.md +3 -1
  9. package/app-node/agent_adapters.test.mjs +14 -0
  10. package/app-node/agent_routing.mjs +148 -0
  11. package/app-node/agent_routing.test.mjs +138 -0
  12. package/app-node/agent_turn.mjs +86 -0
  13. package/app-node/agent_turn.test.mjs +109 -0
  14. package/app-node/bridge_context.mjs +73 -0
  15. package/app-node/bridge_context.test.mjs +54 -0
  16. package/app-node/bridge_state.mjs +4 -0
  17. package/app-node/bridge_wireup.test.mjs +462 -0
  18. package/app-node/cli_install.test.mjs +31 -0
  19. package/app-node/cross_agent_routing.test.mjs +78 -0
  20. package/app-node/discord_command_router.mjs +204 -0
  21. package/app-node/discord_command_router.test.mjs +311 -0
  22. package/app-node/discord_voice_setup.mjs +251 -0
  23. package/app-node/discord_voice_setup.test.mjs +86 -0
  24. package/app-node/hermes_profiles.test.mjs +12 -1
  25. package/app-node/install_config.mjs +110 -3
  26. package/app-node/install_config.test.mjs +8 -0
  27. package/app-node/instance_doctor.test.mjs +9 -0
  28. package/app-node/instances.test.mjs +8 -1
  29. package/app-node/main.mjs +488 -1368
  30. package/app-node/mcp_tools.test.mjs +7 -0
  31. package/app-node/notification_handler.mjs +89 -0
  32. package/app-node/notification_handler.test.mjs +187 -0
  33. package/app-node/plan_dispatcher.mjs +215 -0
  34. package/app-node/plan_dispatcher.test.mjs +101 -0
  35. package/app-node/plan_mode.mjs +36 -7
  36. package/app-node/plan_mode.test.mjs +78 -0
  37. package/app-node/progress_handler.mjs +220 -0
  38. package/app-node/progress_handler.test.mjs +193 -0
  39. package/app-node/progress_speech.mjs +54 -32
  40. package/app-node/progress_speech.test.mjs +12 -3
  41. package/app-node/project_sessions.mjs +5 -2
  42. package/app-node/project_sessions.test.mjs +7 -0
  43. package/app-node/research_mode.mjs +282 -0
  44. package/app-node/research_mode.test.mjs +264 -0
  45. package/app-node/restart_notice.mjs +3 -0
  46. package/app-node/restart_notice.test.mjs +11 -0
  47. package/app-node/session_ontology.mjs +271 -0
  48. package/app-node/session_ontology.test.mjs +130 -0
  49. package/app-node/smart_progress.mjs +1 -1
  50. package/app-node/stream_sentencer.mjs +32 -2
  51. package/app-node/stream_sentencer.test.mjs +65 -0
  52. package/app-node/streaming_tts_queue.mjs +5 -1
  53. package/app-node/streaming_tts_queue.test.mjs +7 -1
  54. package/app-node/stt_whisper.mjs +24 -0
  55. package/app-node/stt_whisper.test.mjs +32 -0
  56. package/app-node/text_routing.mjs +4 -2
  57. package/app-node/tts_backends.mjs +537 -3
  58. package/app-node/tts_backends.test.mjs +454 -0
  59. package/app-node/tts_player.mjs +164 -0
  60. package/app-node/tts_player.test.mjs +202 -0
  61. package/app-node/tts_runtime.mjs +134 -0
  62. package/app-node/tts_runtime.test.mjs +89 -0
  63. package/app-node/tts_settings.mjs +150 -3
  64. package/app-node/tts_settings.test.mjs +204 -0
  65. package/app-node/tts_voice_config.mjs +136 -2
  66. package/app-node/tts_voice_config.test.mjs +94 -0
  67. package/app-node/utterance_router.mjs +216 -0
  68. package/app-node/utterance_router.test.mjs +236 -0
  69. package/app-node/voice_autojoin.mjs +37 -0
  70. package/app-node/voice_autojoin.test.mjs +59 -0
  71. package/app-node/voice_io.mjs +272 -0
  72. package/app-node/voice_io.test.mjs +102 -0
  73. package/app-node/voice_turn_runner.mjs +449 -0
  74. package/app-node/voice_turn_runner.test.mjs +289 -0
  75. package/docs/CONFIGURATION.md +12 -2
  76. package/docs/HARNESSES.md +58 -0
  77. package/docs/HARNESS_AIDER.md +50 -0
  78. package/docs/HARNESS_CLAUDE.md +56 -0
  79. package/docs/HARNESS_CODEX.md +56 -0
  80. package/docs/HARNESS_CURSOR.md +45 -0
  81. package/docs/HARNESS_GEMINI.md +45 -0
  82. package/docs/HARNESS_HERMES.md +57 -0
  83. package/docs/HARNESS_OPENCLAW.md +44 -0
  84. package/docs/HARNESS_OPENCODE.md +44 -0
  85. package/docs/README.md +1 -0
  86. package/docs/ROADMAP.md +20 -5
  87. package/docs/TTS_BACKENDS.md +227 -0
  88. package/docs/USAGE.md +22 -0
  89. package/docs/i18n/AGENTS.es.md +34 -0
  90. package/docs/i18n/AGENTS.fr.md +34 -0
  91. package/docs/i18n/AGENTS.ja.md +34 -0
  92. package/docs/i18n/AGENTS.ko.md +34 -0
  93. package/docs/i18n/AGENTS.ru.md +34 -0
  94. package/docs/i18n/AGENTS.zh.md +34 -0
  95. package/docs/i18n/HARNESSES.es.md +58 -0
  96. package/docs/i18n/HARNESSES.fr.md +58 -0
  97. package/docs/i18n/HARNESSES.ja.md +58 -0
  98. package/docs/i18n/HARNESSES.ko.md +58 -0
  99. package/docs/i18n/HARNESSES.ru.md +58 -0
  100. package/docs/i18n/HARNESSES.zh.md +58 -0
  101. package/docs/i18n/HARNESS_AIDER.es.md +48 -0
  102. package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
  103. package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
  104. package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
  105. package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
  106. package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
  107. package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
  108. package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
  109. package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
  110. package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
  111. package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
  112. package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
  113. package/docs/i18n/HARNESS_CODEX.es.md +55 -0
  114. package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
  115. package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
  116. package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
  117. package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
  118. package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
  119. package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
  120. package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
  121. package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
  122. package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
  123. package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
  124. package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
  125. package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
  126. package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
  127. package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
  128. package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
  129. package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
  130. package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
  131. package/docs/i18n/HARNESS_HERMES.es.md +54 -0
  132. package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
  133. package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
  134. package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
  135. package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
  136. package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
  137. package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
  138. package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
  139. package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
  140. package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
  141. package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
  142. package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
  143. package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
  144. package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
  145. package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
  146. package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
  147. package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
  148. package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
  149. package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
  150. package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
  151. package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
  152. package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
  153. package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
  154. package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
  155. package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
  156. package/integrations/fireredtts2/mlx_llm.py +183 -0
  157. package/integrations/fireredtts2/synth.py +156 -0
  158. package/integrations/fireredtts2/synth_mlx.py +196 -0
  159. package/integrations/mlxaudio/synth.py +74 -0
  160. package/integrations/neuttsair/synth.py +104 -0
  161. package/integrations/omnivoice/synth.py +110 -0
  162. package/package.json +6 -1
  163. package/scripts/cli.mjs +84 -0
  164. package/scripts/doctor.mjs +104 -4
  165. package/scripts/install.mjs +5 -1
  166. package/scripts/install_fireredtts2.sh +109 -0
  167. package/scripts/install_mlxaudio.sh +34 -0
  168. package/scripts/install_mossttsnano.sh +46 -0
  169. package/scripts/postinstall.mjs +34 -0
@@ -0,0 +1,204 @@
1
+ // Discord text-channel command dispatcher. Replaces the big inline
2
+ // `client.on('messageCreate', ...)` body that used to live in main.mjs.
3
+ //
4
+ // Phase 6b extraction. The dispatcher is a single async function that
5
+ // recognises the `!`-prefixed control commands (ping, verbose, notify,
6
+ // smart-progress, sensitivity, session, latency, join/leave, say,
7
+ // voice-test, voice-clone, ask) and falls through to either the
8
+ // project-session router or the agent-text path.
9
+ //
10
+ // Commands are expressed as a small ordered table; adding a new command
11
+ // is a table edit, not a new `if` branch in main.mjs.
12
+
13
+ import { appendRecentDiscordText, shouldRouteDiscordTextToAgent } from './text_routing.mjs';
14
+ import { parseProjectSessionCommand } from './project_sessions.mjs';
15
+
16
+ export function createDiscordCommandRouter(deps) {
17
+ const {
18
+ bridge,
19
+ settings,
20
+ warn,
21
+ path,
22
+ ROOT,
23
+ isAllowed,
24
+ handleProjectSessionCommand,
25
+ handleTextAgentMessage,
26
+ resolveProjectSessionForChannel,
27
+ // Status & toggles
28
+ verboseStatusText,
29
+ setVerboseProgress,
30
+ notifyStatusText,
31
+ smartProgressStatusText,
32
+ sensitivityStatusText,
33
+ setSensitivityMode,
34
+ // Latency metrics
35
+ summarizeLatencyRecords,
36
+ readJsonlRecords,
37
+ formatLatencySummary,
38
+ // Voice channel control
39
+ connectTo,
40
+ // TTS / audio helpers
41
+ synthTTS,
42
+ playAudio,
43
+ speakText,
44
+ // Voice clone capture state
45
+ voiceCloneCapture,
46
+ } = deps;
47
+
48
+ // Build the command table once. Entries are matched in order; the first
49
+ // matching entry consumes the message. Each `match` is either a string,
50
+ // an array of strings (case-insensitive), or a function (content => bool).
51
+ // Each handler is `async (msg, content) => void`.
52
+ const commands = [
53
+ { match: '!ping', handler: async msg => { await msg.reply('pong'); } },
54
+
55
+ { match: '!verbose', handler: async msg => { await msg.reply(verboseStatusText()); } },
56
+ { match: ['!verbose on', '!verbose true', '!verbose 1', '!verbose 켜', '!verbose 켜줘'],
57
+ handler: async msg => { setVerboseProgress(true, 'discord-command'); await msg.reply(verboseStatusText()); } },
58
+ { match: ['!verbose off', '!verbose false', '!verbose 0', '!verbose 꺼', '!verbose 꺼줘'],
59
+ handler: async msg => { setVerboseProgress(false, 'discord-command'); await msg.reply(verboseStatusText()); } },
60
+
61
+ { match: '!notify', handler: async msg => { await msg.reply(notifyStatusText()); } },
62
+ { match: ['!notify on', '!notify always', '!notify 1'],
63
+ handler: async msg => { bridge.notifyUserOptIn = true; await msg.reply(notifyStatusText()); } },
64
+ { match: ['!notify off', '!notify auto', '!notify 0'],
65
+ handler: async msg => { bridge.notifyUserOptIn = false; await msg.reply(notifyStatusText()); } },
66
+
67
+ { match: c => c === '!smart-progress' || c === '!smart_progress',
68
+ handler: async msg => { await msg.reply(smartProgressStatusText()); } },
69
+ { match: ['!smart-progress on', '!smart-progress true', '!smart-progress 1', '!smart_progress on'],
70
+ handler: async msg => { bridge.smartProgressEnabled = true; await msg.reply(smartProgressStatusText()); } },
71
+ { match: ['!smart-progress off', '!smart-progress false', '!smart-progress 0', '!smart_progress off'],
72
+ handler: async msg => { bridge.smartProgressEnabled = false; await msg.reply(smartProgressStatusText()); } },
73
+
74
+ { match: '!sensitivity', handler: async msg => { await msg.reply(sensitivityStatusText()); } },
75
+ { match: '!sensitivity conservative',
76
+ handler: async msg => { setSensitivityMode('conservative', 'discord-command'); await msg.reply(sensitivityStatusText()); } },
77
+ { match: '!sensitivity normal',
78
+ handler: async msg => { setSensitivityMode('normal', 'discord-command'); await msg.reply(sensitivityStatusText()); } },
79
+
80
+ { match: c => c === '!latency' || c === '!metrics',
81
+ handler: async msg => {
82
+ const summary = summarizeLatencyRecords(readJsonlRecords(settings.latencyLogPath, { limit: 200 }));
83
+ await msg.reply(`최근 latency 요약 (${settings.latencyLogPath}):\n${formatLatencySummary(summary)}`.slice(0, 1900));
84
+ } },
85
+
86
+ { match: '!session', handler: async msg => { await handleProjectSessionCommand(msg, { action: 'status' }); } },
87
+ { match: '!reset-session', handler: async msg => { await handleProjectSessionCommand(msg, { action: 'reset' }); } },
88
+
89
+ { match: '!join',
90
+ handler: async msg => {
91
+ const ch = msg.member?.voice?.channel;
92
+ if (!ch) { await msg.reply('먼저 음성 채널에 들어가줘.'); return; }
93
+ await connectTo(ch);
94
+ await msg.reply('들어왔어. Node receiver로 듣는 중.');
95
+ } },
96
+ { match: '!leave',
97
+ handler: async msg => {
98
+ try { bridge.connection?.destroy(); } catch {}
99
+ bridge.connection = null;
100
+ bridge.activeVoiceChannelId = '';
101
+ await msg.reply('나갈게.');
102
+ } },
103
+
104
+ { match: c => c.startsWith('!say '),
105
+ handler: async (_msg, content) => {
106
+ const text = content.slice(5).trim();
107
+ const mp3 = await synthTTS(text);
108
+ await playAudio(mp3);
109
+ } },
110
+
111
+ { match: c => c.startsWith('!voice-test '),
112
+ handler: async (msg, content) => {
113
+ const text = content.slice('!voice-test '.length).trim();
114
+ if (!text) { await msg.reply('테스트할 문장을 붙여줘.'); return; }
115
+ const started = Date.now();
116
+ try {
117
+ await msg.reply(`TTS 백엔드 ${bridge.ttsBackend.name}로 음성 테스트할게.`);
118
+ await speakText(text);
119
+ await msg.channel.send(`음성 테스트 완료: ${bridge.ttsBackend.name}, ${Date.now() - started}ms`);
120
+ } catch (e) {
121
+ warn('voice-test failed', e?.stack || e);
122
+ await msg.channel.send(`음성 테스트 실패: ${String(e?.message || e).slice(0, 700)}`);
123
+ }
124
+ } },
125
+
126
+ { match: c => c === '!voice-clone' || c === '!voice-clone status',
127
+ handler: async msg => {
128
+ const current = voiceCloneCapture.current();
129
+ if (current?.userId === String(msg.author.id)) {
130
+ await msg.reply(`다음 유효한 음성을 ${path.relative(ROOT, current.targetPath)}에 저장할게.`);
131
+ return;
132
+ }
133
+ await msg.reply('대기 중인 보이스 클로닝 샘플 캡처가 없어. `!voice-clone capture`로 시작해.');
134
+ } },
135
+ { match: '!voice-clone cancel',
136
+ handler: async msg => {
137
+ const cancelled = voiceCloneCapture.cancel(msg.author.id);
138
+ await msg.reply(cancelled ? '보이스 클로닝 샘플 캡처를 취소했어.' : '대기 중인 캡처가 없어.');
139
+ } },
140
+ { match: '!voice-clone capture',
141
+ handler: async msg => {
142
+ const armed = voiceCloneCapture.arm({ userId: msg.author.id, source: 'discord-command' });
143
+ await msg.reply(`다음 유효한 음성을 ${path.relative(ROOT, armed.targetPath)}에 저장할게. 음성 채널에서 10~30초 정도 말해줘.`);
144
+ } },
145
+
146
+ { match: c => c.startsWith('!ask '),
147
+ handler: async (msg, content) => {
148
+ const text = content.slice(5).trim();
149
+ if (!text) { await msg.reply('물어볼 내용을 붙여줘.'); return; }
150
+ await handleTextAgentMessage(msg, text, { speakResponse: true });
151
+ } },
152
+ ];
153
+
154
+ function matches(rule, lowered, raw) {
155
+ if (typeof rule === 'string') return raw === rule;
156
+ if (Array.isArray(rule)) return rule.includes(lowered);
157
+ if (typeof rule === 'function') return rule(raw);
158
+ return false;
159
+ }
160
+
161
+ async function handleDiscordMessage(msg) {
162
+ const content = msg.content.trim();
163
+ if (msg.author.bot && !content.startsWith('!say ')) return;
164
+ if (!msg.author.bot && !isAllowed(msg.author.id)) return;
165
+ appendRecentDiscordText(bridge.recentDiscordTextByChannel, {
166
+ channelId: msg.channelId,
167
+ authorLabel: msg.member?.displayName || msg.author?.username || 'user',
168
+ content,
169
+ messageId: msg.id,
170
+ });
171
+
172
+ // Project-session commands (e.g. !session attach-voice ...) take priority.
173
+ const projectSessionCommand = parseProjectSessionCommand(content);
174
+ if (projectSessionCommand) {
175
+ try {
176
+ await handleProjectSessionCommand(msg, projectSessionCommand);
177
+ } catch (e) {
178
+ warn('project session command failed', e?.stack || e);
179
+ await msg.reply(String(e?.message || e).slice(0, 700));
180
+ }
181
+ return;
182
+ }
183
+
184
+ const lowered = content.toLowerCase();
185
+ for (const cmd of commands) {
186
+ if (matches(cmd.match, lowered, content)) {
187
+ await cmd.handler(msg, content);
188
+ return;
189
+ }
190
+ }
191
+
192
+ // Fallback: route to the agent if the heuristic says so OR if the
193
+ // channel is bound to a project session.
194
+ if (shouldRouteDiscordTextToAgent({
195
+ content,
196
+ channelId: msg.channelId,
197
+ transcriptChannelId: settings.transcriptChannelId,
198
+ }) || resolveProjectSessionForChannel(msg.channelId)) {
199
+ await handleTextAgentMessage(msg, content, { speakResponse: false });
200
+ }
201
+ }
202
+
203
+ return { handleDiscordMessage };
204
+ }
@@ -0,0 +1,311 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createDiscordCommandRouter } from './discord_command_router.mjs';
4
+ import { createBridge } from './bridge_context.mjs';
5
+
6
+ const noop = () => {};
7
+ const noopAsync = async () => {};
8
+
9
+ // Construct a minimal `msg` stand-in. Each test passes the `content` it
10
+ // wants to dispatch and inspects what handlers were called.
11
+ function makeMsg({ content, authorId = 'allowed-user', bot = false, channelId = 'tx-ch' } = {}) {
12
+ const replies = [];
13
+ const sends = [];
14
+ return {
15
+ content,
16
+ author: { id: authorId, bot, username: 'tester' },
17
+ member: { displayName: 'Tester', voice: { channel: null } },
18
+ channelId,
19
+ id: 'msg-1',
20
+ channel: { send: async t => { sends.push(t); } },
21
+ reply: async t => { replies.push(t); },
22
+ guild: null,
23
+ _replies: replies,
24
+ _sends: sends,
25
+ };
26
+ }
27
+
28
+ function makeRouter(overrides = {}) {
29
+ const bridge = createBridge();
30
+ // !voice-test reads `bridge.ttsBackend.name` in its reply template, so the
31
+ // bridge needs a non-null ttsBackend stub for tests that exercise that path.
32
+ bridge.ttsBackend = { name: 'fake' };
33
+ const calls = {
34
+ handleProjectSessionCommand: [],
35
+ handleTextAgentMessage: [],
36
+ setVerboseProgress: [],
37
+ setSensitivityMode: [],
38
+ connectTo: [],
39
+ synthTTS: [],
40
+ playAudio: [],
41
+ speakText: [],
42
+ voiceCloneArm: [],
43
+ voiceCloneCancel: [],
44
+ };
45
+ const deps = {
46
+ bridge,
47
+ settings: { transcriptChannelId: 'tx-ch', latencyLogPath: '/tmp/lat.jsonl' },
48
+ log: noop, warn: noop,
49
+ path: { relative: (_root, p) => p, join: (...a) => a.join('/') },
50
+ ROOT: '/tmp/vc',
51
+ isAllowed: () => true,
52
+ handleProjectSessionCommand: async (msg, cmd) => { calls.handleProjectSessionCommand.push(cmd); },
53
+ handleTextAgentMessage: async (msg, text, opts) => { calls.handleTextAgentMessage.push({ text, opts }); },
54
+ resolveProjectSessionForChannel: () => null,
55
+ verboseStatusText: () => 'verbose=off',
56
+ setVerboseProgress: (v, reason) => { calls.setVerboseProgress.push({ v, reason }); },
57
+ notifyStatusText: () => 'notify-status',
58
+ smartProgressStatusText: () => 'smart-progress=off',
59
+ sensitivityStatusText: () => 'sensitivity=normal',
60
+ setSensitivityMode: (mode, reason) => { calls.setSensitivityMode.push({ mode, reason }); return { mode }; },
61
+ summarizeLatencyRecords: () => ({ ok: 0 }),
62
+ readJsonlRecords: () => [],
63
+ formatLatencySummary: () => 'no data',
64
+ connectTo: async ch => { calls.connectTo.push(ch); },
65
+ synthTTS: async t => { calls.synthTTS.push(t); return '/tmp/x.wav'; },
66
+ playAudio: async f => { calls.playAudio.push(f); },
67
+ speakText: async t => { calls.speakText.push(t); },
68
+ voiceCloneCapture: {
69
+ current: () => null,
70
+ arm: ({ userId, source }) => { calls.voiceCloneArm.push({ userId, source }); return { targetPath: '/tmp/sample.wav' }; },
71
+ cancel: uid => { calls.voiceCloneCancel.push(uid); return false; },
72
+ },
73
+ ...overrides,
74
+ };
75
+ const router = createDiscordCommandRouter(deps);
76
+ return { router, bridge, calls, deps };
77
+ }
78
+
79
+ test('createDiscordCommandRouter exposes handleDiscordMessage', () => {
80
+ const { router } = makeRouter();
81
+ assert.equal(typeof router.handleDiscordMessage, 'function');
82
+ });
83
+
84
+ test('ignores bot messages (except !say) and disallowed users', async () => {
85
+ const { router, calls } = makeRouter({ isAllowed: () => false });
86
+ await router.handleDiscordMessage(makeMsg({ content: '!ping', authorId: 'x' }));
87
+ assert.equal(calls.handleProjectSessionCommand.length, 0);
88
+ // Now allow the user; bot=true means we still skip non-!say messages.
89
+ const { router: r2, calls: c2 } = makeRouter();
90
+ const m = makeMsg({ content: 'hello there', bot: true });
91
+ await r2.handleDiscordMessage(m);
92
+ assert.equal(c2.handleTextAgentMessage.length, 0);
93
+ });
94
+
95
+ test('!ping replies pong', async () => {
96
+ const { router } = makeRouter();
97
+ const msg = makeMsg({ content: '!ping' });
98
+ await router.handleDiscordMessage(msg);
99
+ assert.deepEqual(msg._replies, ['pong']);
100
+ });
101
+
102
+ test('!verbose status and toggles', async () => {
103
+ const { router, calls } = makeRouter();
104
+ await router.handleDiscordMessage(makeMsg({ content: '!verbose' }));
105
+ await router.handleDiscordMessage(makeMsg({ content: '!verbose on' }));
106
+ await router.handleDiscordMessage(makeMsg({ content: '!verbose off' }));
107
+ await router.handleDiscordMessage(makeMsg({ content: '!verbose 켜줘' }));
108
+ await router.handleDiscordMessage(makeMsg({ content: '!verbose 꺼' }));
109
+ assert.deepEqual(calls.setVerboseProgress, [
110
+ { v: true, reason: 'discord-command' },
111
+ { v: false, reason: 'discord-command' },
112
+ { v: true, reason: 'discord-command' },
113
+ { v: false, reason: 'discord-command' },
114
+ ]);
115
+ });
116
+
117
+ test('!notify toggles bridge.notifyUserOptIn', async () => {
118
+ const { router, bridge } = makeRouter();
119
+ await router.handleDiscordMessage(makeMsg({ content: '!notify on' }));
120
+ assert.equal(bridge.notifyUserOptIn, true);
121
+ await router.handleDiscordMessage(makeMsg({ content: '!notify off' }));
122
+ assert.equal(bridge.notifyUserOptIn, false);
123
+ await router.handleDiscordMessage(makeMsg({ content: '!notify 1' }));
124
+ assert.equal(bridge.notifyUserOptIn, true);
125
+ });
126
+
127
+ test('!smart-progress toggles bridge.smartProgressEnabled', async () => {
128
+ const { router, bridge } = makeRouter();
129
+ await router.handleDiscordMessage(makeMsg({ content: '!smart-progress on' }));
130
+ assert.equal(bridge.smartProgressEnabled, true);
131
+ await router.handleDiscordMessage(makeMsg({ content: '!smart_progress off' }));
132
+ assert.equal(bridge.smartProgressEnabled, false);
133
+ });
134
+
135
+ test('!sensitivity conservative/normal call setSensitivityMode', async () => {
136
+ const { router, calls } = makeRouter();
137
+ await router.handleDiscordMessage(makeMsg({ content: '!sensitivity conservative' }));
138
+ await router.handleDiscordMessage(makeMsg({ content: '!sensitivity normal' }));
139
+ assert.deepEqual(calls.setSensitivityMode, [
140
+ { mode: 'conservative', reason: 'discord-command' },
141
+ { mode: 'normal', reason: 'discord-command' },
142
+ ]);
143
+ });
144
+
145
+ test('!join calls connectTo with the member voice channel', async () => {
146
+ const { router, calls } = makeRouter();
147
+ const ch = { id: 'vc-1', guild: { name: 'g' }, name: 'general' };
148
+ const msg = makeMsg({ content: '!join' });
149
+ msg.member.voice.channel = ch;
150
+ await router.handleDiscordMessage(msg);
151
+ assert.equal(calls.connectTo.length, 1);
152
+ assert.equal(calls.connectTo[0], ch);
153
+ });
154
+
155
+ test('!join without member voice channel replies a hint', async () => {
156
+ const { router, calls } = makeRouter();
157
+ const msg = makeMsg({ content: '!join' });
158
+ await router.handleDiscordMessage(msg);
159
+ assert.equal(calls.connectTo.length, 0);
160
+ assert.match(msg._replies[0], /음성 채널/);
161
+ });
162
+
163
+ test('!leave destroys the active voice connection', async () => {
164
+ const { router, bridge } = makeRouter();
165
+ let destroyed = false;
166
+ bridge.connection = { destroy: () => { destroyed = true; } };
167
+ bridge.activeVoiceChannelId = 'vc-1';
168
+ await router.handleDiscordMessage(makeMsg({ content: '!leave' }));
169
+ assert.equal(destroyed, true);
170
+ assert.equal(bridge.connection, null);
171
+ assert.equal(bridge.activeVoiceChannelId, '');
172
+ });
173
+
174
+ test('!say synths + plays the trailing text', async () => {
175
+ const { router, calls } = makeRouter();
176
+ await router.handleDiscordMessage(makeMsg({ content: '!say hello world' }));
177
+ assert.deepEqual(calls.synthTTS, ['hello world']);
178
+ assert.deepEqual(calls.playAudio, ['/tmp/x.wav']);
179
+ });
180
+
181
+ test('!voice-clone capture arms the capture state', async () => {
182
+ const { router, calls } = makeRouter();
183
+ await router.handleDiscordMessage(makeMsg({ content: '!voice-clone capture', authorId: 'u1' }));
184
+ assert.equal(calls.voiceCloneArm.length, 1);
185
+ assert.equal(calls.voiceCloneArm[0].userId, 'u1');
186
+ });
187
+
188
+ test('!voice-clone cancel calls cancel(userId)', async () => {
189
+ const { router, calls } = makeRouter();
190
+ await router.handleDiscordMessage(makeMsg({ content: '!voice-clone cancel', authorId: 'u1' }));
191
+ assert.deepEqual(calls.voiceCloneCancel, ['u1']);
192
+ });
193
+
194
+ test('!ask routes trailing text to handleTextAgentMessage with speakResponse:true', async () => {
195
+ const { router, calls } = makeRouter();
196
+ await router.handleDiscordMessage(makeMsg({ content: '!ask what time is it' }));
197
+ assert.equal(calls.handleTextAgentMessage.length, 1);
198
+ assert.equal(calls.handleTextAgentMessage[0].text, 'what time is it');
199
+ assert.equal(calls.handleTextAgentMessage[0].opts.speakResponse, true);
200
+ });
201
+
202
+ test('project-session command takes priority over !-prefixed commands', async () => {
203
+ // parseProjectSessionCommand is imported by the router from
204
+ // ./project_sessions.mjs; we can't easily monkey-patch the import here,
205
+ // but we can prove the priority by checking that a command pattern
206
+ // recognised as a session command never reaches !ping.
207
+ const { router, calls } = makeRouter();
208
+ await router.handleDiscordMessage(makeMsg({ content: '!session' }));
209
+ // !session is handled as a !-command via the table (handleProjectSessionCommand
210
+ // with action=status). Either way the project session handler is called.
211
+ assert.equal(calls.handleProjectSessionCommand.length, 1);
212
+ assert.equal(calls.handleProjectSessionCommand[0].action, 'status');
213
+ });
214
+
215
+ test('fallback routes plain text to handleTextAgentMessage when channel is project-bound', async () => {
216
+ const { router, calls } = makeRouter({
217
+ resolveProjectSessionForChannel: () => ({ slug: 'my-project' }),
218
+ });
219
+ await router.handleDiscordMessage(makeMsg({ content: 'hello agent' }));
220
+ assert.equal(calls.handleTextAgentMessage.length, 1);
221
+ assert.equal(calls.handleTextAgentMessage[0].text, 'hello agent');
222
+ assert.equal(calls.handleTextAgentMessage[0].opts.speakResponse, false);
223
+ });
224
+
225
+ test('fallback does NOT route non-project text without an explicit trigger', async () => {
226
+ const { router, calls } = makeRouter();
227
+ // Use a channel that is neither the transcript channel nor project-bound,
228
+ // so neither leg of the routing heuristic triggers.
229
+ await router.handleDiscordMessage(makeMsg({ content: 'just some chatter', channelId: 'unrelated-channel' }));
230
+ assert.equal(calls.handleTextAgentMessage.length, 0, 'no agent dispatch for non-bound channel');
231
+ });
232
+
233
+ test('!latency / !metrics build a summary from the jsonl log', async () => {
234
+ let summarizeArg = null;
235
+ let formatted = null;
236
+ const { router } = makeRouter({
237
+ readJsonlRecords: () => [{ status: 'ok' }],
238
+ summarizeLatencyRecords: rs => { summarizeArg = rs; return { ok: rs.length }; },
239
+ formatLatencySummary: s => { formatted = s; return 'summary text'; },
240
+ });
241
+ const msg = makeMsg({ content: '!latency' });
242
+ await router.handleDiscordMessage(msg);
243
+ assert.equal(summarizeArg.length, 1, 'summarize ran on the parsed records');
244
+ assert.deepEqual(formatted, { ok: 1 });
245
+ assert.match(msg._replies[0], /summary text/);
246
+
247
+ const msg2 = makeMsg({ content: '!metrics' });
248
+ await router.handleDiscordMessage(msg2);
249
+ assert.match(msg2._replies[0], /summary text/, '!metrics is an alias for !latency');
250
+ });
251
+
252
+ test('!reset-session dispatches to project session handler with action=reset', async () => {
253
+ const { router, calls } = makeRouter();
254
+ await router.handleDiscordMessage(makeMsg({ content: '!reset-session' }));
255
+ assert.deepEqual(calls.handleProjectSessionCommand, [{ action: 'reset' }]);
256
+ });
257
+
258
+ test('!voice-test logs a success line on happy path', async () => {
259
+ const { router } = makeRouter();
260
+ const msg = makeMsg({ content: '!voice-test 안녕하세요' });
261
+ await router.handleDiscordMessage(msg);
262
+ // First reply announces the test; then a send confirms completion.
263
+ assert.ok(msg._replies.some(t => /TTS 백엔드.*음성 테스트/.test(t)));
264
+ assert.ok(msg._sends.some(t => /음성 테스트 완료/.test(t)));
265
+ });
266
+
267
+ test('!voice-test reports failure to the channel when speakText throws', async () => {
268
+ const warnCalls = [];
269
+ const { router } = makeRouter({
270
+ warn: (...args) => warnCalls.push(args),
271
+ speakText: async () => { throw new Error('synth boom'); },
272
+ });
273
+ const msg = makeMsg({ content: '!voice-test fail-me' });
274
+ await router.handleDiscordMessage(msg);
275
+ assert.ok(msg._sends.some(t => /음성 테스트 실패.*synth boom/.test(t)));
276
+ assert.ok(warnCalls.some(args => /voice-test failed/.test(args[0])));
277
+ });
278
+
279
+ // Note on the !voice-test "empty text" branch: handleDiscordMessage does
280
+ // `content = msg.content.trim()` before matching, so `!voice-test ` collapses
281
+ // to `!voice-test` which fails the `c.startsWith('!voice-test ')` predicate.
282
+ // The empty-text branch inside the handler is therefore unreachable; the
283
+ // behaviour predates the refactor (HEAD 575870b had the same shape). Not
284
+ // covered by a test on purpose.
285
+
286
+ test('bot author can still trigger !say (carve-out)', async () => {
287
+ const { router, calls } = makeRouter();
288
+ await router.handleDiscordMessage(makeMsg({ content: '!say hi from bot', bot: true }));
289
+ assert.deepEqual(calls.synthTTS, ['hi from bot']);
290
+ assert.deepEqual(calls.playAudio, ['/tmp/x.wav']);
291
+ });
292
+
293
+ test('bare !voice-clone with no active capture replies a hint', async () => {
294
+ const { router } = makeRouter();
295
+ const msg = makeMsg({ content: '!voice-clone' });
296
+ await router.handleDiscordMessage(msg);
297
+ assert.match(msg._replies[0], /대기 중인 보이스 클로닝 샘플 캡처가 없어/);
298
+ });
299
+
300
+ test('!voice-clone status with an active capture reports the target path', async () => {
301
+ const { router } = makeRouter({
302
+ voiceCloneCapture: {
303
+ current: () => ({ userId: 'u1', targetPath: '/tmp/me.wav' }),
304
+ arm: () => ({ targetPath: '/tmp/me.wav' }),
305
+ cancel: () => false,
306
+ },
307
+ });
308
+ const msg = makeMsg({ content: '!voice-clone status', authorId: 'u1' });
309
+ await router.handleDiscordMessage(msg);
310
+ assert.match(msg._replies[0], /\/tmp\/me\.wav/);
311
+ });