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.
- package/.env.example +83 -0
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/app-node/agent_adapters.mjs +576 -0
- package/app-node/agent_adapters.test.mjs +455 -0
- package/app-node/agent_contract.mjs +45 -0
- package/app-node/barge_in.mjs +148 -0
- package/app-node/barge_in.test.mjs +179 -0
- package/app-node/bridge_logger.mjs +66 -0
- package/app-node/bridge_logger.test.mjs +73 -0
- package/app-node/bridge_state.mjs +104 -0
- package/app-node/bridge_state.test.mjs +64 -0
- package/app-node/cli_install.test.mjs +97 -0
- package/app-node/deferred_queue.mjs +12 -0
- package/app-node/deferred_queue.test.mjs +20 -0
- package/app-node/discord_invite_cli.test.mjs +31 -0
- package/app-node/discord_text.mjs +29 -0
- package/app-node/discord_text.test.mjs +32 -0
- package/app-node/hermes_profiles.mjs +164 -0
- package/app-node/hermes_profiles.test.mjs +276 -0
- package/app-node/install_config.mjs +263 -0
- package/app-node/install_config.test.mjs +205 -0
- package/app-node/instance_doctor.mjs +137 -0
- package/app-node/instance_doctor.test.mjs +128 -0
- package/app-node/instance_profile_lifecycle.mjs +16 -0
- package/app-node/instances.mjs +153 -0
- package/app-node/instances.test.mjs +102 -0
- package/app-node/language_config.mjs +73 -0
- package/app-node/language_config.test.mjs +51 -0
- package/app-node/latency_metrics.mjs +133 -0
- package/app-node/latency_metrics.test.mjs +71 -0
- package/app-node/main.mjs +1771 -0
- package/app-node/mcp_tools.mjs +198 -0
- package/app-node/mcp_tools.test.mjs +39 -0
- package/app-node/progress_cache.mjs +7 -0
- package/app-node/progress_cache.test.mjs +23 -0
- package/app-node/progress_speech.mjs +102 -0
- package/app-node/progress_speech.test.mjs +48 -0
- package/app-node/project_sessions.mjs +148 -0
- package/app-node/project_sessions.test.mjs +77 -0
- package/app-node/restart_notice.mjs +57 -0
- package/app-node/restart_notice.test.mjs +37 -0
- package/app-node/restart_policy.mjs +27 -0
- package/app-node/restart_policy.test.mjs +33 -0
- package/app-node/text_routing.mjs +8 -0
- package/app-node/text_routing.test.mjs +18 -0
- package/app-node/tts_backends.mjs +251 -0
- package/app-node/tts_backends.test.mjs +400 -0
- package/app-node/tts_chunks.mjs +57 -0
- package/app-node/tts_chunks.test.mjs +35 -0
- package/app-node/tts_prefetch.mjs +38 -0
- package/app-node/tts_prefetch.test.mjs +49 -0
- package/app-node/tts_settings.mjs +72 -0
- package/app-node/tts_settings.test.mjs +127 -0
- package/app-node/tts_voice_config.mjs +127 -0
- package/app-node/tts_voice_config.test.mjs +64 -0
- package/app-node/voice_clone_capture.mjs +76 -0
- package/app-node/voice_clone_capture.test.mjs +51 -0
- package/app-node/voice_messages.mjs +62 -0
- package/app-node/voice_messages.test.mjs +33 -0
- package/docs/CONFIGURATION.md +183 -0
- package/docs/FRESH_INSTALL.md +193 -0
- package/docs/MULTI_INSTANCE.md +183 -0
- package/docs/RELEASE.md +72 -0
- package/docs/USAGE.md +108 -0
- package/docs/assets/figures/verbalcoding-flow.svg +63 -0
- package/docs/i18n/README.es.md +121 -0
- package/docs/i18n/README.fr.md +121 -0
- package/docs/i18n/README.ja.md +121 -0
- package/docs/i18n/README.ko.md +121 -0
- package/docs/i18n/README.ru.md +121 -0
- package/docs/i18n/README.zh.md +121 -0
- package/package.json +58 -0
- package/run.sh +82 -0
- package/scripts/bootstrap_prereqs.sh +193 -0
- package/scripts/cli.mjs +369 -0
- package/scripts/docker_ubuntu_smoke.sh +76 -0
- package/scripts/doctor.mjs +134 -0
- package/scripts/install.mjs +108 -0
- package/scripts/install.sh +44 -0
- package/scripts/mcp-server.mjs +84 -0
- package/scripts/openvoice_smoke.py +34 -0
- package/scripts/openvoice_synth.py +103 -0
- package/scripts/setup_openvoice.sh +34 -0
- package/scripts/setup_supertonic.sh +18 -0
package/scripts/cli.mjs
ADDED
|
@@ -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
|
+
}
|