verbalcoding 0.2.0

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 (85) hide show
  1. package/.env.example +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +157 -0
  4. package/app-node/agent_adapters.mjs +576 -0
  5. package/app-node/agent_adapters.test.mjs +455 -0
  6. package/app-node/agent_contract.mjs +45 -0
  7. package/app-node/barge_in.mjs +148 -0
  8. package/app-node/barge_in.test.mjs +179 -0
  9. package/app-node/bridge_logger.mjs +66 -0
  10. package/app-node/bridge_logger.test.mjs +73 -0
  11. package/app-node/bridge_state.mjs +104 -0
  12. package/app-node/bridge_state.test.mjs +64 -0
  13. package/app-node/cli_install.test.mjs +97 -0
  14. package/app-node/deferred_queue.mjs +12 -0
  15. package/app-node/deferred_queue.test.mjs +20 -0
  16. package/app-node/discord_invite_cli.test.mjs +31 -0
  17. package/app-node/discord_text.mjs +29 -0
  18. package/app-node/discord_text.test.mjs +32 -0
  19. package/app-node/hermes_profiles.mjs +164 -0
  20. package/app-node/hermes_profiles.test.mjs +276 -0
  21. package/app-node/install_config.mjs +263 -0
  22. package/app-node/install_config.test.mjs +205 -0
  23. package/app-node/instance_doctor.mjs +137 -0
  24. package/app-node/instance_doctor.test.mjs +128 -0
  25. package/app-node/instance_profile_lifecycle.mjs +16 -0
  26. package/app-node/instances.mjs +153 -0
  27. package/app-node/instances.test.mjs +102 -0
  28. package/app-node/language_config.mjs +73 -0
  29. package/app-node/language_config.test.mjs +51 -0
  30. package/app-node/latency_metrics.mjs +133 -0
  31. package/app-node/latency_metrics.test.mjs +71 -0
  32. package/app-node/main.mjs +1771 -0
  33. package/app-node/mcp_tools.mjs +198 -0
  34. package/app-node/mcp_tools.test.mjs +39 -0
  35. package/app-node/progress_cache.mjs +7 -0
  36. package/app-node/progress_cache.test.mjs +23 -0
  37. package/app-node/progress_speech.mjs +102 -0
  38. package/app-node/progress_speech.test.mjs +48 -0
  39. package/app-node/project_sessions.mjs +148 -0
  40. package/app-node/project_sessions.test.mjs +77 -0
  41. package/app-node/restart_notice.mjs +57 -0
  42. package/app-node/restart_notice.test.mjs +37 -0
  43. package/app-node/restart_policy.mjs +27 -0
  44. package/app-node/restart_policy.test.mjs +33 -0
  45. package/app-node/text_routing.mjs +8 -0
  46. package/app-node/text_routing.test.mjs +18 -0
  47. package/app-node/tts_backends.mjs +251 -0
  48. package/app-node/tts_backends.test.mjs +400 -0
  49. package/app-node/tts_chunks.mjs +57 -0
  50. package/app-node/tts_chunks.test.mjs +35 -0
  51. package/app-node/tts_prefetch.mjs +38 -0
  52. package/app-node/tts_prefetch.test.mjs +49 -0
  53. package/app-node/tts_settings.mjs +72 -0
  54. package/app-node/tts_settings.test.mjs +127 -0
  55. package/app-node/tts_voice_config.mjs +127 -0
  56. package/app-node/tts_voice_config.test.mjs +64 -0
  57. package/app-node/voice_clone_capture.mjs +76 -0
  58. package/app-node/voice_clone_capture.test.mjs +51 -0
  59. package/app-node/voice_messages.mjs +62 -0
  60. package/app-node/voice_messages.test.mjs +33 -0
  61. package/docs/CONFIGURATION.md +183 -0
  62. package/docs/FRESH_INSTALL.md +193 -0
  63. package/docs/MULTI_INSTANCE.md +183 -0
  64. package/docs/RELEASE.md +72 -0
  65. package/docs/USAGE.md +108 -0
  66. package/docs/assets/figures/verbalcoding-flow.svg +63 -0
  67. package/docs/i18n/README.es.md +121 -0
  68. package/docs/i18n/README.fr.md +121 -0
  69. package/docs/i18n/README.ja.md +121 -0
  70. package/docs/i18n/README.ko.md +121 -0
  71. package/docs/i18n/README.ru.md +121 -0
  72. package/docs/i18n/README.zh.md +121 -0
  73. package/package.json +58 -0
  74. package/run.sh +82 -0
  75. package/scripts/bootstrap_prereqs.sh +193 -0
  76. package/scripts/cli.mjs +369 -0
  77. package/scripts/docker_ubuntu_smoke.sh +76 -0
  78. package/scripts/doctor.mjs +134 -0
  79. package/scripts/install.mjs +108 -0
  80. package/scripts/install.sh +44 -0
  81. package/scripts/mcp-server.mjs +84 -0
  82. package/scripts/openvoice_smoke.py +34 -0
  83. package/scripts/openvoice_synth.py +103 -0
  84. package/scripts/setup_openvoice.sh +34 -0
  85. package/scripts/setup_supertonic.sh +18 -0
