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,198 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
import { parseKeyValueEnv } from './install_config.mjs';
|
|
6
|
+
import { applyLanguagePreset, languageStatus, normalizeLanguageKey } from './language_config.mjs';
|
|
7
|
+
import { AUTO_RESTART_ENV_KEY, autoRestartStatusText, normalizeAutoRestartCommand } from './restart_policy.mjs';
|
|
8
|
+
|
|
9
|
+
function quoteEnv(value) {
|
|
10
|
+
return JSON.stringify(String(value ?? ''));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readEnvFile(file) {
|
|
14
|
+
if (!fs.existsSync(file)) return {};
|
|
15
|
+
return parseKeyValueEnv(fs.readFileSync(file, 'utf8'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function upsertEnvFile(file, updates) {
|
|
19
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
const lines = existing.split(/\r?\n/).map(raw => {
|
|
22
|
+
const line = raw.trim();
|
|
23
|
+
if (!line || line.startsWith('#') || !line.includes('=')) return raw;
|
|
24
|
+
const idx = line.indexOf('=');
|
|
25
|
+
const key = line.slice(0, idx).trim().replace(/^export\s+/, '');
|
|
26
|
+
if (!(key in updates)) return raw;
|
|
27
|
+
seen.add(key);
|
|
28
|
+
return `${key}=${quoteEnv(updates[key])}`;
|
|
29
|
+
});
|
|
30
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
31
|
+
if (!seen.has(key)) lines.push(`${key}=${quoteEnv(value)}`);
|
|
32
|
+
}
|
|
33
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
34
|
+
fs.writeFileSync(file, `${lines.filter((line, index, arr) => line !== '' || index < arr.length - 1).join('\n')}\n`, { mode: 0o600 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function runCommand(command, args, { root, timeoutMs = 120000, env = process.env } = {}) {
|
|
38
|
+
const result = spawnSync(command, args, {
|
|
39
|
+
cwd: root,
|
|
40
|
+
env,
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
timeout: timeoutMs,
|
|
43
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
ok: result.status === 0,
|
|
47
|
+
status: result.status,
|
|
48
|
+
signal: result.signal,
|
|
49
|
+
stdout: sanitizeOutput(result.stdout || ''),
|
|
50
|
+
stderr: sanitizeOutput(result.stderr || ''),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sanitizeOutput(text) {
|
|
55
|
+
return String(text || '')
|
|
56
|
+
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+/g, '[REDACTED_EMAIL]')
|
|
57
|
+
.replace(/(token|api[_-]?key|password|secret|authorization|connection[_-]?string)\s*[:=]\s*\S+/gi, '$1=[REDACTED]')
|
|
58
|
+
.replace(/[A-Za-z0-9_-]{40,}/g, '[REDACTED]');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function runningPids() {
|
|
62
|
+
const result = spawnSync('ps', ['-axo', 'pid,command'], { encoding: 'utf8' });
|
|
63
|
+
if (result.status !== 0) return [];
|
|
64
|
+
return result.stdout
|
|
65
|
+
.split(/\r?\n/)
|
|
66
|
+
.map(line => line.trim())
|
|
67
|
+
.map(line => /^(\d+)\s+(.+)$/.exec(line))
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.filter(match => /node\s+app-node\/main\.mjs/.test(match[2]))
|
|
70
|
+
.map(match => Number(match[1]));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function startBridge({ root, env = process.env } = {}) {
|
|
74
|
+
const pids = runningPids();
|
|
75
|
+
if (pids.length) return { ok: true, alreadyRunning: true, pids };
|
|
76
|
+
fs.mkdirSync(path.join(root, '.cache'), { recursive: true });
|
|
77
|
+
const child = spawn('./run.sh', {
|
|
78
|
+
cwd: root,
|
|
79
|
+
env: {
|
|
80
|
+
...env,
|
|
81
|
+
BRIDGE_LOG_PATH: '/tmp/verbalcoding-node.log',
|
|
82
|
+
NODE_AUDIO_DEBUG_DIR: '/tmp/verbalcoding-node-debug',
|
|
83
|
+
},
|
|
84
|
+
detached: true,
|
|
85
|
+
stdio: 'ignore',
|
|
86
|
+
});
|
|
87
|
+
child.unref();
|
|
88
|
+
return { ok: true, started: true, pid: child.pid };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stopBridge() {
|
|
92
|
+
const pids = runningPids();
|
|
93
|
+
for (const pid of pids) {
|
|
94
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, stoppedPids: pids };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createVerbalCodingMcpTools({ root = process.cwd(), envPath = path.join(root, '.env'), env = process.env } = {}) {
|
|
100
|
+
const toolDefs = [
|
|
101
|
+
{
|
|
102
|
+
name: 'status',
|
|
103
|
+
description: 'Return VerbalCoding runtime/config status without exposing secrets.',
|
|
104
|
+
inputSchema: { type: 'object', properties: {} },
|
|
105
|
+
handler: async () => {
|
|
106
|
+
const values = readEnvFile(envPath);
|
|
107
|
+
const lang = languageStatus(values);
|
|
108
|
+
return {
|
|
109
|
+
project: root,
|
|
110
|
+
running: runningPids().length > 0,
|
|
111
|
+
pids: runningPids(),
|
|
112
|
+
backend: values.AGENT_BACKEND || 'hermes',
|
|
113
|
+
ttsBackend: values.TTS_BACKEND || 'edge',
|
|
114
|
+
sttLanguage: lang.sttLanguage,
|
|
115
|
+
voiceLanguage: lang.voiceLanguage,
|
|
116
|
+
ttsVoice: lang.ttsVoice,
|
|
117
|
+
autoRestartAfterCommit: autoRestartStatusText(values),
|
|
118
|
+
sessionFile: values.HERMES_SESSION_FILE || path.join(root, '.verbalcoding-session'),
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'doctor',
|
|
124
|
+
description: 'Run VerbalCoding doctor and return redacted output.',
|
|
125
|
+
inputSchema: { type: 'object', properties: {} },
|
|
126
|
+
handler: async () => runCommand('npm', ['run', 'doctor'], { root, env }),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'set_auto_restart',
|
|
130
|
+
description: 'Enable or disable commit-time voice bot auto-restart. Defaults off unless explicitly enabled.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: { enabled: { type: 'boolean', description: 'true to enable, false to disable' } },
|
|
134
|
+
required: ['enabled'],
|
|
135
|
+
},
|
|
136
|
+
handler: async ({ enabled }) => {
|
|
137
|
+
upsertEnvFile(envPath, { [AUTO_RESTART_ENV_KEY]: enabled ? '1' : '0' });
|
|
138
|
+
const values = readEnvFile(envPath);
|
|
139
|
+
return { ok: true, status: autoRestartStatusText(values), value: values[AUTO_RESTART_ENV_KEY] };
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'set_language',
|
|
144
|
+
description: 'Set STT/progress/TTS language preset to ko, en, or auto.',
|
|
145
|
+
inputSchema: {
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: { language: { type: 'string', enum: ['ko', 'en', 'auto'] } },
|
|
148
|
+
required: ['language'],
|
|
149
|
+
},
|
|
150
|
+
handler: async ({ language }) => {
|
|
151
|
+
const key = normalizeLanguageKey(language, '');
|
|
152
|
+
if (!key) throw new Error(`Unknown language: ${language}`);
|
|
153
|
+
const next = applyLanguagePreset(readEnvFile(envPath), key);
|
|
154
|
+
upsertEnvFile(envPath, {
|
|
155
|
+
VOICE_LANGUAGE: next.VOICE_LANGUAGE,
|
|
156
|
+
WHISPER_CPP_LANGUAGE: next.WHISPER_CPP_LANGUAGE,
|
|
157
|
+
STT_LANGUAGE: next.STT_LANGUAGE,
|
|
158
|
+
TTS_BACKEND: next.TTS_BACKEND,
|
|
159
|
+
TTS_VOICE: next.TTS_VOICE,
|
|
160
|
+
});
|
|
161
|
+
return { ok: true, status: languageStatus(next) };
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: 'start',
|
|
166
|
+
description: 'Start the VerbalCoding Discord voice bridge if it is not already running.',
|
|
167
|
+
inputSchema: { type: 'object', properties: {} },
|
|
168
|
+
handler: async () => startBridge({ root, env }),
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: 'stop',
|
|
172
|
+
description: 'Gracefully stop the VerbalCoding Discord voice bridge.',
|
|
173
|
+
inputSchema: { type: 'object', properties: {} },
|
|
174
|
+
handler: async () => stopBridge(),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'restart',
|
|
178
|
+
description: 'Restart the VerbalCoding Discord voice bridge with an optional short notice.',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: { notice: { type: 'string', description: 'Short user-facing restart notice detail' } },
|
|
182
|
+
},
|
|
183
|
+
handler: async ({ notice = 'MCP 요청으로 재시작해' } = {}) => {
|
|
184
|
+
fs.mkdirSync(path.join(root, '.cache'), { recursive: true });
|
|
185
|
+
fs.writeFileSync(path.join(root, '.cache', 'restart-notice.txt'), String(notice).replace(/\s+/g, ' ').trim().slice(0, 160));
|
|
186
|
+
const stopped = stopBridge();
|
|
187
|
+
const started = startBridge({ root, env });
|
|
188
|
+
return { ok: Boolean(started.ok), stopped, started };
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
const tools = new Map(toolDefs.map(tool => [tool.name, tool]));
|
|
193
|
+
return { toolDefs, tools };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function toolResultContent(value) {
|
|
197
|
+
return [{ type: 'text', text: JSON.stringify(value, null, 2) }];
|
|
198
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { createVerbalCodingMcpTools, readEnvFile } from './mcp_tools.mjs';
|
|
8
|
+
import { AUTO_RESTART_ENV_KEY } from './restart_policy.mjs';
|
|
9
|
+
|
|
10
|
+
test('MCP tool definitions expose VerbalCoding control surface', () => {
|
|
11
|
+
const { toolDefs, tools } = createVerbalCodingMcpTools({ root: process.cwd() });
|
|
12
|
+
const names = toolDefs.map(tool => tool.name).sort();
|
|
13
|
+
assert.deepEqual(names, ['doctor', 'restart', 'set_auto_restart', 'set_language', 'start', 'status', 'stop']);
|
|
14
|
+
assert.equal(tools.get('set_auto_restart').inputSchema.required[0], 'enabled');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('set_auto_restart MCP tool writes the default-off restart flag', async () => {
|
|
18
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-mcp-'));
|
|
19
|
+
const envPath = path.join(dir, '.env');
|
|
20
|
+
const { tools } = createVerbalCodingMcpTools({ root: dir, envPath });
|
|
21
|
+
const off = await tools.get('set_auto_restart').handler({ enabled: false });
|
|
22
|
+
assert.equal(off.value, '0');
|
|
23
|
+
assert.equal(readEnvFile(envPath)[AUTO_RESTART_ENV_KEY], '0');
|
|
24
|
+
const on = await tools.get('set_auto_restart').handler({ enabled: true });
|
|
25
|
+
assert.equal(on.value, '1');
|
|
26
|
+
assert.equal(readEnvFile(envPath)[AUTO_RESTART_ENV_KEY], '1');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('set_language MCP tool updates STT, progress, and TTS language together', async () => {
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-mcp-lang-'));
|
|
31
|
+
const envPath = path.join(dir, '.env');
|
|
32
|
+
const { tools } = createVerbalCodingMcpTools({ root: dir, envPath });
|
|
33
|
+
const result = await tools.get('set_language').handler({ language: 'en' });
|
|
34
|
+
assert.equal(result.ok, true);
|
|
35
|
+
const values = readEnvFile(envPath);
|
|
36
|
+
assert.equal(values.VOICE_LANGUAGE, 'en');
|
|
37
|
+
assert.equal(values.WHISPER_CPP_LANGUAGE, 'en');
|
|
38
|
+
assert.match(values.TTS_VOICE, /^en-/);
|
|
39
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function progressTtsCacheFileName({ backendKeyParts, text, ext }) {
|
|
4
|
+
const key = `${(backendKeyParts || []).join('\n')}\n${String(text || '')}`;
|
|
5
|
+
const digest = createHash('sha256').update(key, 'utf8').digest('hex');
|
|
6
|
+
return `${digest}.${ext || 'mp3'}`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { progressTtsCacheFileName } from './progress_cache.mjs';
|
|
5
|
+
|
|
6
|
+
test('progressTtsCacheFileName includes full text even when backend key prefix is long', () => {
|
|
7
|
+
const longBackendKey = [
|
|
8
|
+
'speechswift',
|
|
9
|
+
'server',
|
|
10
|
+
'http://127.0.0.1:18080',
|
|
11
|
+
'cosyvoice',
|
|
12
|
+
'/path/to/VerbalCoding/voice-samples/user-reference.wav',
|
|
13
|
+
'korean',
|
|
14
|
+
'aufklarer/CosyVoice3-0.5B-MLX-4bit',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const first = progressTtsCacheFileName({ backendKeyParts: longBackendKey, text: 'Hermes Agent 호출 시작', ext: 'wav' });
|
|
18
|
+
const second = progressTtsCacheFileName({ backendKeyParts: longBackendKey, text: '터미널 명령 실행', ext: 'wav' });
|
|
19
|
+
|
|
20
|
+
assert.notEqual(first, second);
|
|
21
|
+
assert.match(first, /^[a-f0-9]{64}\.wav$/);
|
|
22
|
+
assert.match(second, /^[a-f0-9]{64}\.wav$/);
|
|
23
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const CATEGORY_LABELS = {
|
|
2
|
+
ko: {
|
|
3
|
+
test: '테스트 실행', edit: '파일 수정', read: '파일 읽기', search: '검색', terminal: '터미널 실행',
|
|
4
|
+
skill: '스킬 사용', browser: '브라우저 확인', tool: '툴 사용', agent: '에이전트 처리', work: '작업 처리',
|
|
5
|
+
},
|
|
6
|
+
en: {
|
|
7
|
+
test: 'running tests', edit: 'editing files', read: 'reading files', search: 'searching', terminal: 'running terminal commands',
|
|
8
|
+
skill: 'loading skills', browser: 'checking the browser', tool: 'using tools', agent: 'calling the agent', work: 'working',
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const CATEGORY_RULES = [
|
|
13
|
+
{ key: 'test', pattern: /(테스트|test|pytest|npm test|node --test)/i },
|
|
14
|
+
{ key: 'edit', pattern: /(파일\s*수정|수정|patch|write_file|쓰기|변경|edit)/i },
|
|
15
|
+
{ key: 'read', pattern: /(파일\s*읽기|read_file|읽기|열람)/i },
|
|
16
|
+
{ key: 'search', pattern: /(웹\s*검색|검색|web_search|search_files|찾기)/i },
|
|
17
|
+
{ key: 'terminal', pattern: /(터미널|명령|terminal|shell|실행)/i },
|
|
18
|
+
{ key: 'skill', pattern: /(스킬|skill)/i },
|
|
19
|
+
{ key: 'browser', pattern: /(브라우저|browser)/i },
|
|
20
|
+
{ key: 'tool', pattern: /(툴|도구|tool)/i },
|
|
21
|
+
{ key: 'agent', pattern: /(에이전트|agent|hermes)/i },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function labelsFor(language = 'ko') {
|
|
25
|
+
return /^en/i.test(String(language || '')) ? CATEGORY_LABELS.en : CATEGORY_LABELS.ko;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function progressCategory(event, { language = 'ko' } = {}) {
|
|
29
|
+
const text = String(event || '').replace(/\s+/g, ' ').trim();
|
|
30
|
+
if (!text) return null;
|
|
31
|
+
const labels = labelsFor(language);
|
|
32
|
+
const found = CATEGORY_RULES.find(rule => rule.pattern.test(text));
|
|
33
|
+
return found ? { key: found.key, label: labels[found.key] } : { key: 'work', label: labels.work };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function progressDetail(event, { language = 'ko' } = {}) {
|
|
37
|
+
const text = String(event || '').replace(/\s+/g, ' ').trim();
|
|
38
|
+
if (!text) return '';
|
|
39
|
+
const category = progressCategory(text, { language });
|
|
40
|
+
let detail = text
|
|
41
|
+
.replace(/^VERBALCODING_PROGRESS:\s*/i, '')
|
|
42
|
+
.replace(/^(파일\s*읽기|파일\s*수정|웹\s*검색|터미널\s*(명령\s*)?실행|테스트\s*실행|스킬\s*사용|툴\s*사용|브라우저\s*확인|에이전트\s*(호출|처리|응답\s*수신)?|Hermes Agent\s*(호출\s*시작|응답\s*수신)?)\s*/i, '')
|
|
43
|
+
.replace(/^(read_file|write_file|patch|web_search|search_files|terminal|skill_view|tool)\s*/i, '')
|
|
44
|
+
.replace(/[`*_#>\[\](){}]/g, '')
|
|
45
|
+
.replace(/\b[a-zA-Z0-9_.\/-]+\.(mjs|js|py|md|json|txt|sh|yaml|yml)\b/g, '')
|
|
46
|
+
.replace(/\s+/g, ' ')
|
|
47
|
+
.trim();
|
|
48
|
+
if (!detail || detail.length < 2) return category?.label || '';
|
|
49
|
+
if (detail.length > 28) detail = detail.slice(0, 27).replace(/[\s,.;:,。]+$/u, '');
|
|
50
|
+
return `${category?.label || labelsFor(language).work} ${detail}`.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatProgressMessage(event, { language = 'ko' } = {}) {
|
|
54
|
+
const text = String(event || '').replace(/\s+/g, ' ').trim();
|
|
55
|
+
if (!text) return '';
|
|
56
|
+
const category = progressCategory(text, { language });
|
|
57
|
+
const detail = progressDetail(text, { language });
|
|
58
|
+
const english = /^en/i.test(String(language || ''));
|
|
59
|
+
const safeDetail = english && /\p{Script=Hangul}/u.test(detail) ? '' : detail;
|
|
60
|
+
const body = english ? (safeDetail || category?.label || 'working') : (safeDetail || category?.label || '작업 처리');
|
|
61
|
+
const emoji = {
|
|
62
|
+
test: '🧪',
|
|
63
|
+
edit: '✏️',
|
|
64
|
+
read: '📖',
|
|
65
|
+
search: '🔎',
|
|
66
|
+
terminal: '💻',
|
|
67
|
+
skill: '📚',
|
|
68
|
+
browser: '🌐',
|
|
69
|
+
agent: '🤖',
|
|
70
|
+
tool: '🛠️',
|
|
71
|
+
work: '⚙️',
|
|
72
|
+
}[category?.key || 'work'] || '⚙️';
|
|
73
|
+
return `${emoji} ${body}`.trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function summarizeProgressEvents(events, { maxCategories = 3, language = 'ko' } = {}) {
|
|
77
|
+
const seenDetails = new Set();
|
|
78
|
+
const details = [];
|
|
79
|
+
const seenCategories = new Set();
|
|
80
|
+
const labels = [];
|
|
81
|
+
for (const event of events || []) {
|
|
82
|
+
const category = progressCategory(event, { language });
|
|
83
|
+
if (category && !seenCategories.has(category.key)) {
|
|
84
|
+
seenCategories.add(category.key);
|
|
85
|
+
labels.push(category.label);
|
|
86
|
+
}
|
|
87
|
+
const detail = progressDetail(event, { language });
|
|
88
|
+
if (detail && !seenDetails.has(detail)) {
|
|
89
|
+
seenDetails.add(detail);
|
|
90
|
+
details.push(detail);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const items = details.length ? details : labels;
|
|
94
|
+
if (!items.length) return '';
|
|
95
|
+
const english = /^en/i.test(String(language || ''));
|
|
96
|
+
if (items.length === 1) return english ? `${items[0]}.` : `${items[0]} 중이야.`;
|
|
97
|
+
const shown = items.slice(0, Math.max(1, maxCategories));
|
|
98
|
+
if (items.length > shown.length) {
|
|
99
|
+
return english ? `${shown.join(', ')}, and ${items.length - shown.length} more.` : `${shown.join(', ')} 외 ${items.length - shown.length}개 작업 중이야.`;
|
|
100
|
+
}
|
|
101
|
+
return english ? `${shown.join(', ')}.` : `${shown.join(', ')} 중이야.`;
|
|
102
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { progressCategory, progressDetail, summarizeProgressEvents, formatProgressMessage } from './progress_speech.mjs';
|
|
5
|
+
|
|
6
|
+
test('progressCategory maps raw verbose events to stable cache-friendly buckets', () => {
|
|
7
|
+
assert.equal(progressCategory('파일 읽기 app-node/main.mjs').key, 'read');
|
|
8
|
+
assert.equal(progressCategory('터미널 명령 실행 npm test').key, 'test');
|
|
9
|
+
assert.equal(progressCategory('웹 검색 VerbalCoding setup').key, 'search');
|
|
10
|
+
assert.equal(progressCategory('스킬 사용 discord-voice-hermes-bridge').key, 'skill');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('progressDetail preserves the meaningful task name after the generic prefix', () => {
|
|
14
|
+
assert.equal(progressDetail('파일 수정 재시작 안내 문구 개선'), '파일 수정 재시작 안내 문구 개선');
|
|
15
|
+
assert.equal(progressDetail('터미널 실행 음성봇 재시작 적용'), '터미널 실행 음성봇 재시작 적용');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('formatProgressMessage renders intermediate text in the selected English language', () => {
|
|
19
|
+
assert.equal(formatProgressMessage('파일 읽기 app-node/main.mjs', { language: 'en' }), '📖 reading files');
|
|
20
|
+
assert.equal(formatProgressMessage('터미널 명령 실행 npm test', { language: 'en' }), '🧪 running tests npm test');
|
|
21
|
+
assert.equal(formatProgressMessage('파일 수정 재시작 안내 문구 개선', { language: 'en' }), '✏️ editing files');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('agent progress events format as visible and speakable status', () => {
|
|
25
|
+
assert.equal(formatProgressMessage('Hermes Agent 호출 시작'), '🤖 에이전트 처리');
|
|
26
|
+
assert.equal(summarizeProgressEvents(['Hermes Agent 호출 시작']), '에이전트 처리 중이야.');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('summarizeProgressEvents batches many raw events with meaningful details', () => {
|
|
30
|
+
const text = summarizeProgressEvents([
|
|
31
|
+
'파일 읽기 app-node/main.mjs',
|
|
32
|
+
'파일 수정 재시작 안내 문구 개선',
|
|
33
|
+
'웹 검색 VerbalCoding setup',
|
|
34
|
+
'터미널 명령 실행 npm test',
|
|
35
|
+
]);
|
|
36
|
+
assert.equal(text, '파일 읽기, 파일 수정 재시작 안내 문구 개선, 검색 VerbalCoding setup 외 1개 작업 중이야.');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('summarizeProgressEvents caps spoken details to keep latency low', () => {
|
|
40
|
+
const text = summarizeProgressEvents([
|
|
41
|
+
'파일 읽기 app-node/main.mjs',
|
|
42
|
+
'웹 검색 VerbalCoding setup',
|
|
43
|
+
'테스트 실행 npm test',
|
|
44
|
+
'스킬 사용 discord-voice-hermes-bridge',
|
|
45
|
+
'브라우저 확인 settings page',
|
|
46
|
+
], { maxCategories: 3 });
|
|
47
|
+
assert.equal(text, '파일 읽기, 검색 VerbalCoding setup, 테스트 실행 npm test 외 2개 작업 중이야.');
|
|
48
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function slugifySessionName(name) {
|
|
5
|
+
const slug = String(name || '')
|
|
6
|
+
.trim()
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/[^\p{L}\p{N}_.-]+/gu, '-')
|
|
9
|
+
.replace(/^-+|-+$/g, '')
|
|
10
|
+
.slice(0, 64);
|
|
11
|
+
return slug || 'default';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadProjectSessions(configPath) {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
17
|
+
return {
|
|
18
|
+
version: 1,
|
|
19
|
+
sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
|
|
20
|
+
channelSessions: parsed.channelSessions && typeof parsed.channelSessions === 'object' ? parsed.channelSessions : {},
|
|
21
|
+
};
|
|
22
|
+
} catch {
|
|
23
|
+
return { version: 1, sessions: {}, channelSessions: {} };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function saveProjectSessions(configPath, state) {
|
|
28
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
29
|
+
fs.writeFileSync(configPath, `${JSON.stringify({
|
|
30
|
+
version: 1,
|
|
31
|
+
sessions: state.sessions || {},
|
|
32
|
+
channelSessions: state.channelSessions || {},
|
|
33
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createProjectSession({ root, state, name, workdir, channelId, voiceChannelId = '', transcriptChannelId = '', mcpContext = '' }) {
|
|
37
|
+
const sessionName = String(name || '').trim();
|
|
38
|
+
if (!sessionName) throw new Error('session name is required');
|
|
39
|
+
const slug = slugifySessionName(sessionName);
|
|
40
|
+
const resolvedWorkdir = path.resolve(String(workdir || root));
|
|
41
|
+
const session = {
|
|
42
|
+
name: sessionName,
|
|
43
|
+
slug,
|
|
44
|
+
workdir: resolvedWorkdir,
|
|
45
|
+
sessionFile: path.join(root, '.agent-sessions', 'hermes', `${slug}.session`),
|
|
46
|
+
transcriptChannelId: String(transcriptChannelId || channelId || '').trim(),
|
|
47
|
+
voiceChannelId: String(voiceChannelId || '').trim(),
|
|
48
|
+
mcpContext: String(mcpContext || '').trim(),
|
|
49
|
+
createdAt: new Date().toISOString(),
|
|
50
|
+
};
|
|
51
|
+
state.sessions[slug] = session;
|
|
52
|
+
if (channelId) state.channelSessions[String(channelId)] = slug;
|
|
53
|
+
if (voiceChannelId) state.channelSessions[String(voiceChannelId)] = slug;
|
|
54
|
+
return session;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function bindProjectSessionToChannel({ state, nameOrSlug, channelId }) {
|
|
58
|
+
const slug = findProjectSessionSlug(state, nameOrSlug);
|
|
59
|
+
if (!slug) throw new Error(`unknown project session: ${nameOrSlug}`);
|
|
60
|
+
state.channelSessions[String(channelId)] = slug;
|
|
61
|
+
return state.sessions[slug];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function findProjectSessionSlug(state, nameOrSlug) {
|
|
65
|
+
const key = slugifySessionName(nameOrSlug);
|
|
66
|
+
if (state.sessions[key]) return key;
|
|
67
|
+
const wanted = String(nameOrSlug || '').trim().toLowerCase();
|
|
68
|
+
return Object.entries(state.sessions || {}).find(([, session]) => String(session.name || '').toLowerCase() === wanted)?.[0] || null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function projectSessionForChannel(state, channelId) {
|
|
72
|
+
const slug = state.channelSessions?.[String(channelId || '')];
|
|
73
|
+
return slug ? state.sessions?.[slug] || null : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function listProjectSessions(state) {
|
|
77
|
+
return Object.values(state.sessions || {}).sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function projectSessionContextText(session) {
|
|
81
|
+
if (!session) return '';
|
|
82
|
+
const parts = [`Project session: ${session.name}`, `Working directory: ${session.workdir}`];
|
|
83
|
+
if (session.mcpContext) parts.push(`MCP/project context: ${session.mcpContext}`);
|
|
84
|
+
return parts.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function tokenParts(rest) {
|
|
88
|
+
return rest.match(/(?:"([^"]+)"|'([^']+)'|(\S+))/g)?.map(part => part.replace(/^['"]|['"]$/g, '')) || [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseVoiceOption(parts) {
|
|
92
|
+
const out = [];
|
|
93
|
+
let voice = '';
|
|
94
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
95
|
+
const part = parts[i];
|
|
96
|
+
if (part === '--voice' || part === '--voice-channel' || part === '--vc') {
|
|
97
|
+
voice = parts[i + 1] || '';
|
|
98
|
+
i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (part.startsWith('--voice=')) {
|
|
102
|
+
voice = part.slice('--voice='.length);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (part.startsWith('--voice-channel=')) {
|
|
106
|
+
voice = part.slice('--voice-channel='.length);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
out.push(part);
|
|
110
|
+
}
|
|
111
|
+
return { parts: out, voice };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function parseProjectSessionCommand(content) {
|
|
115
|
+
const text = String(content || '').trim();
|
|
116
|
+
const match = /^!(?:project-session|session)\s+(new|create|use|bind|attach-voice|voice|status|list|reset)\b\s*(.*)$/i.exec(text);
|
|
117
|
+
if (!match) return parseNaturalVoiceAttachCommand(text);
|
|
118
|
+
const action = match[1].toLowerCase();
|
|
119
|
+
const rest = match[2].trim();
|
|
120
|
+
if (action === 'status' || action === 'list' || action === 'reset') return { action, rest };
|
|
121
|
+
const parsed = parseVoiceOption(tokenParts(rest));
|
|
122
|
+
if (action === 'use' || action === 'bind') return { action: 'use', name: parsed.parts.join(' '), voice: parsed.voice };
|
|
123
|
+
if (action === 'attach-voice' || action === 'voice') {
|
|
124
|
+
return {
|
|
125
|
+
action: 'attach-voice',
|
|
126
|
+
name: parsed.voice ? parsed.parts.join(' ') : '',
|
|
127
|
+
voice: parsed.voice || parsed.parts.join(' '),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
action: 'new',
|
|
132
|
+
name: parsed.parts[0] || '',
|
|
133
|
+
workdir: parsed.parts[1] || '',
|
|
134
|
+
mcpContext: parsed.parts.slice(2).join(' '),
|
|
135
|
+
voice: parsed.voice,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseNaturalVoiceAttachCommand(content) {
|
|
140
|
+
const text = String(content || '').trim();
|
|
141
|
+
if (!text || text.startsWith('!')) return null;
|
|
142
|
+
const compact = text.replace(/\s+/g, ' ');
|
|
143
|
+
const hasVoiceTarget = /(음성|보이스)\s*(채널|세션)|voice\s*(channel|session)|vc\b/i.test(compact);
|
|
144
|
+
const asksAttach = /(붙여\s*(줘|줘라|달라|주세요|주라)|연결\s*(해|해줘|해줘라|해주세요|시켜|시켜줘)|attach|connect)/i.test(compact);
|
|
145
|
+
if (!hasVoiceTarget || !asksAttach) return null;
|
|
146
|
+
const explicit = /(?:--voice(?:-channel)?|--vc)\s+(["']?)([^"'\s][^"']*?)\1(?:\s|$)/i.exec(compact);
|
|
147
|
+
return { action: 'attach-voice', voice: explicit?.[2]?.trim() || '' };
|
|
148
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
bindProjectSessionToChannel,
|
|
9
|
+
createProjectSession,
|
|
10
|
+
loadProjectSessions,
|
|
11
|
+
parseNaturalVoiceAttachCommand,
|
|
12
|
+
parseProjectSessionCommand,
|
|
13
|
+
projectSessionContextText,
|
|
14
|
+
projectSessionForChannel,
|
|
15
|
+
saveProjectSessions,
|
|
16
|
+
slugifySessionName,
|
|
17
|
+
} from './project_sessions.mjs';
|
|
18
|
+
|
|
19
|
+
test('project sessions map Discord text and voice channel ids to isolated Hermes session files', () => {
|
|
20
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-sessions-'));
|
|
21
|
+
const state = loadProjectSessions(path.join(root, 'sessions.json'));
|
|
22
|
+
const session = createProjectSession({ root, state, name: 'LLM Wiki', workdir: '/tmp/llm-wiki', channelId: 'text-1', voiceChannelId: 'voice-1', mcpContext: 'llm-wiki graph' });
|
|
23
|
+
assert.equal(session.slug, 'llm-wiki');
|
|
24
|
+
assert.equal(session.voiceChannelId, 'voice-1');
|
|
25
|
+
assert.equal(session.sessionFile, path.join(root, '.agent-sessions', 'hermes', 'llm-wiki.session'));
|
|
26
|
+
assert.equal(projectSessionForChannel(state, 'text-1').name, 'LLM Wiki');
|
|
27
|
+
assert.equal(projectSessionForChannel(state, 'voice-1').name, 'LLM Wiki');
|
|
28
|
+
assert.match(projectSessionContextText(session), /Working directory: \/tmp\/llm-wiki/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('project sessions persist and can be rebound to another channel', () => {
|
|
32
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-sessions-persist-'));
|
|
33
|
+
const configPath = path.join(root, 'sessions.json');
|
|
34
|
+
const state = loadProjectSessions(configPath);
|
|
35
|
+
createProjectSession({ root, state, name: 'Other Project', workdir: root, channelId: 'a' });
|
|
36
|
+
saveProjectSessions(configPath, state);
|
|
37
|
+
const loaded = loadProjectSessions(configPath);
|
|
38
|
+
bindProjectSessionToChannel({ state: loaded, nameOrSlug: 'other-project', channelId: 'b' });
|
|
39
|
+
assert.equal(projectSessionForChannel(loaded, 'b').name, 'Other Project');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('project session command parser supports new/use/status/list/reset', () => {
|
|
43
|
+
assert.deepEqual(parseProjectSessionCommand('!session new wiki /tmp/wiki graph'), {
|
|
44
|
+
action: 'new', name: 'wiki', workdir: '/tmp/wiki', mcpContext: 'graph', voice: '',
|
|
45
|
+
});
|
|
46
|
+
assert.deepEqual(parseProjectSessionCommand('!project-session use wiki'), { action: 'use', name: 'wiki', voice: '' });
|
|
47
|
+
assert.equal(parseProjectSessionCommand('!session status').action, 'status');
|
|
48
|
+
assert.equal(slugifySessionName('한글 Project!'), '한글-project');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('project session command parser supports explicit voice channel selection', () => {
|
|
52
|
+
assert.deepEqual(parseProjectSessionCommand('!session new wiki /tmp/wiki graph --voice "LLM Wiki Voice"'), {
|
|
53
|
+
action: 'new', name: 'wiki', workdir: '/tmp/wiki', mcpContext: 'graph', voice: 'LLM Wiki Voice',
|
|
54
|
+
});
|
|
55
|
+
assert.deepEqual(parseProjectSessionCommand('!session use wiki --voice=LLM-Voice'), {
|
|
56
|
+
action: 'use', name: 'wiki', voice: 'LLM-Voice',
|
|
57
|
+
});
|
|
58
|
+
assert.deepEqual(parseProjectSessionCommand('!session attach-voice --voice "LLM-Wiki"'), {
|
|
59
|
+
action: 'attach-voice', name: '', voice: 'LLM-Wiki',
|
|
60
|
+
});
|
|
61
|
+
assert.deepEqual(parseProjectSessionCommand('!session voice LLM-Wiki'), {
|
|
62
|
+
action: 'attach-voice', name: '', voice: 'LLM-Wiki',
|
|
63
|
+
});
|
|
64
|
+
assert.deepEqual(parseProjectSessionCommand('!session voice llm-wiki --voice "LLM-Wiki"'), {
|
|
65
|
+
action: 'attach-voice', name: 'llm-wiki', voice: 'LLM-Wiki',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('project session parser treats natural voice attach requests as control commands', () => {
|
|
70
|
+
assert.deepEqual(parseProjectSessionCommand('이 쓰레드에 음성 채널 붙여줘'), {
|
|
71
|
+
action: 'attach-voice', voice: '',
|
|
72
|
+
});
|
|
73
|
+
assert.deepEqual(parseNaturalVoiceAttachCommand('보이스 세션 연결해줘 --voice "LLM Wiki"'), {
|
|
74
|
+
action: 'attach-voice', voice: 'LLM Wiki',
|
|
75
|
+
});
|
|
76
|
+
assert.equal(parseNaturalVoiceAttachCommand('음성 채널 상태가 어때?'), null);
|
|
77
|
+
});
|