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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { languagePreset, normalizeLanguageKey } from './language_config.mjs';
|
|
2
|
+
|
|
3
|
+
export const SUPPORTED_HARNESSES = [
|
|
4
|
+
'hermes',
|
|
5
|
+
'claude-code',
|
|
6
|
+
'claude',
|
|
7
|
+
'codex',
|
|
8
|
+
'gemini',
|
|
9
|
+
'opencode',
|
|
10
|
+
'openclaw',
|
|
11
|
+
'custom',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_DISCORD_BOT_PERMISSIONS = 277028604928;
|
|
15
|
+
|
|
16
|
+
function clean(value, fallback = '') {
|
|
17
|
+
const v = value == null ? '' : String(value).trim();
|
|
18
|
+
return v || fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeInstallAnswers(input = {}) {
|
|
22
|
+
const harness = clean(input.harness || input.AGENT_BACKEND, 'hermes').toLowerCase();
|
|
23
|
+
const normalizedHarness = SUPPORTED_HARNESSES.includes(harness) ? harness : 'custom';
|
|
24
|
+
const language = normalizeLanguageKey(input.language || input.VOICE_LANGUAGE || input.WHISPER_CPP_LANGUAGE || input.STT_LANGUAGE || 'ko');
|
|
25
|
+
const preset = languagePreset(language);
|
|
26
|
+
const out = {
|
|
27
|
+
AGENT_BACKEND: normalizedHarness,
|
|
28
|
+
DISCORD_BOT_TOKEN: clean(input.discordBotToken || input.DISCORD_BOT_TOKEN),
|
|
29
|
+
DISCORD_ALLOWED_USERS: clean(input.allowedUsers || input.DISCORD_ALLOWED_USERS),
|
|
30
|
+
AUTO_JOIN_VOICE_CHANNELS: clean(input.autoJoinVoiceChannels || input.AUTO_JOIN_VOICE_CHANNELS, '일반,General,general'),
|
|
31
|
+
TRANSCRIPT_CHANNEL_ID: clean(input.transcriptChannelId || input.TRANSCRIPT_CHANNEL_ID),
|
|
32
|
+
TTS_BACKEND: ['edge', 'openvoice', 'speechswift', 'supertonic'].includes(clean(input.ttsBackend || input.TTS_BACKEND, 'edge').toLowerCase())
|
|
33
|
+
? clean(input.ttsBackend || input.TTS_BACKEND, 'edge').toLowerCase()
|
|
34
|
+
: 'edge',
|
|
35
|
+
EDGE_TTS_COMMAND: clean(input.edgeTtsCommand || input.EDGE_TTS_COMMAND || input.TTS_EDGE_COMMAND, 'edge-tts'),
|
|
36
|
+
VOICE_LANGUAGE: clean(input.voiceLanguage || input.VOICE_LANGUAGE, preset.voiceLanguage),
|
|
37
|
+
WHISPER_CPP_LANGUAGE: clean(input.whisperLanguage || input.WHISPER_CPP_LANGUAGE || input.STT_LANGUAGE, preset.sttLanguage),
|
|
38
|
+
STT_LANGUAGE: clean(input.sttLanguage || input.STT_LANGUAGE || input.WHISPER_CPP_LANGUAGE, preset.sttLanguage),
|
|
39
|
+
TTS_VOICE: clean(input.ttsVoice || input.TTS_VOICE, preset.ttsVoice),
|
|
40
|
+
TTS_RATE: clean(input.ttsRate || input.TTS_RATE, '+10%'),
|
|
41
|
+
TTS_MAX_CHARS: clean(input.ttsMaxChars || input.TTS_MAX_CHARS, '495'),
|
|
42
|
+
TTS_VOLUME: clean(input.ttsVolume || input.TTS_VOLUME, '1.0'),
|
|
43
|
+
SUPERTONIC_COMMAND: clean(input.supertonicCommand || input.SUPERTONIC_COMMAND, 'supertonic'),
|
|
44
|
+
SUPERTONIC_VOICE: clean(input.supertonicVoice || input.SUPERTONIC_VOICE, 'M1'),
|
|
45
|
+
SUPERTONIC_LANGUAGE: clean(input.supertonicLanguage || input.SUPERTONIC_LANGUAGE, 'ko'),
|
|
46
|
+
SUPERTONIC_STEPS: clean(input.supertonicSteps || input.SUPERTONIC_STEPS, '2'),
|
|
47
|
+
SUPERTONIC_SPEED: clean(input.supertonicSpeed || input.SUPERTONIC_SPEED, '1.0'),
|
|
48
|
+
SUPERTONIC_MAX_CHUNK_LENGTH: clean(input.supertonicMaxChunkLength || input.SUPERTONIC_MAX_CHUNK_LENGTH, '300'),
|
|
49
|
+
SUPERTONIC_SILENCE_DURATION: clean(input.supertonicSilenceDuration || input.SUPERTONIC_SILENCE_DURATION, '0.15'),
|
|
50
|
+
SUPERTONIC_PROGRESS: input.supertonicProgress === true || input.SUPERTONIC_PROGRESS === '1' ? '1' : '0',
|
|
51
|
+
OPENVOICE_DIR: clean(input.openvoiceDir || input.OPENVOICE_DIR, './vendor/OpenVoice'),
|
|
52
|
+
OPENVOICE_VENV: clean(input.openvoiceVenv || input.OPENVOICE_VENV, './.venv-openvoice'),
|
|
53
|
+
OPENVOICE_REF_AUDIO: clean(input.openvoiceRefAudio || input.OPENVOICE_REF_AUDIO, './voice-samples/user-reference.wav'),
|
|
54
|
+
OPENVOICE_LANGUAGE: clean(input.openvoiceLanguage || input.OPENVOICE_LANGUAGE, 'KR'),
|
|
55
|
+
OPENVOICE_STYLE: clean(input.openvoiceStyle || input.OPENVOICE_STYLE, 'default'),
|
|
56
|
+
OPENVOICE_TIMEOUT_MS: clean(input.openvoiceTimeoutMs || input.OPENVOICE_TIMEOUT_MS, '90000'),
|
|
57
|
+
OPENVOICE_PROGRESS: input.openvoiceProgress === true || input.OPENVOICE_PROGRESS === '1' ? '1' : '0',
|
|
58
|
+
REQUIRE_WAKE_WORD: input.requireWakeWord === true || input.REQUIRE_WAKE_WORD === '1' ? '1' : '0',
|
|
59
|
+
MIN_UTTERANCE_SECONDS: clean(input.minUtteranceSeconds || input.MIN_UTTERANCE_SECONDS, '1.0'),
|
|
60
|
+
UTTERANCE_IDLE_MS: clean(input.utteranceIdleMs || input.UTTERANCE_IDLE_MS, '2000'),
|
|
61
|
+
HERMES_TASK_TIMEOUT_MS: clean(input.taskTimeoutMs || input.HERMES_TASK_TIMEOUT_MS, '0'),
|
|
62
|
+
HERMES_CHAT_TIMEOUT_MS: clean(input.chatTimeoutMs || input.HERMES_CHAT_TIMEOUT_MS, '45000'),
|
|
63
|
+
AGENT_VERBOSE_PROGRESS: input.verboseProgress === true || input.AGENT_VERBOSE_PROGRESS === '1' ? '1' : '0',
|
|
64
|
+
LATENCY_LOG_PATH: clean(input.latencyLogPath || input.LATENCY_LOG_PATH, './.logs/latency.jsonl'),
|
|
65
|
+
};
|
|
66
|
+
if (input.agentLabel || input.AGENT_LABEL) out.AGENT_LABEL = clean(input.agentLabel || input.AGENT_LABEL);
|
|
67
|
+
if (input.agentCommand || input.AGENT_COMMAND) out.AGENT_COMMAND = clean(input.agentCommand || input.AGENT_COMMAND);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function quoteEnv(value) {
|
|
72
|
+
return JSON.stringify(String(value ?? ''));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildDiscordBotInviteUrl(input = {}) {
|
|
76
|
+
const clientId = clean(input.clientId || input.DISCORD_CLIENT_ID || input.applicationId || input.APPLICATION_ID);
|
|
77
|
+
if (!clientId) throw new Error('Discord application/client ID is required.');
|
|
78
|
+
if (!/^\d{15,25}$/.test(clientId)) throw new Error('Discord application/client ID must be a numeric snowflake.');
|
|
79
|
+
const permissions = clean(input.permissions || input.DISCORD_BOT_PERMISSIONS, String(DEFAULT_DISCORD_BOT_PERMISSIONS));
|
|
80
|
+
const url = new URL('https://discord.com/oauth2/authorize');
|
|
81
|
+
url.searchParams.set('client_id', clientId);
|
|
82
|
+
url.searchParams.set('permissions', permissions);
|
|
83
|
+
url.searchParams.set('scope', 'bot applications.commands');
|
|
84
|
+
const guildId = clean(input.guildId || input.DISCORD_GUILD_ID);
|
|
85
|
+
if (guildId) {
|
|
86
|
+
url.searchParams.set('guild_id', guildId);
|
|
87
|
+
url.searchParams.set('disable_guild_select', 'true');
|
|
88
|
+
}
|
|
89
|
+
return url.toString();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function slugifyInstanceName(name) {
|
|
93
|
+
return String(name || '')
|
|
94
|
+
.trim()
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.replace(/[^\p{L}\p{N}_.-]+/gu, '-')
|
|
97
|
+
.replace(/^-+|-+$/g, '')
|
|
98
|
+
.slice(0, 64) || 'default';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildEnvFile(values = {}) {
|
|
102
|
+
const ordered = [
|
|
103
|
+
'DISCORD_BOT_TOKEN',
|
|
104
|
+
'DISCORD_ALLOWED_USERS',
|
|
105
|
+
'AUTO_JOIN_VOICE_CHANNELS',
|
|
106
|
+
'TRANSCRIPT_CHANNEL_ID',
|
|
107
|
+
'AGENT_BACKEND',
|
|
108
|
+
'AGENT_LABEL',
|
|
109
|
+
'AGENT_COMMAND',
|
|
110
|
+
'VOICE_LANGUAGE',
|
|
111
|
+
'WHISPER_CPP_LANGUAGE',
|
|
112
|
+
'STT_LANGUAGE',
|
|
113
|
+
'TTS_BACKEND',
|
|
114
|
+
'EDGE_TTS_COMMAND',
|
|
115
|
+
'TTS_VOICE',
|
|
116
|
+
'TTS_RATE',
|
|
117
|
+
'TTS_MAX_CHARS',
|
|
118
|
+
'TTS_VOLUME',
|
|
119
|
+
'SUPERTONIC_COMMAND',
|
|
120
|
+
'SUPERTONIC_VOICE',
|
|
121
|
+
'SUPERTONIC_LANGUAGE',
|
|
122
|
+
'SUPERTONIC_STEPS',
|
|
123
|
+
'SUPERTONIC_SPEED',
|
|
124
|
+
'SUPERTONIC_MAX_CHUNK_LENGTH',
|
|
125
|
+
'SUPERTONIC_SILENCE_DURATION',
|
|
126
|
+
'SUPERTONIC_PROGRESS',
|
|
127
|
+
'OPENVOICE_DIR',
|
|
128
|
+
'OPENVOICE_VENV',
|
|
129
|
+
'OPENVOICE_REF_AUDIO',
|
|
130
|
+
'OPENVOICE_LANGUAGE',
|
|
131
|
+
'OPENVOICE_STYLE',
|
|
132
|
+
'OPENVOICE_TIMEOUT_MS',
|
|
133
|
+
'OPENVOICE_PROGRESS',
|
|
134
|
+
'REQUIRE_WAKE_WORD',
|
|
135
|
+
'MIN_UTTERANCE_SECONDS',
|
|
136
|
+
'UTTERANCE_IDLE_MS',
|
|
137
|
+
'HERMES_TASK_TIMEOUT_MS',
|
|
138
|
+
'HERMES_CHAT_TIMEOUT_MS',
|
|
139
|
+
'AGENT_VERBOSE_PROGRESS',
|
|
140
|
+
'LATENCY_LOG_PATH',
|
|
141
|
+
];
|
|
142
|
+
const lines = [
|
|
143
|
+
'# Generated by VerbalCoding installer.',
|
|
144
|
+
'# Secrets stay local; do not commit this file.',
|
|
145
|
+
];
|
|
146
|
+
for (const key of ordered) {
|
|
147
|
+
if (values[key] == null || values[key] === '') continue;
|
|
148
|
+
lines.push(`${key}=${quoteEnv(values[key])}`);
|
|
149
|
+
}
|
|
150
|
+
return `${lines.join('\n')}\n`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function normalizeInstanceAnswers(input = {}) {
|
|
154
|
+
const instanceNameRaw = clean(input.instanceName || input.INSTANCE_NAME, 'example');
|
|
155
|
+
const instanceName = slugifyInstanceName(instanceNameRaw);
|
|
156
|
+
const displayName = clean(input.displayName || input.agentLabel || input.AGENT_LABEL, instanceNameRaw);
|
|
157
|
+
const workdir = clean(input.workdir || input.AGENT_CWD || input.AGENT_WORKDIR, '');
|
|
158
|
+
const projectContext = clean(input.projectContext || input.AGENT_PROJECT_CONTEXT, `Project session: ${displayName}`);
|
|
159
|
+
const out = {
|
|
160
|
+
INSTANCE_NAME: instanceName,
|
|
161
|
+
DISCORD_TOKEN: clean(input.discordBotToken || input.DISCORD_TOKEN || input.DISCORD_BOT_TOKEN),
|
|
162
|
+
DISCORD_CLIENT_ID: clean(input.discordClientId || input.DISCORD_CLIENT_ID || input.applicationId || input.APPLICATION_ID),
|
|
163
|
+
DISCORD_ALLOWED_USERS: clean(input.allowedUsers || input.DISCORD_ALLOWED_USERS),
|
|
164
|
+
AUTO_JOIN_VOICE_CHANNELS: clean(input.autoJoinVoiceChannels || input.AUTO_JOIN_VOICE_CHANNELS, displayName),
|
|
165
|
+
TRANSCRIPT_CHANNEL_ID: clean(input.transcriptChannelId || input.TRANSCRIPT_CHANNEL_ID),
|
|
166
|
+
PROJECT_SESSIONS_FILE: clean(input.projectSessionsFile || input.PROJECT_SESSIONS_FILE, `config/project-sessions.${instanceName}.json`),
|
|
167
|
+
BRIDGE_LOG_PATH: clean(input.bridgeLogPath || input.BRIDGE_LOG_PATH, `/tmp/verbalcoding-${instanceName}.log`),
|
|
168
|
+
NODE_AUDIO_DEBUG_DIR: clean(input.nodeAudioDebugDir || input.NODE_AUDIO_DEBUG_DIR, `/tmp/verbalcoding-${instanceName}-debug`),
|
|
169
|
+
HERMES_SESSION_FILE: clean(input.hermesSessionFile || input.HERMES_SESSION_FILE, `.agent-sessions/hermes/${instanceName}.session`),
|
|
170
|
+
HERMES_HOME: clean(input.hermesHome || input.HERMES_HOME),
|
|
171
|
+
AGENT_LABEL: clean(input.agentLabel || input.AGENT_LABEL, `Hermes Agent · ${displayName}`),
|
|
172
|
+
AGENT_CWD: workdir,
|
|
173
|
+
AGENT_PROJECT_CONTEXT: projectContext,
|
|
174
|
+
};
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function buildInstanceEnvFile(values = {}) {
|
|
179
|
+
const ordered = [
|
|
180
|
+
'INSTANCE_NAME',
|
|
181
|
+
'DISCORD_TOKEN',
|
|
182
|
+
'DISCORD_CLIENT_ID',
|
|
183
|
+
'DISCORD_ALLOWED_USERS',
|
|
184
|
+
'AUTO_JOIN_VOICE_CHANNELS',
|
|
185
|
+
'TRANSCRIPT_CHANNEL_ID',
|
|
186
|
+
'PROJECT_SESSIONS_FILE',
|
|
187
|
+
'BRIDGE_LOG_PATH',
|
|
188
|
+
'NODE_AUDIO_DEBUG_DIR',
|
|
189
|
+
'HERMES_SESSION_FILE',
|
|
190
|
+
'HERMES_HOME',
|
|
191
|
+
'AGENT_LABEL',
|
|
192
|
+
'AGENT_CWD',
|
|
193
|
+
'AGENT_PROJECT_CONTEXT',
|
|
194
|
+
];
|
|
195
|
+
const lines = [
|
|
196
|
+
'# Generated by `verbalcoding instance setup`.',
|
|
197
|
+
'# Secrets stay local; do not commit this file.',
|
|
198
|
+
];
|
|
199
|
+
for (const key of ordered) {
|
|
200
|
+
if (values[key] == null || values[key] === '') continue;
|
|
201
|
+
lines.push(`${key}=${quoteEnv(values[key])}`);
|
|
202
|
+
}
|
|
203
|
+
return `${lines.join('\n')}\n`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function renderInstanceSetupSummary(values = {}) {
|
|
207
|
+
const name = values.INSTANCE_NAME || 'example';
|
|
208
|
+
const inviteUrl = values.DISCORD_CLIENT_ID ? buildDiscordBotInviteUrl({ clientId: values.DISCORD_CLIENT_ID }) : '';
|
|
209
|
+
return [
|
|
210
|
+
`Configured VerbalCoding instance: ${name}`,
|
|
211
|
+
`Env file: instances/${name}.env`,
|
|
212
|
+
...(inviteUrl ? ['', 'Discord bot invite URL:', ` ${inviteUrl}`] : ['', 'Discord bot invite URL: run `vc bot invite <client-id>` after creating the Discord application.']),
|
|
213
|
+
'',
|
|
214
|
+
'Next commands:',
|
|
215
|
+
' vc doctor',
|
|
216
|
+
` vc instance start ${name}`,
|
|
217
|
+
` vc instance status ${name}`,
|
|
218
|
+
'',
|
|
219
|
+
`Voice channel: ${values.AUTO_JOIN_VOICE_CHANNELS || '(not set)'}`,
|
|
220
|
+
`Transcript target: ${values.TRANSCRIPT_CHANNEL_ID || '(not set)'}`,
|
|
221
|
+
`Log: ${values.BRIDGE_LOG_PATH || `/tmp/verbalcoding-${name}.log`}`,
|
|
222
|
+
].join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function parseKeyValueEnv(text) {
|
|
226
|
+
const out = {};
|
|
227
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
228
|
+
const line = raw.trim();
|
|
229
|
+
if (!line || line.startsWith('#') || !line.includes('=')) continue;
|
|
230
|
+
const idx = line.indexOf('=');
|
|
231
|
+
const key = line.slice(0, idx).trim().replace(/^export\s+/, '');
|
|
232
|
+
let value = line.slice(idx + 1).trim();
|
|
233
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
234
|
+
value = value.slice(1, -1);
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
if (line.slice(idx + 1).trim().startsWith('"')) value = JSON.parse(line.slice(idx + 1).trim());
|
|
238
|
+
} catch {}
|
|
239
|
+
out[key] = value;
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function renderInstallSummary(values = {}) {
|
|
245
|
+
const backend = values.AGENT_BACKEND || 'hermes';
|
|
246
|
+
return [
|
|
247
|
+
`Configured Discord voice bridge for harness: ${backend}`,
|
|
248
|
+
'',
|
|
249
|
+
'Next commands:',
|
|
250
|
+
' npm install -g . # or ./scripts/install.sh to install the vc command',
|
|
251
|
+
' vc doctor',
|
|
252
|
+
' ./run.sh',
|
|
253
|
+
'',
|
|
254
|
+
'Legacy project-local equivalents still work:',
|
|
255
|
+
' npm install',
|
|
256
|
+
' npm run doctor',
|
|
257
|
+
'',
|
|
258
|
+
`Auto-join voice channels: ${values.AUTO_JOIN_VOICE_CHANNELS || '일반,General,general'}`,
|
|
259
|
+
`TTS backend: ${values.TTS_BACKEND || 'edge'}`,
|
|
260
|
+
`Verbose progress: ${values.AGENT_VERBOSE_PROGRESS === '1' ? 'on' : 'off'} (toggle later with !verbose on/off)`,
|
|
261
|
+
'Use !ask <text>, !session, !reset-session, !join, !leave, !verbose, !voice-test, and !sensitivity in Discord.',
|
|
262
|
+
].join('\n');
|
|
263
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_DISCORD_BOT_PERMISSIONS,
|
|
6
|
+
buildDiscordBotInviteUrl,
|
|
7
|
+
buildEnvFile,
|
|
8
|
+
buildInstanceEnvFile,
|
|
9
|
+
normalizeInstallAnswers,
|
|
10
|
+
normalizeInstanceAnswers,
|
|
11
|
+
parseKeyValueEnv,
|
|
12
|
+
renderInstallSummary,
|
|
13
|
+
renderInstanceSetupSummary,
|
|
14
|
+
} from './install_config.mjs';
|
|
15
|
+
|
|
16
|
+
test('buildDiscordBotInviteUrl creates bot invite with voice and text permissions', () => {
|
|
17
|
+
const url = buildDiscordBotInviteUrl({ clientId: '123456789012345678' });
|
|
18
|
+
const parsed = new URL(url);
|
|
19
|
+
|
|
20
|
+
assert.equal(parsed.origin, 'https://discord.com');
|
|
21
|
+
assert.equal(parsed.pathname, '/oauth2/authorize');
|
|
22
|
+
assert.equal(parsed.searchParams.get('client_id'), '123456789012345678');
|
|
23
|
+
assert.equal(parsed.searchParams.get('scope'), 'bot applications.commands');
|
|
24
|
+
assert.equal(parsed.searchParams.get('permissions'), String(DEFAULT_DISCORD_BOT_PERMISSIONS));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('buildDiscordBotInviteUrl can pin a guild and disables guild selection', () => {
|
|
28
|
+
const url = buildDiscordBotInviteUrl({ clientId: '123456789012345678', guildId: '987654321098765432' });
|
|
29
|
+
const parsed = new URL(url);
|
|
30
|
+
|
|
31
|
+
assert.equal(parsed.searchParams.get('guild_id'), '987654321098765432');
|
|
32
|
+
assert.equal(parsed.searchParams.get('disable_guild_select'), 'true');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('normalizeInstallAnswers maps supported harnesses to backend env', () => {
|
|
36
|
+
const answers = normalizeInstallAnswers({
|
|
37
|
+
harness: 'opencode',
|
|
38
|
+
discordBotToken: 'token-123',
|
|
39
|
+
allowedUsers: '111,222',
|
|
40
|
+
autoJoinVoiceChannels: '일반,General',
|
|
41
|
+
transcriptChannelId: '333',
|
|
42
|
+
language: 'en',
|
|
43
|
+
ttsVoice: '',
|
|
44
|
+
ttsRate: '',
|
|
45
|
+
requireWakeWord: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal(answers.AGENT_BACKEND, 'opencode');
|
|
49
|
+
assert.equal(answers.DISCORD_BOT_TOKEN, 'token-123');
|
|
50
|
+
assert.equal(answers.DISCORD_ALLOWED_USERS, '111,222');
|
|
51
|
+
assert.equal(answers.AUTO_JOIN_VOICE_CHANNELS, '일반,General');
|
|
52
|
+
assert.equal(answers.TRANSCRIPT_CHANNEL_ID, '333');
|
|
53
|
+
assert.equal(answers.TTS_BACKEND, 'edge');
|
|
54
|
+
assert.equal(answers.EDGE_TTS_COMMAND, 'edge-tts');
|
|
55
|
+
assert.equal(answers.VOICE_LANGUAGE, 'en');
|
|
56
|
+
assert.equal(answers.WHISPER_CPP_LANGUAGE, 'en');
|
|
57
|
+
assert.equal(answers.STT_LANGUAGE, 'en');
|
|
58
|
+
assert.equal(answers.TTS_VOICE, 'en-US-GuyNeural');
|
|
59
|
+
assert.equal(answers.TTS_RATE, '+10%');
|
|
60
|
+
assert.equal(answers.TTS_VOLUME, '1.0');
|
|
61
|
+
assert.equal(answers.SUPERTONIC_COMMAND, 'supertonic');
|
|
62
|
+
assert.equal(answers.SUPERTONIC_SPEED, '1.0');
|
|
63
|
+
assert.equal(answers.SUPERTONIC_LANGUAGE, 'ko');
|
|
64
|
+
assert.equal(answers.OPENVOICE_LANGUAGE, 'KR');
|
|
65
|
+
assert.equal(answers.REQUIRE_WAKE_WORD, '0');
|
|
66
|
+
assert.equal(answers.UTTERANCE_IDLE_MS, '2000');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('buildEnvFile writes configurable CLI harness and Discord settings without comments leaking into values', () => {
|
|
70
|
+
const envText = buildEnvFile({
|
|
71
|
+
AGENT_BACKEND: 'custom',
|
|
72
|
+
AGENT_LABEL: 'My Harness',
|
|
73
|
+
AGENT_COMMAND: 'my-harness run --json',
|
|
74
|
+
VOICE_LANGUAGE: 'auto',
|
|
75
|
+
WHISPER_CPP_LANGUAGE: 'auto',
|
|
76
|
+
STT_LANGUAGE: 'auto',
|
|
77
|
+
TTS_BACKEND: 'supertonic',
|
|
78
|
+
EDGE_TTS_COMMAND: './.venv-tts/bin/edge-tts',
|
|
79
|
+
SUPERTONIC_VOICE: 'M4',
|
|
80
|
+
SUPERTONIC_STEPS: '3',
|
|
81
|
+
DISCORD_BOT_TOKEN: 'token-abc',
|
|
82
|
+
DISCORD_ALLOWED_USERS: '111',
|
|
83
|
+
AUTO_JOIN_VOICE_CHANNELS: '일반',
|
|
84
|
+
TRANSCRIPT_CHANNEL_ID: '222',
|
|
85
|
+
TTS_VOICE: 'ko-KR-SunHiNeural',
|
|
86
|
+
TTS_RATE: '+10%',
|
|
87
|
+
TTS_VOLUME: '1.6',
|
|
88
|
+
REQUIRE_WAKE_WORD: '0',
|
|
89
|
+
OPENVOICE_REF_AUDIO: './voice-samples/me.wav',
|
|
90
|
+
});
|
|
91
|
+
const parsed = parseKeyValueEnv(envText);
|
|
92
|
+
|
|
93
|
+
assert.equal(parsed.AGENT_BACKEND, 'custom');
|
|
94
|
+
assert.equal(parsed.AGENT_LABEL, 'My Harness');
|
|
95
|
+
assert.equal(parsed.AGENT_COMMAND, 'my-harness run --json');
|
|
96
|
+
assert.equal(parsed.TTS_BACKEND, 'supertonic');
|
|
97
|
+
assert.equal(parsed.EDGE_TTS_COMMAND, './.venv-tts/bin/edge-tts');
|
|
98
|
+
assert.equal(parsed.VOICE_LANGUAGE, 'auto');
|
|
99
|
+
assert.equal(parsed.WHISPER_CPP_LANGUAGE, 'auto');
|
|
100
|
+
assert.equal(parsed.STT_LANGUAGE, 'auto');
|
|
101
|
+
assert.equal(parsed.SUPERTONIC_VOICE, 'M4');
|
|
102
|
+
assert.equal(parsed.SUPERTONIC_STEPS, '3');
|
|
103
|
+
assert.equal(parsed.TTS_VOLUME, '1.6');
|
|
104
|
+
assert.equal(parsed.OPENVOICE_REF_AUDIO, './voice-samples/me.wav');
|
|
105
|
+
assert.equal(parsed.DISCORD_BOT_TOKEN, 'token-abc');
|
|
106
|
+
assert.equal(parsed.REQUIRE_WAKE_WORD, '0');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('renderInstallSummary documents selected harness and next commands', () => {
|
|
110
|
+
const summary = renderInstallSummary({ AGENT_BACKEND: 'claude-code', AUTO_JOIN_VOICE_CHANNELS: '일반', TTS_BACKEND: 'openvoice' });
|
|
111
|
+
|
|
112
|
+
assert.match(summary, /claude-code/);
|
|
113
|
+
assert.match(summary, /npm install/);
|
|
114
|
+
assert.match(summary, /\.\/run\.sh/);
|
|
115
|
+
assert.match(summary, /openvoice/);
|
|
116
|
+
assert.match(summary, /!voice-test/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('normalizeInstanceAnswers derives isolated per-instance env values', () => {
|
|
120
|
+
const values = normalizeInstanceAnswers({
|
|
121
|
+
instanceName: 'LLM Wiki',
|
|
122
|
+
discordBotToken: 'token-instance',
|
|
123
|
+
discordClientId: '123456789012345678',
|
|
124
|
+
autoJoinVoiceChannels: 'LLM-Wiki',
|
|
125
|
+
transcriptChannelId: '123456789012345678',
|
|
126
|
+
workdir: '/path/to/my-project',
|
|
127
|
+
projectContext: 'LLM-Wiki graph context',
|
|
128
|
+
agentLabel: '',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
assert.equal(values.INSTANCE_NAME, 'llm-wiki');
|
|
132
|
+
assert.equal(values.DISCORD_TOKEN, 'token-instance');
|
|
133
|
+
assert.equal(values.DISCORD_CLIENT_ID, '123456789012345678');
|
|
134
|
+
assert.equal(values.AUTO_JOIN_VOICE_CHANNELS, 'LLM-Wiki');
|
|
135
|
+
assert.equal(values.TRANSCRIPT_CHANNEL_ID, '123456789012345678');
|
|
136
|
+
assert.equal(values.PROJECT_SESSIONS_FILE, 'config/project-sessions.llm-wiki.json');
|
|
137
|
+
assert.equal(values.BRIDGE_LOG_PATH, '/tmp/verbalcoding-llm-wiki.log');
|
|
138
|
+
assert.equal(values.NODE_AUDIO_DEBUG_DIR, '/tmp/verbalcoding-llm-wiki-debug');
|
|
139
|
+
assert.equal(values.HERMES_SESSION_FILE, '.agent-sessions/hermes/llm-wiki.session');
|
|
140
|
+
assert.equal(values.AGENT_LABEL, 'Hermes Agent · LLM Wiki');
|
|
141
|
+
assert.equal(values.AGENT_CWD, '/path/to/my-project');
|
|
142
|
+
assert.equal(values.AGENT_PROJECT_CONTEXT, 'LLM-Wiki graph context');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('buildInstanceEnvFile writes only local per-instance values with token redaction left to callers', () => {
|
|
146
|
+
const envText = buildInstanceEnvFile(normalizeInstanceAnswers({
|
|
147
|
+
instanceName: 'verbalcoding',
|
|
148
|
+
discordBotToken: 'token-vc',
|
|
149
|
+
autoJoinVoiceChannels: 'VerbalCoding',
|
|
150
|
+
transcriptChannelId: 'thread-1',
|
|
151
|
+
allowedUsers: '111,222',
|
|
152
|
+
discordClientId: '123456789012345678',
|
|
153
|
+
}));
|
|
154
|
+
const parsed = parseKeyValueEnv(envText);
|
|
155
|
+
|
|
156
|
+
assert.equal(parsed.INSTANCE_NAME, 'verbalcoding');
|
|
157
|
+
assert.equal(parsed.DISCORD_TOKEN, 'token-vc');
|
|
158
|
+
assert.equal(parsed.DISCORD_CLIENT_ID, '123456789012345678');
|
|
159
|
+
assert.equal(parsed.DISCORD_ALLOWED_USERS, '111,222');
|
|
160
|
+
assert.equal(parsed.AUTO_JOIN_VOICE_CHANNELS, 'VerbalCoding');
|
|
161
|
+
assert.equal(parsed.TRANSCRIPT_CHANNEL_ID, 'thread-1');
|
|
162
|
+
assert.equal(parsed.PROJECT_SESSIONS_FILE, 'config/project-sessions.verbalcoding.json');
|
|
163
|
+
assert.equal(parsed.AGENT_BACKEND, undefined);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('normalizeInstanceAnswers surfaces HERMES_HOME when provided', () => {
|
|
167
|
+
const out = normalizeInstanceAnswers({
|
|
168
|
+
instanceName: 'llm-wiki',
|
|
169
|
+
discordBotToken: 'token',
|
|
170
|
+
autoJoinVoiceChannels: 'LLM-Wiki',
|
|
171
|
+
transcriptChannelId: '123',
|
|
172
|
+
workdir: '/projects/llm-wiki',
|
|
173
|
+
projectContext: 'LLM-Wiki agent',
|
|
174
|
+
hermesHome: '/home/you/.hermes/profiles/my-project',
|
|
175
|
+
});
|
|
176
|
+
assert.equal(out.HERMES_HOME, '/home/you/.hermes/profiles/my-project');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('buildInstanceEnvFile emits HERMES_HOME after HERMES_SESSION_FILE', () => {
|
|
180
|
+
const text = buildInstanceEnvFile({
|
|
181
|
+
INSTANCE_NAME: 'llm-wiki',
|
|
182
|
+
DISCORD_TOKEN: 'token',
|
|
183
|
+
HERMES_SESSION_FILE: '.agent-sessions/hermes/llm-wiki.session',
|
|
184
|
+
HERMES_HOME: '/home/you/.hermes/profiles/my-project',
|
|
185
|
+
AGENT_LABEL: 'Hermes · llm-wiki',
|
|
186
|
+
AGENT_CWD: '/projects/llm-wiki',
|
|
187
|
+
});
|
|
188
|
+
const lines = text.split('\n');
|
|
189
|
+
const sessionIdx = lines.findIndex(l => l.startsWith('HERMES_SESSION_FILE='));
|
|
190
|
+
const homeIdx = lines.findIndex(l => l.startsWith('HERMES_HOME='));
|
|
191
|
+
assert.ok(sessionIdx >= 0 && homeIdx === sessionIdx + 1, `expected HERMES_HOME directly after HERMES_SESSION_FILE; got lines:\n${text}`);
|
|
192
|
+
assert.match(text, /HERMES_HOME="\/home\/you\/\.hermes\/profiles\/my-project"/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('renderInstanceSetupSummary points users at installed vc commands, not npm script wrappers or manual editing', () => {
|
|
196
|
+
const summary = renderInstanceSetupSummary({ INSTANCE_NAME: 'llm-wiki', DISCORD_CLIENT_ID: '123456789012345678', BRIDGE_LOG_PATH: '/tmp/verbalcoding-llm-wiki.log' });
|
|
197
|
+
|
|
198
|
+
assert.match(summary, /instances\/llm-wiki\.env/);
|
|
199
|
+
assert.match(summary, /vc instance start llm-wiki/);
|
|
200
|
+
assert.match(summary, /vc instance status llm-wiki/);
|
|
201
|
+
assert.match(summary, /vc doctor/);
|
|
202
|
+
assert.match(summary, /Discord bot invite URL:/);
|
|
203
|
+
assert.match(summary, /client_id=123456789012345678/);
|
|
204
|
+
assert.doesNotMatch(summary, /npm run vc/);
|
|
205
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { listInstanceEnvFiles, instanceNameFromEnvPath, readInstanceEnv } from './instances.mjs';
|
|
6
|
+
|
|
7
|
+
export function tokenFingerprint(token) {
|
|
8
|
+
return crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 12);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readProfileTerminalCwdFromConfig(dir, fsDep = fs) {
|
|
12
|
+
try {
|
|
13
|
+
const text = fsDep.readFileSync(path.join(dir, 'config.yaml'), 'utf8');
|
|
14
|
+
const lines = text.split(/\r?\n/);
|
|
15
|
+
let inTerminal = false;
|
|
16
|
+
for (const line of lines) {
|
|
17
|
+
if (/^terminal:\s*$/.test(line)) {
|
|
18
|
+
inTerminal = true;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (inTerminal) {
|
|
22
|
+
if (/^\S/.test(line)) break;
|
|
23
|
+
const m = line.match(/^\s+cwd:\s*"?([^"\n]+?)"?\s*$/);
|
|
24
|
+
if (m) return m[1].trim();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return '';
|
|
28
|
+
} catch {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function effectiveInstanceValue(root, instance, key) {
|
|
34
|
+
const value = String(instance.env[key] || '').trim();
|
|
35
|
+
if (value) return value;
|
|
36
|
+
if (key === 'PROJECT_SESSIONS_FILE') return path.join(root, 'config', 'project-sessions.json');
|
|
37
|
+
if (key === 'BRIDGE_LOG_PATH') return `/tmp/verbalcoding-${instance.name}.log`;
|
|
38
|
+
if (key === 'NODE_AUDIO_DEBUG_DIR') return '/tmp/verbalcoding-node-debug';
|
|
39
|
+
if (key === 'HERMES_SESSION_FILE') return path.join(root, '.verbalcoding-session');
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function addCollisionIssues({ root, instances, key, messagePrefix }) {
|
|
44
|
+
const buckets = new Map();
|
|
45
|
+
for (const instance of instances) {
|
|
46
|
+
const value = effectiveInstanceValue(root, instance, key);
|
|
47
|
+
if (!value) continue;
|
|
48
|
+
const normalized = key.endsWith('FILE') || key.endsWith('PATH') || key.endsWith('DIR')
|
|
49
|
+
? path.normalize(value)
|
|
50
|
+
: value;
|
|
51
|
+
if (!buckets.has(normalized)) buckets.set(normalized, []);
|
|
52
|
+
buckets.get(normalized).push(instance.name);
|
|
53
|
+
}
|
|
54
|
+
const issues = [];
|
|
55
|
+
for (const [value, names] of buckets.entries()) {
|
|
56
|
+
if (names.length > 1) {
|
|
57
|
+
issues.push(`${messagePrefix} ${key} collision: ${names.join(', ')} -> ${value}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { issues };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function checkInstanceConfigs(root, options = {}) {
|
|
64
|
+
const instancesDir = options.instancesDir || path.join(root, 'instances');
|
|
65
|
+
const envFiles = listInstanceEnvFiles(instancesDir);
|
|
66
|
+
const instances = envFiles.map(envPath => ({
|
|
67
|
+
name: instanceNameFromEnvPath(envPath),
|
|
68
|
+
envPath,
|
|
69
|
+
env: readInstanceEnv(envPath),
|
|
70
|
+
}));
|
|
71
|
+
const errors = [];
|
|
72
|
+
const warnings = [];
|
|
73
|
+
|
|
74
|
+
for (const instance of instances) {
|
|
75
|
+
const token = String(instance.env.DISCORD_BOT_TOKEN || instance.env.DISCORD_TOKEN || '').trim();
|
|
76
|
+
if (!token || /^replace/i.test(token)) {
|
|
77
|
+
errors.push(`${instance.name}: missing DISCORD_TOKEN or DISCORD_BOT_TOKEN`);
|
|
78
|
+
}
|
|
79
|
+
if (!String(instance.env.AUTO_JOIN_VOICE_CHANNELS || '').trim()) {
|
|
80
|
+
errors.push(`${instance.name}: missing AUTO_JOIN_VOICE_CHANNELS`);
|
|
81
|
+
}
|
|
82
|
+
if (!String(instance.env.TRANSCRIPT_CHANNEL_ID || '').trim()) {
|
|
83
|
+
errors.push(`${instance.name}: missing TRANSCRIPT_CHANNEL_ID`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tokenBuckets = new Map();
|
|
88
|
+
for (const instance of instances) {
|
|
89
|
+
const token = String(instance.env.DISCORD_BOT_TOKEN || instance.env.DISCORD_TOKEN || '').trim();
|
|
90
|
+
if (!token || /^replace/i.test(token)) continue;
|
|
91
|
+
const fp = tokenFingerprint(token);
|
|
92
|
+
if (!tokenBuckets.has(fp)) tokenBuckets.set(fp, []);
|
|
93
|
+
tokenBuckets.get(fp).push(instance.name);
|
|
94
|
+
}
|
|
95
|
+
for (const [fp, names] of tokenBuckets.entries()) {
|
|
96
|
+
if (names.length > 1) {
|
|
97
|
+
errors.push(`duplicate Discord token fingerprint ${fp}: ${names.join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const key of ['PROJECT_SESSIONS_FILE', 'BRIDGE_LOG_PATH', 'NODE_AUDIO_DEBUG_DIR']) {
|
|
102
|
+
const { issues } = addCollisionIssues({ root, instances, key, messagePrefix: 'instance' });
|
|
103
|
+
errors.push(...issues);
|
|
104
|
+
}
|
|
105
|
+
const { issues: sessionWarnings } = addCollisionIssues({ root, instances, key: 'HERMES_SESSION_FILE', messagePrefix: 'instance' });
|
|
106
|
+
warnings.push(...sessionWarnings);
|
|
107
|
+
|
|
108
|
+
const readTerminalCwd = options.readTerminalCwd || (dir => readProfileTerminalCwdFromConfig(dir));
|
|
109
|
+
for (const instance of instances) {
|
|
110
|
+
const home = String(instance.env.HERMES_HOME || '').trim();
|
|
111
|
+
if (!home) continue;
|
|
112
|
+
if (!fs.existsSync(path.join(home, 'config.yaml'))) {
|
|
113
|
+
warnings.push(`${instance.name}: HERMES_HOME points at ${home} which is missing; vc instance start will create it`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const profileCwd = readTerminalCwd(home);
|
|
117
|
+
const agentCwd = String(instance.env.AGENT_CWD || '').trim();
|
|
118
|
+
if (profileCwd && agentCwd && profileCwd !== agentCwd) {
|
|
119
|
+
errors.push(`${instance.name}: profile terminal.cwd (${profileCwd}) does not match AGENT_CWD (${agentCwd}); re-run vc instance setup to reconcile`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { errors, warnings, instances: instances.map(({ name, envPath }) => ({ name, envPath })) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function formatInstanceDoctor(result) {
|
|
127
|
+
const lines = [];
|
|
128
|
+
if (result.instances.length === 0) {
|
|
129
|
+
lines.push('• No per-instance env files found in instances/*.env');
|
|
130
|
+
} else {
|
|
131
|
+
lines.push(`• Instance env files: ${result.instances.map(i => i.name).join(', ')}`);
|
|
132
|
+
}
|
|
133
|
+
for (const error of result.errors) lines.push(`✗ ${error}`);
|
|
134
|
+
for (const warning of result.warnings) lines.push(`• Warning: ${warning}`);
|
|
135
|
+
if (result.errors.length === 0) lines.push('✓ Instance configuration checks passed');
|
|
136
|
+
return lines;
|
|
137
|
+
}
|