@@ -0,0 +1,369 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { applyLanguagePreset, languageStatus, normalizeLanguageKey } from '../app-node/language_config.mjs';
7
+ import {
8
+ buildInstanceEnvFile,
9
+ buildDiscordBotInviteUrl,
10
+ normalizeInstanceAnswers,
11
+ parseKeyValueEnv,
12
+ renderInstanceSetupSummary,
13
+ } from '../app-node/install_config.mjs';
14
+ import { ensureHermesProfile, validateProfileName } from '../app-node/hermes_profiles.mjs';
15
+ import { checkInstanceConfigs } from '../app-node/instance_doctor.mjs';
16
+ import { healInstanceProfileFromEnv } from '../app-node/instance_profile_lifecycle.mjs';
17
+ import {
18
+ listInstanceStatuses,
19
+ resolveInstanceEnvPath,
20
+ startInstance,
21
+ statusForInstance,
22
+ stopInstance,
23
+ } from '../app-node/instances.mjs';
24
+ import { AUTO_RESTART_ENV_KEY, autoRestartStatusText, normalizeAutoRestartCommand } from '../app-node/restart_policy.mjs';
25
+
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const ENV_PATH = path.join(ROOT, '.env');
28
+
29
+ function usage() {
30
+ return `VerbalCoding CLI
31
+
32
+ Usage:
33
+ vc setup [--yes] [--no-wizard] [--skip-system] [--skip-model] [--skip-edge-tts]
34
+ vc start
35
+ vc status
36
+ vc language <ko|en|auto>
37
+ vc language status
38
+ vc restart auto <on|off|status>
39
+ vc bot invite <client-id> [--guild <guild-id>]
40
+ vc instance list
41
+ vc instance setup [name] [--start]
42
+ vc instance status [name]
43
+ vc instance start <name>
44
+ vc instance stop <name>
45
+ vc instance restart <name>
46
+ vc doctor
47
+
48
+ Examples:
49
+ npx verbalcoding setup --yes
50
+ vc setup --yes
51
+ vc start
52
+ vc language en
53
+ vc language ko
54
+ vc language auto
55
+ vc restart auto off
56
+ vc bot invite 123456789012345678
57
+ `;
58
+ }
59
+
60
+ function readEnvFile(file = ENV_PATH) {
61
+ if (!fs.existsSync(file)) return {};
62
+ return parseKeyValueEnv(fs.readFileSync(file, 'utf8'));
63
+ }
64
+
65
+ function quoteEnv(value) {
66
+ return JSON.stringify(String(value ?? ''));
67
+ }
68
+
69
+ function upsertEnvFile(file, updates) {
70
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
71
+ const seen = new Set();
72
+ const lines = existing.split(/\r?\n/).map(raw => {
73
+ const line = raw.trim();
74
+ if (!line || line.startsWith('#') || !line.includes('=')) return raw;
75
+ const idx = line.indexOf('=');
76
+ const key = line.slice(0, idx).trim().replace(/^export\s+/, '');
77
+ if (!(key in updates)) return raw;
78
+ seen.add(key);
79
+ return `${key}=${quoteEnv(updates[key])}`;
80
+ });
81
+ for (const [key, value] of Object.entries(updates)) {
82
+ if (!seen.has(key)) lines.push(`${key}=${quoteEnv(value)}`);
83
+ }
84
+ const text = `${lines.filter((line, index, arr) => line !== '' || index < arr.length - 1).join('\n')}\n`;
85
+ fs.writeFileSync(file, text, { mode: 0o600 });
86
+ }
87
+
88
+ function printLanguageStatus(values) {
89
+ const s = languageStatus(values);
90
+ console.log(`STT language: ${s.sttLanguage}`);
91
+ console.log(`Progress/voice language: ${s.voiceLanguage}`);
92
+ console.log(`TTS voice: ${s.ttsVoice}`);
93
+ }
94
+
95
+ function printInstanceStatus(statuses) {
96
+ if (statuses.length === 0) {
97
+ console.log('No instance env files found in instances/*.env');
98
+ return;
99
+ }
100
+ for (const status of statuses) {
101
+ const pid = status.pid ?? '-';
102
+ console.log(`${status.name.padEnd(16)} ${status.status.padEnd(8)} pid=${pid} log=${status.logPath}`);
103
+ }
104
+ }
105
+
106
+ function assertInstanceStartIsSafe() {
107
+ const result = checkInstanceConfigs(ROOT);
108
+ if (result.errors.length > 0) {
109
+ throw new Error(`Refusing to start instance because instance configuration checks failed: ${result.errors.join('; ')}`);
110
+ }
111
+ }
112
+
113
+ async function askQuestion(rl, question, fallback = '', options = {}) {
114
+ const suffixValue = options.fallbackLabel ?? fallback;
115
+ const suffix = suffixValue ? ` [${suffixValue}]` : '';
116
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
117
+ return answer || fallback;
118
+ }
119
+
120
+ async function setupInstance(argv) {
121
+ const { default: readline } = await import('node:readline/promises');
122
+ const { stdin: input, stdout: output } = await import('node:process');
123
+ const startAfter = argv.includes('--start');
124
+ const nonFlagArgs = argv.slice(2).filter(arg => !arg.startsWith('--'));
125
+ const shared = readEnvFile();
126
+ const initialName = nonFlagArgs[0] || '';
127
+ const rl = readline.createInterface({ input, output });
128
+ try {
129
+ console.log('VerbalCoding instance setup');
130
+ console.log('This creates or updates instances/<name>.env; no manual editing is required.');
131
+ const instanceName = await askQuestion(rl, 'Instance name', initialName || 'llm-wiki');
132
+ const preview = normalizeInstanceAnswers({ instanceName });
133
+ const instancePath = path.join(ROOT, 'instances', `${preview.INSTANCE_NAME}.env`);
134
+ const existing = readEnvFile(instancePath);
135
+ const defaults = { ...shared, ...existing };
136
+ const existingToken = defaults.DISCORD_TOKEN || defaults.DISCORD_BOT_TOKEN || '';
137
+ const values = normalizeInstanceAnswers({
138
+ instanceName,
139
+ discordBotToken: await askQuestion(rl, 'Discord bot token for this bot', existingToken, { fallbackLabel: existingToken ? 'keep existing' : '' }),
140
+ discordClientId: await askQuestion(rl, 'Discord application/client ID for invite URL', defaults.DISCORD_CLIENT_ID || ''),
141
+ allowedUsers: await askQuestion(rl, 'Allowed Discord user IDs, comma-separated', defaults.DISCORD_ALLOWED_USERS || shared.DISCORD_ALLOWED_USERS || ''),
142
+ autoJoinVoiceChannels: await askQuestion(rl, 'Voice channel for this instance', defaults.AUTO_JOIN_VOICE_CHANNELS || instanceName),
143
+ transcriptChannelId: await askQuestion(rl, 'Transcript text channel/thread ID', defaults.TRANSCRIPT_CHANNEL_ID || ''),
144
+ workdir: await askQuestion(rl, 'Project working directory', defaults.AGENT_CWD || defaults.AGENT_WORKDIR || shared.AGENT_CWD || shared.AGENT_WORKDIR || ROOT),
145
+ projectContext: await askQuestion(rl, 'Project context prompt', defaults.AGENT_PROJECT_CONTEXT || `Project session: ${instanceName}`),
146
+ projectSessionsFile: await askQuestion(rl, 'Project sessions file', defaults.PROJECT_SESSIONS_FILE || `config/project-sessions.${preview.INSTANCE_NAME}.json`),
147
+ bridgeLogPath: await askQuestion(rl, 'Bridge log path', defaults.BRIDGE_LOG_PATH || `/tmp/verbalcoding-${preview.INSTANCE_NAME}.log`),
148
+ nodeAudioDebugDir: await askQuestion(rl, 'Audio debug directory', defaults.NODE_AUDIO_DEBUG_DIR || `/tmp/verbalcoding-${preview.INSTANCE_NAME}-debug`),
149
+ hermesSessionFile: await askQuestion(rl, 'Hermes session file', defaults.HERMES_SESSION_FILE || `.agent-sessions/hermes/${preview.INSTANCE_NAME}.session`),
150
+ agentLabel: await askQuestion(rl, 'Agent label', defaults.AGENT_LABEL || `Hermes Agent · ${instanceName}`),
151
+ });
152
+ try {
153
+ validateProfileName(values.INSTANCE_NAME);
154
+ } catch (err) {
155
+ console.error(`Instance name ${JSON.stringify(values.INSTANCE_NAME)} cannot be used as a Hermes profile name.`);
156
+ console.error('Pick a name matching ^[a-z0-9][a-z0-9_-]{0,63}$ (e.g. llm-wiki, acme).');
157
+ process.exitCode = 2;
158
+ return;
159
+ }
160
+
161
+ let profileResult;
162
+ try {
163
+ profileResult = await ensureHermesProfile({
164
+ name: values.INSTANCE_NAME,
165
+ workdir: values.AGENT_CWD || ROOT,
166
+ projectContext: values.AGENT_PROJECT_CONTEXT || '',
167
+ });
168
+ } catch (err) {
169
+ console.error(`Hermes profile setup failed: ${err.message}`);
170
+ process.exitCode = 2;
171
+ return;
172
+ }
173
+ values.HERMES_HOME = profileResult.dir;
174
+ for (const w of profileResult.warnings || []) console.warn(`warning: ${w}`);
175
+ console.log(`Hermes profile: ${profileResult.name} at ${profileResult.dir} (${profileResult.created ? 'created' : 'reused'})`);
176
+ fs.mkdirSync(path.dirname(instancePath), { recursive: true });
177
+ if (fs.existsSync(instancePath)) {
178
+ const backup = `${instancePath}.bak-${Date.now()}`;
179
+ fs.copyFileSync(instancePath, backup);
180
+ console.log(`Backed up existing instance env to ${backup}`);
181
+ }
182
+ fs.writeFileSync(instancePath, buildInstanceEnvFile(values), { mode: 0o600 });
183
+ console.log(`Wrote ${instancePath}`);
184
+ console.log(renderInstanceSetupSummary(values));
185
+ if (startAfter) {
186
+ assertInstanceStartIsSafe();
187
+ const status = startInstance(ROOT, values.INSTANCE_NAME);
188
+ console.log(`Started ${status.name} pid=${status.pid}`);
189
+ console.log(`Log: ${status.logPath}`);
190
+ }
191
+ } finally {
192
+ rl.close();
193
+ }
194
+ }
195
+
196
+ function handleBotCommand(argv) {
197
+ const action = argv[1] || 'invite';
198
+ if (action !== 'invite') {
199
+ console.error(`Unknown bot command: ${action}`);
200
+ console.error('Use: vc bot invite <client-id> [--guild <guild-id>]');
201
+ process.exitCode = 2;
202
+ return;
203
+ }
204
+ const clientId = argv[2];
205
+ const guildIndex = argv.indexOf('--guild');
206
+ const guildId = guildIndex >= 0 ? argv[guildIndex + 1] : '';
207
+ if (!clientId || clientId.startsWith('--')) {
208
+ console.error('Use: vc bot invite <client-id> [--guild <guild-id>]');
209
+ process.exitCode = 2;
210
+ return;
211
+ }
212
+ console.log('Discord bot invite URL:');
213
+ console.log(buildDiscordBotInviteUrl({ clientId, guildId }));
214
+ }
215
+
216
+ async function handleInstanceCommand(argv) {
217
+ const action = argv[1] || 'status';
218
+ const name = argv[2];
219
+ if (action === 'list' || (action === 'status' && !name)) {
220
+ printInstanceStatus(listInstanceStatuses(ROOT));
221
+ return;
222
+ }
223
+ if (action === 'setup' || action === 'configure') {
224
+ await setupInstance(argv);
225
+ return;
226
+ }
227
+ if (action === 'status') {
228
+ const envPath = resolveInstanceEnvPath(ROOT, name);
229
+ printInstanceStatus([statusForInstance(ROOT, envPath)]);
230
+ return;
231
+ }
232
+ if (action === 'start') {
233
+ if (!name) throw new Error('Use: verbalcoding instance start <name>');
234
+ const envPath = resolveInstanceEnvPath(ROOT, name);
235
+ const instanceEnv = fs.existsSync(envPath) ? parseKeyValueEnv(fs.readFileSync(envPath, 'utf8')) : {};
236
+ try {
237
+ await healInstanceProfileFromEnv(name, instanceEnv);
238
+ } catch (err) {
239
+ console.error(`Hermes profile self-heal failed: ${err.message}`);
240
+ process.exitCode = 2;
241
+ return;
242
+ }
243
+ assertInstanceStartIsSafe();
244
+ const status = startInstance(ROOT, name);
245
+ console.log(`Started ${status.name} pid=${status.pid}`);
246
+ console.log(`Log: ${status.logPath}`);
247
+ return;
248
+ }
249
+ if (action === 'stop') {
250
+ if (!name) throw new Error('Use: verbalcoding instance stop <name>');
251
+ const result = await stopInstance(ROOT, name);
252
+ const suffix = result.alreadyStopped ? 'already stopped' : (result.killed ? 'stopped with SIGKILL fallback' : 'stopped');
253
+ console.log(`${result.name} ${suffix}`);
254
+ return;
255
+ }
256
+ if (action === 'restart') {
257
+ if (!name) throw new Error('Use: verbalcoding instance restart <name>');
258
+ assertInstanceStartIsSafe();
259
+ await stopInstance(ROOT, name);
260
+ const status = startInstance(ROOT, name);
261
+ console.log(`Restarted ${status.name} pid=${status.pid}`);
262
+ console.log(`Log: ${status.logPath}`);
263
+ return;
264
+ }
265
+ console.error(`Unknown instance command: ${action}`);
266
+ console.error('Use: verbalcoding instance <list|setup|status|start|stop|restart> [name]');
267
+ process.exitCode = 2;
268
+ }
269
+
270
+ async function main(argv = process.argv.slice(2)) {
271
+ const [command, subcommand] = argv;
272
+ if (!command || ['help', '-h', '--help'].includes(command)) {
273
+ console.log(usage());
274
+ return;
275
+ }
276
+ if (command === 'setup' || command === 'install') {
277
+ const { spawnSync } = await import('node:child_process');
278
+ const script = path.join(ROOT, 'scripts', 'install.sh');
279
+ const result = spawnSync('bash', [script, ...argv.slice(1)], {
280
+ stdio: 'inherit',
281
+ cwd: ROOT,
282
+ env: { ...process.env, VERBALCODING_SKIP_CLI_LINK: process.env.VERBALCODING_SKIP_CLI_LINK || '1' },
283
+ });
284
+ process.exitCode = result.status ?? 1;
285
+ return;
286
+ }
287
+ if (command === 'start' || command === 'run') {
288
+ const { spawnSync } = await import('node:child_process');
289
+ const result = spawnSync('bash', [path.join(ROOT, 'run.sh'), ...argv.slice(1)], { stdio: 'inherit', cwd: ROOT });
290
+ process.exitCode = result.status ?? 1;
291
+ return;
292
+ }
293
+ if (command === 'doctor') {
294
+ const { spawnSync } = await import('node:child_process');
295
+ const result = spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'doctor.mjs')], { stdio: 'inherit', cwd: ROOT });
296
+ process.exitCode = result.status ?? 1;
297
+ return;
298
+ }
299
+ if (command === 'status') {
300
+ printLanguageStatus(readEnvFile());
301
+ console.log(autoRestartStatusText(readEnvFile()));
302
+ return;
303
+ }
304
+ if (command === 'instance') {
305
+ await handleInstanceCommand(argv);
306
+ return;
307
+ }
308
+ if (command === 'bot') {
309
+ handleBotCommand(argv);
310
+ return;
311
+ }
312
+ if (command === 'restart') {
313
+ if (subcommand !== 'auto') {
314
+ console.error('Use: verbalcoding restart auto <on|off|status>');
315
+ process.exitCode = 2;
316
+ return;
317
+ }
318
+ const value = argv[2] || 'status';
319
+ if (value === 'status') {
320
+ console.log(autoRestartStatusText(readEnvFile()));
321
+ return;
322
+ }
323
+ const normalized = normalizeAutoRestartCommand(value);
324
+ if (normalized === null) {
325
+ console.error(`Unknown auto restart setting: ${value}`);
326
+ console.error('Use on, off, or status.');
327
+ process.exitCode = 2;
328
+ return;
329
+ }
330
+ upsertEnvFile(ENV_PATH, { [AUTO_RESTART_ENV_KEY]: normalized });
331
+ console.log(`Updated ${ENV_PATH}`);
332
+ console.log(autoRestartStatusText({ [AUTO_RESTART_ENV_KEY]: normalized }));
333
+ return;
334
+ }
335
+ if (command === 'language') {
336
+ if (!subcommand || subcommand === 'status') {
337
+ printLanguageStatus(readEnvFile());
338
+ return;
339
+ }
340
+ const key = normalizeLanguageKey(subcommand, '');
341
+ if (!key) {
342
+ console.error(`Unknown language: ${subcommand}`);
343
+ console.error('Use ko, en, or auto.');
344
+ process.exitCode = 2;
345
+ return;
346
+ }
347
+ const current = readEnvFile();
348
+ const next = applyLanguagePreset(current, key);
349
+ upsertEnvFile(ENV_PATH, {
350
+ VOICE_LANGUAGE: next.VOICE_LANGUAGE,
351
+ WHISPER_CPP_LANGUAGE: next.WHISPER_CPP_LANGUAGE,
352
+ STT_LANGUAGE: next.STT_LANGUAGE,
353
+ TTS_BACKEND: next.TTS_BACKEND,
354
+ TTS_VOICE: next.TTS_VOICE,
355
+ });
356
+ console.log(`Updated ${ENV_PATH}`);
357
+ printLanguageStatus(next);
358
+ console.log('Restart the bridge for this to take effect.');
359
+ return;
360
+ }
361
+ console.error(`Unknown command: ${command}`);
362
+ console.error(usage());
363
+ process.exitCode = 2;
364
+ }
365
+
366
+ main().catch(err => {
367
+ console.error(err?.stack || err);
368
+ process.exit(1);
369
+ });
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5
+ IMAGE="${VERBALCODING_UBUNTU_IMAGE:-ubuntu:24.04}"
6
+ WORK="$(mktemp -d)"
7
+ KEEP_WORK="${VERBALCODING_KEEP_SMOKE_WORK:-0}"
8
+
9
+ cleanup() {
10
+ if [ "$KEEP_WORK" != "1" ]; then rm -rf "$WORK"; fi
11
+ }
12
+ trap cleanup EXIT
13
+
14
+ echo "==> Preparing clean tracked-tree copy at $WORK"
15
+ git -C "$ROOT" archive --format=tar HEAD | tar -x -C "$WORK"
16
+
17
+ # Include this script when it is being tested before commit.
18
+ if [ -f "$ROOT/scripts/docker_ubuntu_smoke.sh" ] && [ ! -f "$WORK/scripts/docker_ubuntu_smoke.sh" ]; then
19
+ cp "$ROOT/scripts/docker_ubuntu_smoke.sh" "$WORK/scripts/docker_ubuntu_smoke.sh"
20
+ chmod +x "$WORK/scripts/docker_ubuntu_smoke.sh"
21
+ fi
22
+
23
+ echo "==> Running Ubuntu smoke test in $IMAGE"
24
+ docker run --rm \
25
+ -e DEBIAN_FRONTEND=noninteractive \
26
+ -e VERBALCODING_SKIP_CLI_LINK=0 \
27
+ -v "$WORK:/work" \
28
+ -w /work \
29
+ "$IMAGE" \
30
+ bash -lc '
31
+ set -euo pipefail
32
+ echo "==> Container OS"
33
+ cat /etc/os-release | sed -n "1,6p"
34
+
35
+ echo "==> Bootstrap install without interactive wizard"
36
+ ./scripts/install.sh --yes --no-wizard
37
+
38
+ echo "==> Create non-secret smoke .env"
39
+ cat > .env <<"ENV"
40
+ DISCORD_BOT_TOKEN="smoke-test-token"
41
+ DISCORD_ALLOWED_USERS=""
42
+ AUTO_JOIN_VOICE_CHANNELS="General"
43
+ TRANSCRIPT_CHANNEL_ID="123456789012345678"
44
+ AGENT_BACKEND="custom"
45
+ AGENT_LABEL="Smoke Agent"
46
+ AGENT_COMMAND="/bin/true"
47
+ VOICE_LANGUAGE="en"
48
+ WHISPER_CPP_LANGUAGE="en"
49
+ STT_LANGUAGE="en"
50
+ TTS_BACKEND="edge"
51
+ EDGE_TTS_COMMAND="./.venv-tts/bin/edge-tts"
52
+ TTS_VOICE="en-US-GuyNeural"
53
+ TTS_RATE="+0%"
54
+ TTS_VOLUME="1.0"
55
+ REQUIRE_WAKE_WORD="0"
56
+ UTTERANCE_IDLE_MS="2000"
57
+ LATENCY_LOG_PATH="./.logs/latency.jsonl"
58
+ ENV
59
+ chmod 600 .env
60
+
61
+ echo "==> Verify shell CLI"
62
+ command -v vc
63
+ vc status
64
+
65
+ echo "==> Verify syntax and tests"
66
+ bash -n run.sh scripts/install.sh scripts/bootstrap_prereqs.sh scripts/docker_ubuntu_smoke.sh
67
+ node --check app-node/main.mjs
68
+ node --check scripts/install.mjs
69
+ node --check scripts/cli.mjs
70
+ npm test
71
+
72
+ echo "==> Verify doctor passes with smoke env"
73
+ vc doctor
74
+
75
+ echo "==> Ubuntu smoke test passed"
76
+ '
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { parseKeyValueEnv } from '../app-node/install_config.mjs';
6
+ import { checkInstanceConfigs, formatInstanceDoctor } from '../app-node/instance_doctor.mjs';
7
+ import { autoRestartVoiceBotEnabled } from '../app-node/restart_policy.mjs';
8
+
9
+ const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
10
+
11
+ function readEnvFile(file) {
12
+ try {
13
+ return parseKeyValueEnv(fs.readFileSync(file, 'utf8'));
14
+ } catch {
15
+ return {};
16
+ }
17
+ }
18
+
19
+ function mergeEnv() {
20
+ // Project .env intentionally wins over ~/.zshrc so local setup is reproducible.
21
+ return {
22
+ ...process.env,
23
+ ...readEnvFile(path.join(process.env.HOME || '', '.zshrc')),
24
+ ...readEnvFile(path.join(ROOT, '.env')),
25
+ };
26
+ }
27
+
28
+ function commandExists(command) {
29
+ const result = spawnSync('bash', ['-lc', `command -v ${JSON.stringify(command)}`], {
30
+ cwd: ROOT,
31
+ encoding: 'utf8',
32
+ });
33
+ return result.status === 0 ? result.stdout.trim() : '';
34
+ }
35
+
36
+ function check(label, ok, detail = '') {
37
+ const mark = ok ? '✓' : '✗';
38
+ console.log(`${mark} ${label}${detail ? ` — ${detail}` : ''}`);
39
+ return Boolean(ok);
40
+ }
41
+
42
+ function note(label, detail = '') {
43
+ console.log(`• ${label}${detail ? ` — ${detail}` : ''}`);
44
+ }
45
+
46
+ const env = mergeEnv();
47
+ const backend = (env.AGENT_BACKEND || 'hermes').toLowerCase();
48
+ const ttsBackend = (env.TTS_BACKEND || 'edge').toLowerCase();
49
+ let ok = true;
50
+
51
+ console.log('VerbalCoding doctor');
52
+ console.log(`Project: ${ROOT}`);
53
+ console.log(`Backend: ${backend}`);
54
+ console.log(`TTS backend: ${ttsBackend}`);
55
+ console.log('');
56
+
57
+ ok = check('Node.js', commandExists('node'), commandExists('node') || 'missing') && ok;
58
+ ok = check('npm', commandExists('npm'), commandExists('npm') || 'missing') && ok;
59
+ ok = check('ffmpeg', commandExists('ffmpeg'), commandExists('ffmpeg') || 'missing') && ok;
60
+ ok = check('whisper-cli', commandExists(env.WHISPER_CPP_BIN || 'whisper-cli'), commandExists(env.WHISPER_CPP_BIN || 'whisper-cli') || 'missing') && ok;
61
+
62
+ const modelPath = path.resolve(ROOT, env.WHISPER_CPP_MODEL || 'models/ggml-small-q5_1.bin');
63
+ ok = check('whisper.cpp model', fs.existsSync(modelPath), path.relative(ROOT, modelPath)) && ok;
64
+ ok = check('Discord bot token configured', Boolean(env.DISCORD_BOT_TOKEN || env.DISCORD_TOKEN), (env.DISCORD_BOT_TOKEN || env.DISCORD_TOKEN) ? '[REDACTED]' : 'missing DISCORD_BOT_TOKEN') && ok;
65
+ note('Allowed users configured', env.DISCORD_ALLOWED_USERS ? '[REDACTED]' : 'not set; bot may accept all users depending on config');
66
+ note('Auto-join channels', env.AUTO_JOIN_VOICE_CHANNELS || 'default: 일반,General,general');
67
+ note('Verbose progress default', ['1', 'true', 'yes', 'on'].includes(String(env.AGENT_VERBOSE_PROGRESS || env.VERBALCODING_VERBOSE_PROGRESS || '0').toLowerCase()) ? 'on' : 'off');
68
+ note('Auto restart voice bot after commits', autoRestartVoiceBotEnabled(env) ? 'on' : 'off');
69
+ note('Utterance idle wait before STT', `${env.UTTERANCE_IDLE_MS || '2000'} ms`);
70
+ note('STT language', env.WHISPER_CPP_LANGUAGE || env.STT_LANGUAGE || 'ko');
71
+ note('Progress/voice language', env.VOICE_LANGUAGE || env.WHISPER_CPP_LANGUAGE || env.STT_LANGUAGE || 'ko');
72
+ note('Latency log path', env.LATENCY_LOG_PATH || './.logs/latency.jsonl');
73
+ note('TTS voice fallback', env.TTS_VOICE || 'ko-KR-SunHiNeural');
74
+
75
+ if (!['edge', 'openvoice', 'speechswift', 'supertonic'].includes(ttsBackend)) {
76
+ ok = check('TTS_BACKEND value', false, 'must be edge, openvoice, speechswift, or supertonic') && ok;
77
+ }
78
+ if (ttsBackend === 'edge') {
79
+ const edgeCommand = env.EDGE_TTS_COMMAND || env.TTS_EDGE_COMMAND || 'edge-tts';
80
+ ok = check('edge-tts', commandExists(edgeCommand), commandExists(edgeCommand) || 'missing') && ok;
81
+ } else if (ttsBackend === 'openvoice') {
82
+ ok = check('Python for OpenVoice', commandExists('python3'), commandExists('python3') || 'missing') && ok;
83
+ const openvoiceDir = path.resolve(ROOT, env.OPENVOICE_DIR || './vendor/OpenVoice');
84
+ const openvoiceVenv = path.resolve(ROOT, env.OPENVOICE_VENV || './.venv-openvoice');
85
+ const refAudio = path.resolve(ROOT, env.OPENVOICE_REF_AUDIO || './voice-samples/user-reference.wav');
86
+ ok = check('OpenVoice repo', fs.existsSync(openvoiceDir), path.relative(ROOT, openvoiceDir)) && ok;
87
+ ok = check('OpenVoice venv', fs.existsSync(openvoiceVenv), path.relative(ROOT, openvoiceVenv)) && ok;
88
+ ok = check('OpenVoice reference audio', fs.existsSync(refAudio), path.relative(ROOT, refAudio)) && ok;
89
+ ok = check('OpenVoice synth wrapper help', spawnSync('python3', ['scripts/openvoice_synth.py', '--help'], { cwd: ROOT, encoding: 'utf8' }).status === 0, 'scripts/openvoice_synth.py') && ok;
90
+ note('OpenVoice progress prompts', ['1', 'true', 'yes', 'on'].includes(String(env.OPENVOICE_PROGRESS || '0').toLowerCase()) ? 'openvoice' : 'edge fallback');
91
+ } else if (ttsBackend === 'speechswift') {
92
+ const mode = String(env.SPEECHSWIFT_MODE || 'cli').toLowerCase() === 'server' ? 'server' : 'cli';
93
+ ok = check(mode === 'server' ? 'audio-server' : 'audio CLI', commandExists(mode === 'server' ? 'audio-server' : (env.SPEECHSWIFT_COMMAND || 'audio')), commandExists(mode === 'server' ? 'audio-server' : (env.SPEECHSWIFT_COMMAND || 'audio')) || 'missing') && ok;
94
+ note('SpeechSwift progress prompts', ['1', 'true', 'yes', 'on'].includes(String(env.SPEECHSWIFT_PROGRESS || '0').toLowerCase()) ? 'speechswift' : 'edge fallback');
95
+ } else if (ttsBackend === 'supertonic') {
96
+ const supertonicCommand = env.SUPERTONIC_COMMAND || 'supertonic';
97
+ ok = check('supertonic CLI', commandExists(supertonicCommand), commandExists(supertonicCommand) || 'install with: python3 -m pip install supertonic') && ok;
98
+ note('Supertonic voice/lang/steps', `${env.SUPERTONIC_VOICE || 'M1'} / ${env.SUPERTONIC_LANGUAGE || 'ko'} / ${env.SUPERTONIC_STEPS || '2'}`);
99
+ note('Supertonic progress prompts', ['1', 'true', 'yes', 'on'].includes(String(env.SUPERTONIC_PROGRESS || '0').toLowerCase()) ? 'supertonic' : 'edge fallback');
100
+ }
101
+
102
+ const backendCommand = {
103
+ hermes: env.HERMES_COMMAND || 'hermes',
104
+ 'claude-code': env.CLAUDE_COMMAND || 'claude',
105
+ claude: env.CLAUDE_COMMAND || 'claude',
106
+ codex: env.CODEX_COMMAND || 'codex',
107
+ gemini: env.GEMINI_COMMAND || 'gemini',
108
+ opencode: env.OPENCODE_COMMAND || 'opencode',
109
+ openclaw: env.OPENCLAW_COMMAND || 'openclaw',
110
+ custom: env.AGENT_COMMAND || '',
111
+ }[backend] || '';
112
+
113
+ if (backend === 'custom') {
114
+ ok = check('Custom AGENT_COMMAND configured', Boolean(env.AGENT_COMMAND), env.AGENT_COMMAND ? '[REDACTED]' : 'missing AGENT_COMMAND') && ok;
115
+ } else {
116
+ const first = String(backendCommand).trim().split(/\s+/)[0];
117
+ ok = check(`${backend} CLI`, first && commandExists(first), first ? (commandExists(first) || `missing ${first}`) : 'missing command') && ok;
118
+ }
119
+
120
+ console.log('');
121
+ console.log('Instance checks');
122
+ const instanceResult = checkInstanceConfigs(ROOT);
123
+ for (const line of formatInstanceDoctor(instanceResult)) {
124
+ console.log(line);
125
+ }
126
+ ok = instanceResult.errors.length === 0 && ok;
127
+
128
+ console.log('');
129
+ if (ok) {
130
+ console.log('Doctor passed. Run ./run.sh to start VerbalCoding.');
131
+ } else {
132
+ console.log('Doctor found missing prerequisites. Fix the ✗ items, then rerun npm run doctor.');
133
+ process.exitCode = 1;
134
+ }