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,251 @@
1
+ // Discord voice channel join / attach / shutdown machinery.
2
+ //
3
+ // Phase 5d extraction from main.mjs. createDiscordVoiceSetup(deps) closes
4
+ // over the bridge state (connection, player, activeVoiceChannelId,
5
+ // currentAbortController, ttsBackend, agentAdaptersBySession) plus the
6
+ // Discord client and a handful of helpers, and returns the seven functions
7
+ // main.mjs used to own: connectTo, autoJoin, findVoiceChannelBySelector,
8
+ // voiceChannelLabel, resolveVoiceChannelForAttach,
9
+ // attachVoiceChannelToTextSession, gracefulShutdown.
10
+ //
11
+ // The shutdown guard (`shutdownStarted`) lives as a closure variable
12
+ // inside the factory so SIGTERM/SIGINT handlers in main.mjs see exactly
13
+ // one shared flag.
14
+
15
+ import path from 'node:path';
16
+ import fs from 'node:fs';
17
+ import {
18
+ AudioPlayerStatus,
19
+ VoiceConnectionStatus,
20
+ entersState,
21
+ joinVoiceChannel,
22
+ } from '@discordjs/voice';
23
+ import { pickOccupiedUserVoiceChannel } from './voice_autojoin.mjs';
24
+ import { formatRestartShutdownNotice } from './restart_notice.mjs';
25
+
26
+ export function createDiscordVoiceSetup(deps) {
27
+ const {
28
+ bridge,
29
+ client,
30
+ settings,
31
+ ROOT,
32
+ log,
33
+ warn,
34
+ speakText,
35
+ waitEvent,
36
+ subscribeUser,
37
+ pendingFallbackNoticePromises,
38
+ bindProjectSessionToChannel,
39
+ createProjectSession,
40
+ resolveProjectSessionForChannel,
41
+ saveProjectSessionsState,
42
+ projectSessionsState,
43
+ invalidateBackendAdaptersForSession,
44
+ VOICE_CONNECT_TIMEOUT_MS,
45
+ } = deps;
46
+
47
+ async function connectTo(channel) {
48
+ if (bridge.connection) {
49
+ try { bridge.connection.destroy(); } catch {}
50
+ }
51
+ bridge.activeVoiceChannelId = channel.id;
52
+ bridge.connection = joinVoiceChannel({
53
+ channelId: channel.id,
54
+ guildId: channel.guild.id,
55
+ adapterCreator: channel.guild.voiceAdapterCreator,
56
+ selfDeaf: false,
57
+ selfMute: false,
58
+ });
59
+ const voiceConnection = bridge.connection;
60
+ voiceConnection.subscribe(bridge.player);
61
+ voiceConnection.on('error', e => warn('voice connection error', e?.stack || e));
62
+ voiceConnection.on('stateChange', async (oldState, newState) => {
63
+ log('voice connection state', oldState.status, '->', newState.status);
64
+ if (bridge.connection !== voiceConnection) {
65
+ log('ignore stale voice connection state', oldState.status, '->', newState.status);
66
+ return;
67
+ }
68
+ if (newState.status === VoiceConnectionStatus.Disconnected) {
69
+ try {
70
+ await Promise.race([
71
+ entersState(voiceConnection, VoiceConnectionStatus.Signalling, 5000),
72
+ entersState(voiceConnection, VoiceConnectionStatus.Connecting, 5000),
73
+ ]);
74
+ } catch (e) {
75
+ if (bridge.connection !== voiceConnection) return;
76
+ warn('voice connection disconnected; reconnecting to channel', channel.guild.name, channel.name, e?.message || e);
77
+ try { voiceConnection.destroy(); } catch {}
78
+ bridge.connection = null;
79
+ setTimeout(() => connectTo(channel).catch(err => warn('voice reconnect failed', err?.stack || err)), 1500);
80
+ }
81
+ }
82
+ });
83
+ await entersState(voiceConnection, VoiceConnectionStatus.Ready, VOICE_CONNECT_TIMEOUT_MS);
84
+ voiceConnection.receiver.speaking.on('start', userId => subscribeUser(voiceConnection.receiver, userId));
85
+ log(`Listening in voice channel ${channel.guild.name} / ${channel.name}`);
86
+ }
87
+
88
+ async function autoJoin() {
89
+ const attempted = [];
90
+ for (const guild of client.guilds.cache.values()) {
91
+ await guild.channels.fetch().catch(e => warn('auto-join channel fetch failed', guild.name, e?.message || e));
92
+ }
93
+ const activeGuildId = bridge.activeVoiceChannelId ? client.channels.cache.get(bridge.activeVoiceChannelId)?.guild?.id || '' : '';
94
+ const occupied = pickOccupiedUserVoiceChannel(client.guilds.cache.values(), settings.allowedUsers, {
95
+ activeVoiceChannelId: bridge.activeVoiceChannelId,
96
+ activeGuildId,
97
+ });
98
+ if (occupied) {
99
+ attempted.push(`${occupied.guild.name}/${occupied.name}`);
100
+ try {
101
+ log('auto-join following occupied user voice channel', occupied.guild.name, occupied.name);
102
+ await connectTo(occupied);
103
+ return;
104
+ } catch (e) {
105
+ warn('auto-join occupied user voice channel failed; trying configured channels', occupied.guild.name, occupied.name, e?.stack || e);
106
+ try { bridge.connection?.destroy(); } catch {}
107
+ bridge.connection = null;
108
+ bridge.activeVoiceChannelId = '';
109
+ }
110
+ }
111
+ for (const preferredName of settings.autoJoinVoiceChannels) {
112
+ for (const guild of client.guilds.cache.values()) {
113
+ const channels = await guild.channels.fetch();
114
+ for (const ch of channels.values()) {
115
+ if (!ch?.isVoiceBased?.() || ch.name.toLowerCase() !== preferredName) continue;
116
+ attempted.push(`${guild.name}/${ch.name}`);
117
+ try {
118
+ await connectTo(ch);
119
+ return;
120
+ } catch (e) {
121
+ warn('auto-join failed; trying next configured voice channel', guild.name, ch.name, e?.stack || e);
122
+ try { bridge.connection?.destroy(); } catch {}
123
+ bridge.connection = null;
124
+ bridge.activeVoiceChannelId = '';
125
+ }
126
+ }
127
+ }
128
+ }
129
+ warn('No auto-join channel found or reachable', settings.autoJoinVoiceChannels, 'attempted', attempted);
130
+ }
131
+
132
+ async function findVoiceChannelBySelector(guild, selector) {
133
+ const wanted = String(selector || '').trim();
134
+ if (!wanted || !guild) return null;
135
+ const id = wanted.replace(/^<#(\d+)>$/, '$1');
136
+ const channels = await guild.channels.fetch();
137
+ const voiceChannels = [...channels.values()].filter(ch => ch?.isVoiceBased?.());
138
+ const byId = voiceChannels.find(ch => ch.id === id);
139
+ if (byId) return byId;
140
+ const matches = voiceChannels.filter(ch => String(ch.name || '').toLowerCase() === wanted.toLowerCase());
141
+ if (matches.length === 1) return matches[0];
142
+ if (matches.length > 1) throw new Error(`같은 이름의 음성 채널이 여러 개야. 채널 ID나 멘션으로 지정해줘: ${wanted}`);
143
+ throw new Error(`음성 채널을 찾지 못했어: ${wanted}`);
144
+ }
145
+
146
+ async function voiceChannelLabel(guild, channelId) {
147
+ if (!channelId || !guild) return '없음';
148
+ try {
149
+ const ch = await guild.channels.fetch(channelId);
150
+ return ch?.name || '지정됨';
151
+ } catch {
152
+ return '지정됨';
153
+ }
154
+ }
155
+
156
+ async function resolveVoiceChannelForAttach(msg, selector = '') {
157
+ if (selector) return findVoiceChannelBySelector(msg.guild, selector);
158
+ if (msg.member?.voice?.channel) return msg.member.voice.channel;
159
+ if (bridge.activeVoiceChannelId && msg.guild) {
160
+ try {
161
+ const ch = await msg.guild.channels.fetch(bridge.activeVoiceChannelId);
162
+ if (ch?.isVoiceBased?.()) return ch;
163
+ } catch {}
164
+ }
165
+ throw new Error('붙일 음성 채널을 못 찾았어. 음성채널에 들어가서 `!session attach-voice`를 치거나 `--voice "채널명"`을 붙여줘.');
166
+ }
167
+
168
+ async function attachVoiceChannelToTextSession(msg, command) {
169
+ const voiceChannel = await resolveVoiceChannelForAttach(msg, command.voice);
170
+ let session = null;
171
+ if (command.name) {
172
+ session = bindProjectSessionToChannel({ state: projectSessionsState, nameOrSlug: command.name, channelId: msg.channelId });
173
+ } else {
174
+ session = resolveProjectSessionForChannel(msg.channelId)
175
+ || resolveProjectSessionForChannel(voiceChannel.id);
176
+ if (!session) {
177
+ const fallbackName = String(msg.channel?.name || `channel-${msg.channelId}`).trim() || `channel-${msg.channelId}`;
178
+ session = createProjectSession({
179
+ root: ROOT,
180
+ state: projectSessionsState,
181
+ name: fallbackName,
182
+ workdir: settings.agent.cwd || ROOT,
183
+ channelId: msg.channelId,
184
+ voiceChannelId: voiceChannel.id,
185
+ transcriptChannelId: msg.channelId,
186
+ mcpContext: 'Ad-hoc Discord text channel session',
187
+ });
188
+ }
189
+ }
190
+ session.transcriptChannelId = msg.channelId;
191
+ session.voiceChannelId = voiceChannel.id;
192
+ projectSessionsState.channelSessions[msg.channelId] = session.slug;
193
+ projectSessionsState.channelSessions[voiceChannel.id] = session.slug;
194
+ saveProjectSessionsState();
195
+ bridge.agentAdaptersBySession.delete(session.slug);
196
+ invalidateBackendAdaptersForSession(session.slug);
197
+ if (bridge.activeVoiceChannelId !== voiceChannel.id) await connectTo(voiceChannel);
198
+ return msg.reply(`${session.name} 세션을 이 텍스트 채널과 음성 채널 ${voiceChannel.name}에 붙였어. 이제 그 음성채널 발화의 STT/답변 텍스트는 이 채널로 가.`);
199
+ }
200
+
201
+ let shutdownStarted = false;
202
+ async function gracefulShutdown(signalName) {
203
+ if (shutdownStarted) return;
204
+ shutdownStarted = true;
205
+ log('graceful shutdown requested', signalName, 'connection', Boolean(bridge.connection));
206
+ try {
207
+ if (bridge.currentAbortController && !bridge.currentAbortController.signal.aborted) bridge.currentAbortController.abort();
208
+ } catch (e) {
209
+ warn('abort before shutdown failed', e?.stack || e);
210
+ }
211
+ try {
212
+ if (bridge.connection) {
213
+ let detail = '';
214
+ const noticePath = path.join(ROOT, '.cache', 'restart-notice.txt');
215
+ try {
216
+ if (fs.existsSync(noticePath)) {
217
+ detail = fs.readFileSync(noticePath, 'utf8').replace(/\s+/g, ' ').trim().slice(0, 120);
218
+ }
219
+ } catch (e) {
220
+ warn('read restart notice failed', e?.stack || e);
221
+ }
222
+ await speakText(formatRestartShutdownNotice(detail, settings.tts.edge.voice));
223
+ await waitEvent(bridge.player, AudioPlayerStatus.Idle, 30000).catch(() => {});
224
+ }
225
+ } catch (e) {
226
+ warn('shutdown voice notice failed', e?.stack || e);
227
+ }
228
+ if (pendingFallbackNoticePromises.size) {
229
+ try {
230
+ await Promise.race([
231
+ Promise.allSettled(Array.from(pendingFallbackNoticePromises)),
232
+ new Promise(resolve => setTimeout(resolve, 3000)),
233
+ ]);
234
+ } catch {}
235
+ }
236
+ try { bridge.ttsBackend?.close?.(); } catch (e) { warn('tts backend close failed', e?.message || e); }
237
+ try { bridge.connection?.destroy(); } catch {}
238
+ try { client.destroy(); } catch {}
239
+ process.exit(0);
240
+ }
241
+
242
+ return {
243
+ connectTo,
244
+ autoJoin,
245
+ findVoiceChannelBySelector,
246
+ voiceChannelLabel,
247
+ resolveVoiceChannelForAttach,
248
+ attachVoiceChannelToTextSession,
249
+ gracefulShutdown,
250
+ };
251
+ }
@@ -0,0 +1,86 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createDiscordVoiceSetup } from './discord_voice_setup.mjs';
4
+ import { createBridge } from './bridge_context.mjs';
5
+
6
+ function makeDeps(overrides = {}) {
7
+ const bridge = createBridge();
8
+ return {
9
+ bridge,
10
+ client: { destroy: () => {}, guilds: { cache: new Map() } },
11
+ settings: { allowedUsers: new Set(), autoJoinVoiceChannels: ['general'], agent: {}, tts: { edge: { voice: 'ko-KR-x' } } },
12
+ ROOT: '/tmp/vc-root',
13
+ log: () => {}, warn: () => {},
14
+ speakText: async () => {},
15
+ waitEvent: async () => {},
16
+ subscribeUser: () => {},
17
+ pendingFallbackNoticePromises: new Set(),
18
+ bindProjectSessionToChannel: () => ({ slug: 's', name: 'S', voiceChannelId: '', transcriptChannelId: '' }),
19
+ createProjectSession: () => ({ slug: 's', name: 'S', voiceChannelId: '', transcriptChannelId: '' }),
20
+ resolveProjectSessionForChannel: () => null,
21
+ saveProjectSessionsState: () => {},
22
+ projectSessionsState: { channelSessions: {} },
23
+ invalidateBackendAdaptersForSession: () => {},
24
+ VOICE_CONNECT_TIMEOUT_MS: 5000,
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ test('createDiscordVoiceSetup exposes the expected functions', () => {
30
+ const setup = createDiscordVoiceSetup(makeDeps());
31
+ for (const name of ['connectTo', 'autoJoin', 'findVoiceChannelBySelector', 'voiceChannelLabel', 'resolveVoiceChannelForAttach', 'attachVoiceChannelToTextSession', 'gracefulShutdown']) {
32
+ assert.equal(typeof setup[name], 'function', `${name} is exposed`);
33
+ }
34
+ });
35
+
36
+ test('voiceChannelLabel returns "없음" when no channel id is given', async () => {
37
+ const { voiceChannelLabel } = createDiscordVoiceSetup(makeDeps());
38
+ const guild = { channels: { fetch: async () => null } };
39
+ assert.equal(await voiceChannelLabel(guild, ''), '없음');
40
+ });
41
+
42
+ test('voiceChannelLabel returns "지정됨" when fetch throws', async () => {
43
+ const { voiceChannelLabel } = createDiscordVoiceSetup(makeDeps());
44
+ const guild = { channels: { fetch: async () => { throw new Error('boom'); } } };
45
+ assert.equal(await voiceChannelLabel(guild, 'ch-id'), '지정됨');
46
+ });
47
+
48
+ test('findVoiceChannelBySelector resolves by channel name', async () => {
49
+ const ch = { id: 'vc-1', name: 'General', isVoiceBased: () => true };
50
+ const guild = { channels: { fetch: async () => new Map([['vc-1', ch]]) } };
51
+ const { findVoiceChannelBySelector } = createDiscordVoiceSetup(makeDeps());
52
+ const out = await findVoiceChannelBySelector(guild, 'general');
53
+ assert.equal(out, ch);
54
+ });
55
+
56
+ test('findVoiceChannelBySelector throws on ambiguous name', async () => {
57
+ const a = { id: 'vc-1', name: 'general', isVoiceBased: () => true };
58
+ const b = { id: 'vc-2', name: 'general', isVoiceBased: () => true };
59
+ const guild = { channels: { fetch: async () => new Map([['vc-1', a], ['vc-2', b]]) } };
60
+ const { findVoiceChannelBySelector } = createDiscordVoiceSetup(makeDeps());
61
+ await assert.rejects(() => findVoiceChannelBySelector(guild, 'general'), /여러 개야/);
62
+ });
63
+
64
+ test('findVoiceChannelBySelector throws when no match', async () => {
65
+ const guild = { channels: { fetch: async () => new Map() } };
66
+ const { findVoiceChannelBySelector } = createDiscordVoiceSetup(makeDeps());
67
+ await assert.rejects(() => findVoiceChannelBySelector(guild, 'nope'), /찾지 못했어/);
68
+ });
69
+
70
+ test('resolveVoiceChannelForAttach falls back to msg.member voice channel', async () => {
71
+ const ch = { id: 'vc-1', isVoiceBased: () => true };
72
+ const { resolveVoiceChannelForAttach } = createDiscordVoiceSetup(makeDeps());
73
+ const msg = { member: { voice: { channel: ch } }, guild: null };
74
+ const out = await resolveVoiceChannelForAttach(msg, '');
75
+ assert.equal(out, ch);
76
+ });
77
+
78
+ test('autoJoin uses pickOccupiedUserVoiceChannel when one is returned', async () => {
79
+ // The real pickOccupiedUserVoiceChannel is module-imported; we can only stub the
80
+ // surrounding inputs. Verify autoJoin doesn't throw when no matching channels.
81
+ const deps = makeDeps();
82
+ const { autoJoin } = createDiscordVoiceSetup(deps);
83
+ await autoJoin();
84
+ // No channels available -> no connection, no throw.
85
+ assert.equal(deps.bridge.connection, null);
86
+ });
@@ -21,8 +21,15 @@ import path from 'node:path';
21
21
 
22
22
  import { hermesProfilesRoot, hermesProfileDir, profileExists } from './hermes_profiles.mjs';
23
23
 
24
+ const __tempRoots = [];
25
+ test.after(() => {
26
+ for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
27
+ });
28
+
24
29
  function tempHome() {
25
- return fs.mkdtempSync(path.join(os.tmpdir(), 'vc-hermes-home-'));
30
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-hermes-home-'));
31
+ __tempRoots.push(root);
32
+ return root;
26
33
  }
27
34
 
28
35
  test('hermesProfilesRoot resolves under HOME', () => {
@@ -208,6 +215,7 @@ import { applyProjectContextToSoul, VC_SOUL_MARKER_START, VC_SOUL_MARKER_END } f
208
215
 
209
216
  test('applyProjectContextToSoul appends a marker block to existing SOUL.md', () => {
210
217
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
218
+ __tempRoots.push(tmp);
211
219
  const soulPath = path.join(tmp, 'SOUL.md');
212
220
  const persona = 'You are Hermes Agent, an intelligent AI assistant.';
213
221
  fs.writeFileSync(soulPath, persona);
@@ -222,6 +230,7 @@ test('applyProjectContextToSoul appends a marker block to existing SOUL.md', ()
222
230
 
223
231
  test('applyProjectContextToSoul updates an existing marker block in place (idempotent)', () => {
224
232
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
233
+ __tempRoots.push(tmp);
225
234
  const soulPath = path.join(tmp, 'SOUL.md');
226
235
  fs.writeFileSync(soulPath, 'Persona text.');
227
236
  applyProjectContextToSoul(soulPath, 'first context');
@@ -237,6 +246,7 @@ test('applyProjectContextToSoul updates an existing marker block in place (idemp
237
246
 
238
247
  test('applyProjectContextToSoul writes a fresh SOUL.md when none exists', () => {
239
248
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
249
+ __tempRoots.push(tmp);
240
250
  const soulPath = path.join(tmp, 'SOUL.md');
241
251
  applyProjectContextToSoul(soulPath, 'fresh project context');
242
252
  const out = fs.readFileSync(soulPath, 'utf8');
@@ -247,6 +257,7 @@ test('applyProjectContextToSoul writes a fresh SOUL.md when none exists', () =>
247
257
 
248
258
  test('applyProjectContextToSoul is a no-op when projectContext is empty', () => {
249
259
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
260
+ __tempRoots.push(tmp);
250
261
  const soulPath = path.join(tmp, 'SOUL.md');
251
262
  fs.writeFileSync(soulPath, 'persona');
252
263
  applyProjectContextToSoul(soulPath, ' ');
@@ -1,4 +1,5 @@
1
1
  import { languagePreset, normalizeLanguageKey } from './language_config.mjs';
2
+ import { normalizeTtsBackendName, SUPPORTED_TTS_BACKENDS } from './tts_settings.mjs';
2
3
 
3
4
  export const SUPPORTED_HARNESSES = [
4
5
  'hermes',
@@ -21,6 +22,8 @@ function clean(value, fallback = '') {
21
22
  return v || fallback;
22
23
  }
23
24
 
25
+ export { SUPPORTED_TTS_BACKENDS };
26
+
24
27
  export function normalizeInstallAnswers(input = {}) {
25
28
  const harness = clean(input.harness || input.AGENT_BACKEND, 'hermes').toLowerCase();
26
29
  const normalizedHarness = SUPPORTED_HARNESSES.includes(harness) ? harness : 'custom';
@@ -33,9 +36,7 @@ export function normalizeInstallAnswers(input = {}) {
33
36
  DISCORD_ALLOWED_USERS: clean(input.allowedUsers || input.DISCORD_ALLOWED_USERS),
34
37
  AUTO_JOIN_VOICE_CHANNELS: clean(input.autoJoinVoiceChannels || input.AUTO_JOIN_VOICE_CHANNELS, '일반,General,general'),
35
38
  TRANSCRIPT_CHANNEL_ID: clean(input.transcriptChannelId || input.TRANSCRIPT_CHANNEL_ID),
36
- TTS_BACKEND: ['edge', 'openvoice', 'speechswift', 'supertonic'].includes(clean(input.ttsBackend || input.TTS_BACKEND, 'edge').toLowerCase())
37
- ? clean(input.ttsBackend || input.TTS_BACKEND, 'edge').toLowerCase()
38
- : 'edge',
39
+ TTS_BACKEND: normalizeTtsBackendName(input.ttsBackend || input.TTS_BACKEND, 'edge'),
39
40
  EDGE_TTS_COMMAND: clean(input.edgeTtsCommand || input.EDGE_TTS_COMMAND || input.TTS_EDGE_COMMAND, 'edge-tts'),
40
41
  VOICE_LANGUAGE: clean(input.voiceLanguage || input.VOICE_LANGUAGE, preset.voiceLanguage),
41
42
  WHISPER_CPP_LANGUAGE: clean(input.whisperLanguage || input.WHISPER_CPP_LANGUAGE || input.STT_LANGUAGE, preset.sttLanguage),
@@ -59,6 +60,59 @@ export function normalizeInstallAnswers(input = {}) {
59
60
  OPENVOICE_STYLE: clean(input.openvoiceStyle || input.OPENVOICE_STYLE, 'default'),
60
61
  OPENVOICE_TIMEOUT_MS: clean(input.openvoiceTimeoutMs || input.OPENVOICE_TIMEOUT_MS, '90000'),
61
62
  OPENVOICE_PROGRESS: input.openvoiceProgress === true || input.OPENVOICE_PROGRESS === '1' ? '1' : '0',
63
+ OMNIVOICE_PYTHON: clean(input.omnivoicePython || input.OMNIVOICE_PYTHON, './.venv-omnivoice/bin/python'),
64
+ OMNIVOICE_MODEL: clean(input.omnivoiceModel || input.OMNIVOICE_MODEL, 'k2-fsa/OmniVoice'),
65
+ OMNIVOICE_DEVICE: clean(input.omnivoiceDevice || input.OMNIVOICE_DEVICE, 'mps'),
66
+ OMNIVOICE_DTYPE: clean(input.omnivoiceDtype || input.OMNIVOICE_DTYPE, 'float16'),
67
+ OMNIVOICE_REF_AUDIO: clean(input.omnivoiceRefAudio || input.OMNIVOICE_REF_AUDIO || input.OPENVOICE_REF_AUDIO, './voice-samples/user-reference.wav'),
68
+ OMNIVOICE_REF_TEXT: clean(input.omnivoiceRefText || input.OMNIVOICE_REF_TEXT),
69
+ OMNIVOICE_LANGUAGE: clean(input.omnivoiceLanguage || input.OMNIVOICE_LANGUAGE, 'ko'),
70
+ OMNIVOICE_SPEAKER: clean(input.omnivoiceSpeaker || input.OMNIVOICE_SPEAKER),
71
+ OMNIVOICE_TIMEOUT_MS: clean(input.omnivoiceTimeoutMs || input.OMNIVOICE_TIMEOUT_MS, '180000'),
72
+ OMNIVOICE_PROGRESS: input.omnivoiceProgress === true || input.OMNIVOICE_PROGRESS === '1' ? '1' : '0',
73
+ QWEN3TTS_COMMAND: clean(input.qwen3TtsCommand || input.QWEN3TTS_COMMAND, 'audio'),
74
+ QWEN3TTS_MODE: clean(input.qwen3TtsMode || input.QWEN3TTS_MODE, 'custom'),
75
+ QWEN3TTS_MODEL: clean(input.qwen3TtsModel || input.QWEN3TTS_MODEL, 'customVoice'),
76
+ QWEN3TTS_LANGUAGE: clean(input.qwen3TtsLanguage || input.QWEN3TTS_LANGUAGE, 'korean'),
77
+ QWEN3TTS_SPEAKER: clean(input.qwen3TtsSpeaker || input.QWEN3TTS_SPEAKER, 'sohee'),
78
+ QWEN3TTS_PROGRESS: input.qwen3TtsProgress === true || input.QWEN3TTS_PROGRESS === '1' ? '1' : '0',
79
+ MLXAUDIO_PYTHON: clean(input.mlxAudioPython || input.MLXAUDIO_PYTHON, './.venv-mlxaudio/bin/python'),
80
+ MLXAUDIO_MODEL: clean(input.mlxAudioModel || input.MLXAUDIO_MODEL, 'mlx-community/Qwen3-TTS-12Hz-1.7B-Base-8bit'),
81
+ MLXAUDIO_VOICE: clean(input.mlxAudioVoice || input.MLXAUDIO_VOICE, 'Chelsie'),
82
+ MLXAUDIO_LANG_CODE: clean(input.mlxAudioLangCode || input.MLXAUDIO_LANG_CODE, 'ko'),
83
+ MLXAUDIO_TIMEOUT_MS: clean(input.mlxAudioTimeoutMs || input.MLXAUDIO_TIMEOUT_MS, '180000'),
84
+ MLXAUDIO_PROGRESS: input.mlxAudioProgress === true || input.MLXAUDIO_PROGRESS === '1' ? '1' : '0',
85
+ FIREREDTTS2_COMMAND: clean(input.fireRedTts2Command || input.FIREREDTTS2_COMMAND, './.local/bin/fireredtts2'),
86
+ FIREREDTTS2_PRETRAINED_DIR: clean(input.fireRedTts2PretrainedDir || input.FIREREDTTS2_PRETRAINED_DIR, 'pretrained_models/FireRedTTS2'),
87
+ FIREREDTTS2_DEVICE: clean(input.fireRedTts2Device || input.FIREREDTTS2_DEVICE, 'auto'),
88
+ FIREREDTTS2_GEN_TYPE: clean(input.fireRedTts2GenType || input.FIREREDTTS2_GEN_TYPE, 'monologue'),
89
+ FIREREDTTS2_SPEAKER: clean(input.fireRedTts2Speaker || input.FIREREDTTS2_SPEAKER, 'S1'),
90
+ FIREREDTTS2_PROMPT_AUDIO: clean(input.fireRedTts2PromptAudio || input.FIREREDTTS2_PROMPT_AUDIO, './voice-samples/user-reference.wav'),
91
+ FIREREDTTS2_PROMPT_TEXT: clean(input.fireRedTts2PromptText || input.FIREREDTTS2_PROMPT_TEXT),
92
+ FIREREDTTS2_BF16: input.fireRedTts2Bf16 === true || input.FIREREDTTS2_BF16 === '1' ? '1' : '0',
93
+ FIREREDTTS2_TIMEOUT_MS: clean(input.fireRedTts2TimeoutMs || input.FIREREDTTS2_TIMEOUT_MS, '180000'),
94
+ FIREREDTTS2_PROGRESS: input.fireRedTts2Progress === true || input.FIREREDTTS2_PROGRESS === '1' ? '1' : '0',
95
+ MOSSTTSNANO_COMMAND: clean(input.mossTtsNanoCommand || input.MOSSTTSNANO_COMMAND, './.venv-mossttsnano/bin/python'),
96
+ MOSSTTSNANO_SCRIPT: clean(input.mossTtsNanoScript || input.MOSSTTSNANO_SCRIPT, 'vendor/MOSS-TTS-Nano/infer.py'),
97
+ MOSSTTSNANO_CHECKPOINT: clean(input.mossTtsNanoCheckpoint || input.MOSSTTSNANO_CHECKPOINT, 'OpenMOSS-Team/MOSS-TTS-Nano'),
98
+ MOSSTTSNANO_MODE: clean(input.mossTtsNanoMode || input.MOSSTTSNANO_MODE, 'continuation'),
99
+ MOSSTTSNANO_DEVICE: clean(input.mossTtsNanoDevice || input.MOSSTTSNANO_DEVICE, 'auto'),
100
+ MOSSTTSNANO_DTYPE: clean(input.mossTtsNanoDtype || input.MOSSTTSNANO_DTYPE, 'auto'),
101
+ MOSSTTSNANO_PROMPT_AUDIO: clean(input.mossTtsNanoPromptAudio || input.MOSSTTSNANO_PROMPT_AUDIO, './voice-samples/user-reference.wav'),
102
+ MOSSTTSNANO_TIMEOUT_MS: clean(input.mossTtsNanoTimeoutMs || input.MOSSTTSNANO_TIMEOUT_MS, '120000'),
103
+ MOSSTTSNANO_PROGRESS: input.mossTtsNanoProgress === true || input.MOSSTTSNANO_PROGRESS === '1' ? '1' : '0',
104
+ NEUTTSAIR_PYTHON: clean(input.neuttsAirPython || input.NEUTTSAIR_PYTHON, './.venv-neuttsair/bin/python'),
105
+ NEUTTSAIR_SCRIPT: clean(input.neuttsAirScript || input.NEUTTSAIR_SCRIPT, 'integrations/neuttsair/synth.py'),
106
+ NEUTTSAIR_BACKBONE_REPO: clean(input.neuttsAirBackboneRepo || input.NEUTTSAIR_BACKBONE_REPO, 'neuphonic/neutts-air-q4-gguf'),
107
+ NEUTTSAIR_BACKBONE_DEVICE: clean(input.neuttsAirBackboneDevice || input.NEUTTSAIR_BACKBONE_DEVICE, 'mps'),
108
+ NEUTTSAIR_CODEC_REPO: clean(input.neuttsAirCodecRepo || input.NEUTTSAIR_CODEC_REPO, 'neuphonic/neucodec'),
109
+ NEUTTSAIR_CODEC_DEVICE: clean(input.neuttsAirCodecDevice || input.NEUTTSAIR_CODEC_DEVICE, 'mps'),
110
+ NEUTTSAIR_REF_AUDIO: clean(input.neuttsAirRefAudio || input.NEUTTSAIR_REF_AUDIO || input.OPENVOICE_REF_AUDIO, './voice-samples/user-reference.wav'),
111
+ NEUTTSAIR_REF_TEXT: clean(input.neuttsAirRefText || input.NEUTTSAIR_REF_TEXT),
112
+ NEUTTSAIR_LANGUAGE: clean(input.neuttsAirLanguage || input.NEUTTSAIR_LANGUAGE, 'en'),
113
+ NEUTTSAIR_SAMPLE_RATE: clean(input.neuttsAirSampleRate || input.NEUTTSAIR_SAMPLE_RATE, '24000'),
114
+ NEUTTSAIR_TIMEOUT_MS: clean(input.neuttsAirTimeoutMs || input.NEUTTSAIR_TIMEOUT_MS, '120000'),
115
+ NEUTTSAIR_PROGRESS: input.neuttsAirProgress === true || input.NEUTTSAIR_PROGRESS === '1' ? '1' : '0',
62
116
  REQUIRE_WAKE_WORD: input.requireWakeWord === true || input.REQUIRE_WAKE_WORD === '1' ? '1' : '0',
63
117
  MIN_UTTERANCE_SECONDS: clean(input.minUtteranceSeconds || input.MIN_UTTERANCE_SECONDS, '1.0'),
64
118
  UTTERANCE_IDLE_MS: clean(input.utteranceIdleMs || input.UTTERANCE_IDLE_MS, '4500'),
@@ -136,6 +190,59 @@ export function buildEnvFile(values = {}) {
136
190
  'OPENVOICE_STYLE',
137
191
  'OPENVOICE_TIMEOUT_MS',
138
192
  'OPENVOICE_PROGRESS',
193
+ 'OMNIVOICE_PYTHON',
194
+ 'OMNIVOICE_MODEL',
195
+ 'OMNIVOICE_DEVICE',
196
+ 'OMNIVOICE_DTYPE',
197
+ 'OMNIVOICE_REF_AUDIO',
198
+ 'OMNIVOICE_REF_TEXT',
199
+ 'OMNIVOICE_LANGUAGE',
200
+ 'OMNIVOICE_SPEAKER',
201
+ 'OMNIVOICE_TIMEOUT_MS',
202
+ 'OMNIVOICE_PROGRESS',
203
+ 'QWEN3TTS_COMMAND',
204
+ 'QWEN3TTS_MODE',
205
+ 'QWEN3TTS_MODEL',
206
+ 'QWEN3TTS_LANGUAGE',
207
+ 'QWEN3TTS_SPEAKER',
208
+ 'QWEN3TTS_PROGRESS',
209
+ 'MLXAUDIO_PYTHON',
210
+ 'MLXAUDIO_MODEL',
211
+ 'MLXAUDIO_VOICE',
212
+ 'MLXAUDIO_LANG_CODE',
213
+ 'MLXAUDIO_TIMEOUT_MS',
214
+ 'MLXAUDIO_PROGRESS',
215
+ 'FIREREDTTS2_COMMAND',
216
+ 'FIREREDTTS2_PRETRAINED_DIR',
217
+ 'FIREREDTTS2_DEVICE',
218
+ 'FIREREDTTS2_GEN_TYPE',
219
+ 'FIREREDTTS2_SPEAKER',
220
+ 'FIREREDTTS2_PROMPT_AUDIO',
221
+ 'FIREREDTTS2_PROMPT_TEXT',
222
+ 'FIREREDTTS2_BF16',
223
+ 'FIREREDTTS2_TIMEOUT_MS',
224
+ 'FIREREDTTS2_PROGRESS',
225
+ 'MOSSTTSNANO_COMMAND',
226
+ 'MOSSTTSNANO_SCRIPT',
227
+ 'MOSSTTSNANO_CHECKPOINT',
228
+ 'MOSSTTSNANO_MODE',
229
+ 'MOSSTTSNANO_DEVICE',
230
+ 'MOSSTTSNANO_DTYPE',
231
+ 'MOSSTTSNANO_PROMPT_AUDIO',
232
+ 'MOSSTTSNANO_TIMEOUT_MS',
233
+ 'MOSSTTSNANO_PROGRESS',
234
+ 'NEUTTSAIR_PYTHON',
235
+ 'NEUTTSAIR_SCRIPT',
236
+ 'NEUTTSAIR_BACKBONE_REPO',
237
+ 'NEUTTSAIR_BACKBONE_DEVICE',
238
+ 'NEUTTSAIR_CODEC_REPO',
239
+ 'NEUTTSAIR_CODEC_DEVICE',
240
+ 'NEUTTSAIR_REF_AUDIO',
241
+ 'NEUTTSAIR_REF_TEXT',
242
+ 'NEUTTSAIR_LANGUAGE',
243
+ 'NEUTTSAIR_SAMPLE_RATE',
244
+ 'NEUTTSAIR_TIMEOUT_MS',
245
+ 'NEUTTSAIR_PROGRESS',
139
246
  'REQUIRE_WAKE_WORD',
140
247
  'MIN_UTTERANCE_SECONDS',
141
248
  'UTTERANCE_IDLE_MS',
@@ -61,6 +61,10 @@ test('normalizeInstallAnswers maps supported harnesses to backend env', () => {
61
61
  assert.equal(answers.SUPERTONIC_COMMAND, 'supertonic');
62
62
  assert.equal(answers.SUPERTONIC_SPEED, '1.0');
63
63
  assert.equal(answers.SUPERTONIC_LANGUAGE, 'ko');
64
+ assert.equal(answers.OMNIVOICE_PYTHON, './.venv-omnivoice/bin/python');
65
+ assert.equal(answers.OMNIVOICE_MODEL, 'k2-fsa/OmniVoice');
66
+ assert.equal(answers.OMNIVOICE_REF_AUDIO, './voice-samples/user-reference.wav');
67
+ assert.equal(answers.OMNIVOICE_LANGUAGE, 'ko');
64
68
  assert.equal(answers.OPENVOICE_LANGUAGE, 'KR');
65
69
  assert.equal(answers.REQUIRE_WAKE_WORD, '0');
66
70
  assert.equal(answers.UTTERANCE_IDLE_MS, '4500');
@@ -87,6 +91,8 @@ test('buildEnvFile writes configurable CLI harness and Discord settings without
87
91
  TTS_VOLUME: '1.6',
88
92
  REQUIRE_WAKE_WORD: '0',
89
93
  OPENVOICE_REF_AUDIO: './voice-samples/me.wav',
94
+ OMNIVOICE_PYTHON: './.venv-omnivoice/bin/python',
95
+ OMNIVOICE_REF_AUDIO: './voice-samples/omni.wav',
90
96
  });
91
97
  const parsed = parseKeyValueEnv(envText);
92
98
 
@@ -102,6 +108,8 @@ test('buildEnvFile writes configurable CLI harness and Discord settings without
102
108
  assert.equal(parsed.SUPERTONIC_STEPS, '3');
103
109
  assert.equal(parsed.TTS_VOLUME, '1.6');
104
110
  assert.equal(parsed.OPENVOICE_REF_AUDIO, './voice-samples/me.wav');
111
+ assert.equal(parsed.OMNIVOICE_PYTHON, './.venv-omnivoice/bin/python');
112
+ assert.equal(parsed.OMNIVOICE_REF_AUDIO, './voice-samples/omni.wav');
105
113
  assert.equal(parsed.DISCORD_BOT_TOKEN, 'token-abc');
106
114
  assert.equal(parsed.REQUIRE_WAKE_WORD, '0');
107
115
  });
@@ -6,9 +6,15 @@ import path from 'node:path';
6
6
 
7
7
  import { checkInstanceConfigs, tokenFingerprint } from './instance_doctor.mjs';
8
8
 
9
+ const __tempRoots = [];
10
+ test.after(() => {
11
+ for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
12
+ });
13
+
9
14
  function tempRepo() {
10
15
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-instance-doctor-'));
11
16
  fs.mkdirSync(path.join(root, 'instances'), { recursive: true });
17
+ __tempRoots.push(root);
12
18
  return root;
13
19
  }
14
20
 
@@ -68,6 +74,7 @@ test('checkInstanceConfigs treats omitted runtime paths as effective default col
68
74
 
69
75
  test('checkInstanceConfigs warns when HERMES_HOME points at a missing profile', () => {
70
76
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-doctor-'));
77
+ __tempRoots.push(root);
71
78
  const instancesDir = path.join(root, 'instances');
72
79
  fs.mkdirSync(instancesDir, { recursive: true });
73
80
  fs.writeFileSync(path.join(instancesDir, 'llm-wiki.env'), [
@@ -84,6 +91,7 @@ test('checkInstanceConfigs warns when HERMES_HOME points at a missing profile',
84
91
 
85
92
  test('checkInstanceConfigs errors when profile terminal.cwd differs from AGENT_CWD', () => {
86
93
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-doctor-'));
94
+ __tempRoots.push(root);
87
95
  const instancesDir = path.join(root, 'instances');
88
96
  const profileDir = path.join(root, '.hermes', 'profiles', 'llm-wiki');
89
97
  fs.mkdirSync(instancesDir, { recursive: true });
@@ -106,6 +114,7 @@ test('checkInstanceConfigs errors when profile terminal.cwd differs from AGENT_C
106
114
 
107
115
  test('checkInstanceConfigs reads only terminal.cwd, ignoring sibling cwd keys', () => {
108
116
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-doctor-'));
117
+ __tempRoots.push(root);
109
118
  const instancesDir = path.join(root, 'instances');
110
119
  const profileDir = path.join(root, '.hermes', 'profiles', 'llm-wiki');
111
120
  fs.mkdirSync(instancesDir, { recursive: true });
@@ -14,8 +14,15 @@ import {
14
14
  statusForInstance,
15
15
  } from './instances.mjs';
16
16
 
17
+ const __tempRoots = [];
18
+ test.after(() => {
19
+ for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
20
+ });
21
+
17
22
  function tempDir() {
18
- return fs.mkdtempSync(path.join(os.tmpdir(), 'vc-instances-'));
23
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-instances-'));
24
+ __tempRoots.push(root);
25
+ return root;
19
26
  }
20
27
 
21
28
  test('listInstanceEnvFiles finds env files except example', () => {