verbalcoding 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.env.example +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +157 -0
  4. package/app-node/agent_adapters.mjs +576 -0
  5. package/app-node/agent_adapters.test.mjs +455 -0
  6. package/app-node/agent_contract.mjs +45 -0
  7. package/app-node/barge_in.mjs +148 -0
  8. package/app-node/barge_in.test.mjs +179 -0
  9. package/app-node/bridge_logger.mjs +66 -0
  10. package/app-node/bridge_logger.test.mjs +73 -0
  11. package/app-node/bridge_state.mjs +104 -0
  12. package/app-node/bridge_state.test.mjs +64 -0
  13. package/app-node/cli_install.test.mjs +97 -0
  14. package/app-node/deferred_queue.mjs +12 -0
  15. package/app-node/deferred_queue.test.mjs +20 -0
  16. package/app-node/discord_invite_cli.test.mjs +31 -0
  17. package/app-node/discord_text.mjs +29 -0
  18. package/app-node/discord_text.test.mjs +32 -0
  19. package/app-node/hermes_profiles.mjs +164 -0
  20. package/app-node/hermes_profiles.test.mjs +276 -0
  21. package/app-node/install_config.mjs +263 -0
  22. package/app-node/install_config.test.mjs +205 -0
  23. package/app-node/instance_doctor.mjs +137 -0
  24. package/app-node/instance_doctor.test.mjs +128 -0
  25. package/app-node/instance_profile_lifecycle.mjs +16 -0
  26. package/app-node/instances.mjs +153 -0
  27. package/app-node/instances.test.mjs +102 -0
  28. package/app-node/language_config.mjs +73 -0
  29. package/app-node/language_config.test.mjs +51 -0
  30. package/app-node/latency_metrics.mjs +133 -0
  31. package/app-node/latency_metrics.test.mjs +71 -0
  32. package/app-node/main.mjs +1771 -0
  33. package/app-node/mcp_tools.mjs +198 -0
  34. package/app-node/mcp_tools.test.mjs +39 -0
  35. package/app-node/progress_cache.mjs +7 -0
  36. package/app-node/progress_cache.test.mjs +23 -0
  37. package/app-node/progress_speech.mjs +102 -0
  38. package/app-node/progress_speech.test.mjs +48 -0
  39. package/app-node/project_sessions.mjs +148 -0
  40. package/app-node/project_sessions.test.mjs +77 -0
  41. package/app-node/restart_notice.mjs +57 -0
  42. package/app-node/restart_notice.test.mjs +37 -0
  43. package/app-node/restart_policy.mjs +27 -0
  44. package/app-node/restart_policy.test.mjs +33 -0
  45. package/app-node/text_routing.mjs +8 -0
  46. package/app-node/text_routing.test.mjs +18 -0
  47. package/app-node/tts_backends.mjs +251 -0
  48. package/app-node/tts_backends.test.mjs +400 -0
  49. package/app-node/tts_chunks.mjs +57 -0
  50. package/app-node/tts_chunks.test.mjs +35 -0
  51. package/app-node/tts_prefetch.mjs +38 -0
  52. package/app-node/tts_prefetch.test.mjs +49 -0
  53. package/app-node/tts_settings.mjs +72 -0
  54. package/app-node/tts_settings.test.mjs +127 -0
  55. package/app-node/tts_voice_config.mjs +127 -0
  56. package/app-node/tts_voice_config.test.mjs +64 -0
  57. package/app-node/voice_clone_capture.mjs +76 -0
  58. package/app-node/voice_clone_capture.test.mjs +51 -0
  59. package/app-node/voice_messages.mjs +62 -0
  60. package/app-node/voice_messages.test.mjs +33 -0
  61. package/docs/CONFIGURATION.md +183 -0
  62. package/docs/FRESH_INSTALL.md +193 -0
  63. package/docs/MULTI_INSTANCE.md +183 -0
  64. package/docs/RELEASE.md +72 -0
  65. package/docs/USAGE.md +108 -0
  66. package/docs/assets/figures/verbalcoding-flow.svg +63 -0
  67. package/docs/i18n/README.es.md +121 -0
  68. package/docs/i18n/README.fr.md +121 -0
  69. package/docs/i18n/README.ja.md +121 -0
  70. package/docs/i18n/README.ko.md +121 -0
  71. package/docs/i18n/README.ru.md +121 -0
  72. package/docs/i18n/README.zh.md +121 -0
  73. package/package.json +58 -0
  74. package/run.sh +82 -0
  75. package/scripts/bootstrap_prereqs.sh +193 -0
  76. package/scripts/cli.mjs +369 -0
  77. package/scripts/docker_ubuntu_smoke.sh +76 -0
  78. package/scripts/doctor.mjs +134 -0
  79. package/scripts/install.mjs +108 -0
  80. package/scripts/install.sh +44 -0
  81. package/scripts/mcp-server.mjs +84 -0
  82. package/scripts/openvoice_smoke.py +34 -0
  83. package/scripts/openvoice_synth.py +103 -0
  84. package/scripts/setup_openvoice.sh +34 -0
  85. package/scripts/setup_supertonic.sh +18 -0
@@ -0,0 +1,128 @@
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 { checkInstanceConfigs, tokenFingerprint } from './instance_doctor.mjs';
8
+
9
+ function tempRepo() {
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-instance-doctor-'));
11
+ fs.mkdirSync(path.join(root, 'instances'), { recursive: true });
12
+ return root;
13
+ }
14
+
15
+ function writeInstance(root, name, content) {
16
+ fs.writeFileSync(path.join(root, 'instances', `${name}.env`), content);
17
+ }
18
+
19
+ test('tokenFingerprint is stable and does not expose token contents', () => {
20
+ const fp = tokenFingerprint('super-secret-token');
21
+ assert.match(fp, /^[a-f0-9]{12}$/);
22
+ assert.equal(fp, tokenFingerprint('super-secret-token'));
23
+ assert.equal(fp.includes('secret'), false);
24
+ });
25
+
26
+ test('checkInstanceConfigs returns no issues when no instance env files exist', () => {
27
+ const root = tempRepo();
28
+ const result = checkInstanceConfigs(root);
29
+ assert.deepEqual(result, { errors: [], warnings: [], instances: [] });
30
+ });
31
+
32
+ test('checkInstanceConfigs requires token, voice channel, transcript target, and isolated runtime paths', () => {
33
+ const root = tempRepo();
34
+ writeInstance(root, 'llm-wiki', 'DISCORD_TOKEN=token-a\nAUTO_JOIN_VOICE_CHANNELS=LLM-Wiki\nTRANSCRIPT_CHANNEL_ID=111\nPROJECT_SESSIONS_FILE=config/llm.json\nBRIDGE_LOG_PATH=/tmp/llm.log\nNODE_AUDIO_DEBUG_DIR=/tmp/llm-debug\nHERMES_SESSION_FILE=.agent-sessions/llm.session\n');
35
+ writeInstance(root, 'bad', 'DISCORD_TOKEN=\nAUTO_JOIN_VOICE_CHANNELS=\nTRANSCRIPT_CHANNEL_ID=\nPROJECT_SESSIONS_FILE=config/llm.json\nBRIDGE_LOG_PATH=/tmp/llm.log\nNODE_AUDIO_DEBUG_DIR=/tmp/llm-debug\nHERMES_SESSION_FILE=.agent-sessions/llm.session\n');
36
+
37
+ const result = checkInstanceConfigs(root);
38
+ assert.deepEqual(result.instances.map(i => i.name), ['bad', 'llm-wiki']);
39
+ assert(result.errors.some(e => e.includes('bad: missing DISCORD_TOKEN or DISCORD_BOT_TOKEN')));
40
+ assert(result.errors.some(e => e.includes('bad: missing AUTO_JOIN_VOICE_CHANNELS')));
41
+ assert(result.errors.some(e => e.includes('bad: missing TRANSCRIPT_CHANNEL_ID')));
42
+ assert(result.errors.some(e => e.includes('PROJECT_SESSIONS_FILE collision')));
43
+ assert(result.errors.some(e => e.includes('BRIDGE_LOG_PATH collision')));
44
+ assert(result.errors.some(e => e.includes('NODE_AUDIO_DEBUG_DIR collision')));
45
+ assert(result.warnings.some(e => e.includes('HERMES_SESSION_FILE collision')));
46
+ });
47
+
48
+ test('checkInstanceConfigs detects duplicate token fingerprints without printing tokens', () => {
49
+ const root = tempRepo();
50
+ writeInstance(root, 'one', 'DISCORD_TOKEN=same-token\nAUTO_JOIN_VOICE_CHANNELS=One\nTRANSCRIPT_CHANNEL_ID=1\nPROJECT_SESSIONS_FILE=config/one.json\nBRIDGE_LOG_PATH=/tmp/one.log\nNODE_AUDIO_DEBUG_DIR=/tmp/one-debug\n');
51
+ writeInstance(root, 'two', 'DISCORD_BOT_TOKEN=same-token\nAUTO_JOIN_VOICE_CHANNELS=Two\nTRANSCRIPT_CHANNEL_ID=2\nPROJECT_SESSIONS_FILE=config/two.json\nBRIDGE_LOG_PATH=/tmp/two.log\nNODE_AUDIO_DEBUG_DIR=/tmp/two-debug\n');
52
+
53
+ const result = checkInstanceConfigs(root);
54
+ assert(result.errors.some(e => e.includes('duplicate Discord token fingerprint')));
55
+ assert.equal(result.errors.join('\n').includes('same-token'), false);
56
+ });
57
+
58
+ test('checkInstanceConfigs treats omitted runtime paths as effective default collisions', () => {
59
+ const root = tempRepo();
60
+ writeInstance(root, 'one', 'DISCORD_TOKEN=token-one\nAUTO_JOIN_VOICE_CHANNELS=One\nTRANSCRIPT_CHANNEL_ID=1\n');
61
+ writeInstance(root, 'two', 'DISCORD_TOKEN=token-two\nAUTO_JOIN_VOICE_CHANNELS=Two\nTRANSCRIPT_CHANNEL_ID=2\n');
62
+
63
+ const result = checkInstanceConfigs(root);
64
+ assert(result.errors.some(e => e.includes('PROJECT_SESSIONS_FILE collision')));
65
+ assert(result.errors.some(e => e.includes('NODE_AUDIO_DEBUG_DIR collision')));
66
+ assert(result.warnings.some(e => e.includes('HERMES_SESSION_FILE collision')));
67
+ });
68
+
69
+ test('checkInstanceConfigs warns when HERMES_HOME points at a missing profile', () => {
70
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-doctor-'));
71
+ const instancesDir = path.join(root, 'instances');
72
+ fs.mkdirSync(instancesDir, { recursive: true });
73
+ fs.writeFileSync(path.join(instancesDir, 'llm-wiki.env'), [
74
+ 'DISCORD_TOKEN="t"',
75
+ 'AUTO_JOIN_VOICE_CHANNELS="LLM-Wiki"',
76
+ 'TRANSCRIPT_CHANNEL_ID="1"',
77
+ 'AGENT_CWD="/projects/llm-wiki"',
78
+ 'HERMES_HOME="/nonexistent/.hermes/profiles/llm-wiki"',
79
+ '',
80
+ ].join('\n'));
81
+ const result = checkInstanceConfigs(root, { instancesDir });
82
+ assert.ok(result.warnings.some(w => /HERMES_HOME points at .* missing/.test(w)));
83
+ });
84
+
85
+ test('checkInstanceConfigs errors when profile terminal.cwd differs from AGENT_CWD', () => {
86
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-doctor-'));
87
+ const instancesDir = path.join(root, 'instances');
88
+ const profileDir = path.join(root, '.hermes', 'profiles', 'llm-wiki');
89
+ fs.mkdirSync(instancesDir, { recursive: true });
90
+ fs.mkdirSync(profileDir, { recursive: true });
91
+ fs.writeFileSync(path.join(profileDir, 'config.yaml'), 'terminal:\n cwd: /elsewhere\n');
92
+ fs.writeFileSync(path.join(instancesDir, 'llm-wiki.env'), [
93
+ 'DISCORD_TOKEN="t"',
94
+ 'AUTO_JOIN_VOICE_CHANNELS="LLM-Wiki"',
95
+ 'TRANSCRIPT_CHANNEL_ID="1"',
96
+ 'AGENT_CWD="/projects/llm-wiki"',
97
+ `HERMES_HOME="${profileDir}"`,
98
+ '',
99
+ ].join('\n'));
100
+ const result = checkInstanceConfigs(root, {
101
+ instancesDir,
102
+ readTerminalCwd: () => '/elsewhere',
103
+ });
104
+ assert.ok(result.errors.some(e => /terminal\.cwd .* does not match AGENT_CWD/.test(e)));
105
+ });
106
+
107
+ test('checkInstanceConfigs reads only terminal.cwd, ignoring sibling cwd keys', () => {
108
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-doctor-'));
109
+ const instancesDir = path.join(root, 'instances');
110
+ const profileDir = path.join(root, '.hermes', 'profiles', 'llm-wiki');
111
+ fs.mkdirSync(instancesDir, { recursive: true });
112
+ fs.mkdirSync(profileDir, { recursive: true });
113
+ // git.cwd appears BEFORE terminal.cwd; old regex would match git.cwd.
114
+ fs.writeFileSync(path.join(profileDir, 'config.yaml'),
115
+ 'git:\n cwd: /elsewhere\n' +
116
+ 'terminal:\n cwd: /projects/llm-wiki\n');
117
+ fs.writeFileSync(path.join(instancesDir, 'llm-wiki.env'), [
118
+ 'DISCORD_TOKEN="t"',
119
+ 'AUTO_JOIN_VOICE_CHANNELS="LLM-Wiki"',
120
+ 'TRANSCRIPT_CHANNEL_ID="1"',
121
+ 'AGENT_CWD="/projects/llm-wiki"',
122
+ `HERMES_HOME="${profileDir}"`,
123
+ '',
124
+ ].join('\n'));
125
+ const result = checkInstanceConfigs(root, { instancesDir });
126
+ assert.equal(result.errors.filter(e => /terminal\.cwd/.test(e)).length, 0,
127
+ `expected no terminal.cwd errors but got: ${result.errors.join(' | ')}`);
128
+ });
@@ -0,0 +1,16 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import { ensureHermesProfile as defaultEnsure } from './hermes_profiles.mjs';
5
+
6
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
+
8
+ export async function healInstanceProfileFromEnv(name, instanceEnv, deps = {}) {
9
+ if (!instanceEnv || !instanceEnv.HERMES_HOME) return null;
10
+ const ensure = deps.ensureHermesProfile || defaultEnsure;
11
+ return ensure({
12
+ name,
13
+ workdir: instanceEnv.AGENT_CWD || ROOT,
14
+ projectContext: instanceEnv.AGENT_PROJECT_CONTEXT || '',
15
+ });
16
+ }
@@ -0,0 +1,153 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+
5
+ import { parseKeyValueEnv } from './install_config.mjs';
6
+
7
+ export function instanceNameFromEnvPath(file) {
8
+ return path.basename(file).replace(/\.env$/i, '');
9
+ }
10
+
11
+ export function listInstanceEnvFiles(dir) {
12
+ if (!fs.existsSync(dir)) return [];
13
+ return fs.readdirSync(dir)
14
+ .filter(name => name.endsWith('.env') && name !== 'example.env')
15
+ .sort()
16
+ .map(name => path.join(dir, name));
17
+ }
18
+
19
+ export function instanceRuntimePaths(root, name) {
20
+ return {
21
+ pidFile: path.join(root, '.run', 'instances', `${name}.pid`),
22
+ defaultLogPath: `/tmp/verbalcoding-${name}.log`,
23
+ };
24
+ }
25
+
26
+ export function readInstanceEnv(file) {
27
+ try {
28
+ return parseKeyValueEnv(fs.readFileSync(file, 'utf8'));
29
+ } catch {
30
+ return {};
31
+ }
32
+ }
33
+
34
+ export function readPidFile(pidFile) {
35
+ try {
36
+ const value = Number.parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
37
+ return Number.isInteger(value) && value > 0 ? value : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export function isPidAlive(pid) {
44
+ if (!Number.isInteger(pid) || pid <= 0) return false;
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ } catch (err) {
49
+ return err?.code === 'EPERM';
50
+ }
51
+ }
52
+
53
+ export function statusForInstance(root, envPath) {
54
+ const name = instanceNameFromEnvPath(envPath);
55
+ const runtime = instanceRuntimePaths(root, name);
56
+ const env = readInstanceEnv(envPath);
57
+ const pid = readPidFile(runtime.pidFile);
58
+ const alive = pid !== null && isPidAlive(pid);
59
+ return {
60
+ name,
61
+ envPath,
62
+ pid: alive ? pid : null,
63
+ status: alive ? 'running' : 'stopped',
64
+ logPath: env.BRIDGE_LOG_PATH || runtime.defaultLogPath,
65
+ pidFile: runtime.pidFile,
66
+ };
67
+ }
68
+
69
+ export function listInstanceStatuses(root, instancesDir = path.join(root, 'instances')) {
70
+ return listInstanceEnvFiles(instancesDir).map(envPath => statusForInstance(root, envPath));
71
+ }
72
+
73
+ export function resolveInstanceEnvPath(root, name) {
74
+ const clean = String(name || '').trim();
75
+ if (!/^[A-Za-z0-9_.-]+$/.test(clean) || clean.startsWith('.') || clean.endsWith('.env')) {
76
+ throw new Error(`invalid instance name: ${name}`);
77
+ }
78
+ const candidate = path.join(root, 'instances', `${clean}.env`);
79
+ if (!fs.existsSync(candidate)) {
80
+ throw new Error(`instance env file not found: ${candidate}`);
81
+ }
82
+ return candidate;
83
+ }
84
+
85
+ export function startInstance(root, name, options = {}) {
86
+ const envPath = resolveInstanceEnvPath(root, name);
87
+ const instanceName = instanceNameFromEnvPath(envPath);
88
+ const runtime = instanceRuntimePaths(root, instanceName);
89
+ const currentPid = readPidFile(runtime.pidFile);
90
+ if (currentPid && isPidAlive(currentPid)) {
91
+ throw new Error(`${instanceName} is already running pid=${currentPid}`);
92
+ }
93
+ fs.mkdirSync(path.dirname(runtime.pidFile), { recursive: true });
94
+ const instanceEnv = readInstanceEnv(envPath);
95
+ const child = spawn(path.join(root, 'run.sh'), [envPath], {
96
+ cwd: root,
97
+ detached: true,
98
+ stdio: ['ignore', 'ignore', 'ignore'],
99
+ env: {
100
+ ...process.env,
101
+ BRIDGE_LOG_PATH: instanceEnv.BRIDGE_LOG_PATH || runtime.defaultLogPath,
102
+ NODE_AUDIO_DEBUG_DIR: instanceEnv.NODE_AUDIO_DEBUG_DIR || `/tmp/verbalcoding-${instanceName}-debug`,
103
+ PROJECT_SESSIONS_FILE: instanceEnv.PROJECT_SESSIONS_FILE || path.join(root, 'config', `project-sessions.${instanceName}.json`),
104
+ HERMES_SESSION_FILE: instanceEnv.HERMES_SESSION_FILE || path.join(root, '.agent-sessions', 'hermes', `${instanceName}.session`),
105
+ VERBALCODING_INSTANCE_NAME: instanceName,
106
+ VERBALCODING_INSTANCE_ENV: envPath,
107
+ ...(options.env || {}),
108
+ },
109
+ });
110
+ child.unref();
111
+ fs.writeFileSync(runtime.pidFile, `${child.pid}\n`);
112
+ const status = statusForInstance(root, envPath);
113
+ return { ...status, pid: child.pid, status: 'running' };
114
+ }
115
+
116
+ function sleep(ms) {
117
+ return new Promise(resolve => setTimeout(resolve, ms));
118
+ }
119
+
120
+ export async function stopInstance(root, name, options = {}) {
121
+ const envPath = resolveInstanceEnvPath(root, name);
122
+ const instanceName = instanceNameFromEnvPath(envPath);
123
+ const runtime = instanceRuntimePaths(root, instanceName);
124
+ const pid = readPidFile(runtime.pidFile);
125
+ if (!pid || !isPidAlive(pid)) {
126
+ fs.rmSync(runtime.pidFile, { force: true });
127
+ return { name: instanceName, pid: null, status: 'stopped', alreadyStopped: true };
128
+ }
129
+ const timeoutMs = options.timeoutMs ?? 10_000;
130
+ const intervalMs = options.intervalMs ?? 250;
131
+ try {
132
+ process.kill(pid, 'SIGTERM');
133
+ } catch (err) {
134
+ if (err?.code !== 'ESRCH') throw err;
135
+ }
136
+ const deadline = Date.now() + timeoutMs;
137
+ while (Date.now() < deadline) {
138
+ if (!isPidAlive(pid)) {
139
+ fs.rmSync(runtime.pidFile, { force: true });
140
+ return { name: instanceName, pid, status: 'stopped', killed: false };
141
+ }
142
+ await sleep(intervalMs);
143
+ }
144
+ if (isPidAlive(pid)) {
145
+ try {
146
+ process.kill(pid, 'SIGKILL');
147
+ } catch (err) {
148
+ if (err?.code !== 'ESRCH') throw err;
149
+ }
150
+ }
151
+ fs.rmSync(runtime.pidFile, { force: true });
152
+ return { name: instanceName, pid, status: 'stopped', killed: true };
153
+ }
@@ -0,0 +1,102 @@
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
+ instanceNameFromEnvPath,
9
+ instanceRuntimePaths,
10
+ isPidAlive,
11
+ listInstanceEnvFiles,
12
+ readPidFile,
13
+ resolveInstanceEnvPath,
14
+ statusForInstance,
15
+ } from './instances.mjs';
16
+
17
+ function tempDir() {
18
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'vc-instances-'));
19
+ }
20
+
21
+ test('listInstanceEnvFiles finds env files except example', () => {
22
+ const dir = tempDir();
23
+ fs.writeFileSync(path.join(dir, 'example.env'), '');
24
+ fs.writeFileSync(path.join(dir, 'llm-wiki.env'), '');
25
+ fs.writeFileSync(path.join(dir, 'verbalcoding.env'), '');
26
+ fs.writeFileSync(path.join(dir, 'notes.txt'), '');
27
+
28
+ assert.deepEqual(listInstanceEnvFiles(dir).map(p => path.basename(p)), ['llm-wiki.env', 'verbalcoding.env']);
29
+ });
30
+
31
+ test('listInstanceEnvFiles returns empty for missing directory', () => {
32
+ const dir = path.join(tempDir(), 'missing');
33
+ assert.deepEqual(listInstanceEnvFiles(dir), []);
34
+ });
35
+
36
+ test('instanceNameFromEnvPath strips only the env extension', () => {
37
+ assert.equal(instanceNameFromEnvPath('/repo/instances/llm-wiki.env'), 'llm-wiki');
38
+ assert.equal(instanceNameFromEnvPath('/repo/instances/foo.local.env'), 'foo.local');
39
+ });
40
+
41
+ test('instanceRuntimePaths derives pid and log paths', () => {
42
+ assert.deepEqual(instanceRuntimePaths('/repo', 'llm-wiki'), {
43
+ pidFile: '/repo/.run/instances/llm-wiki.pid',
44
+ defaultLogPath: '/tmp/verbalcoding-llm-wiki.log',
45
+ });
46
+ });
47
+
48
+ test('readPidFile returns null for missing or invalid pid file', () => {
49
+ const dir = tempDir();
50
+ assert.equal(readPidFile(path.join(dir, 'missing.pid')), null);
51
+ const file = path.join(dir, 'bad.pid');
52
+ fs.writeFileSync(file, 'not-a-pid');
53
+ assert.equal(readPidFile(file), null);
54
+ });
55
+
56
+ test('readPidFile parses a numeric pid', () => {
57
+ const dir = tempDir();
58
+ const file = path.join(dir, 'ok.pid');
59
+ fs.writeFileSync(file, '12345\n');
60
+ assert.equal(readPidFile(file), 12345);
61
+ });
62
+
63
+ test('isPidAlive treats current process as alive and impossible pid as dead', () => {
64
+ assert.equal(isPidAlive(process.pid), true);
65
+ assert.equal(isPidAlive(99999999), false);
66
+ });
67
+
68
+ test('statusForInstance reports stopped when no pid file exists', () => {
69
+ const root = tempDir();
70
+ const envPath = path.join(root, 'instances', 'llm-wiki.env');
71
+ fs.mkdirSync(path.dirname(envPath), { recursive: true });
72
+ fs.writeFileSync(envPath, 'INSTANCE_NAME=llm-wiki\n');
73
+
74
+ assert.deepEqual(statusForInstance(root, envPath), {
75
+ name: 'llm-wiki',
76
+ envPath,
77
+ pid: null,
78
+ status: 'stopped',
79
+ logPath: '/tmp/verbalcoding-llm-wiki.log',
80
+ pidFile: path.join(root, '.run', 'instances', 'llm-wiki.pid'),
81
+ });
82
+ });
83
+
84
+ test('statusForInstance prefers BRIDGE_LOG_PATH from env file', () => {
85
+ const root = tempDir();
86
+ const envPath = path.join(root, 'instances', 'verbalcoding.env');
87
+ fs.mkdirSync(path.dirname(envPath), { recursive: true });
88
+ fs.writeFileSync(envPath, 'BRIDGE_LOG_PATH=/tmp/custom-vc.log\n');
89
+
90
+ const status = statusForInstance(root, envPath);
91
+ assert.equal(status.logPath, '/tmp/custom-vc.log');
92
+ });
93
+
94
+ test('resolveInstanceEnvPath accepts only safe instance names under instances directory', () => {
95
+ const root = tempDir();
96
+ fs.mkdirSync(path.join(root, 'instances'), { recursive: true });
97
+ fs.writeFileSync(path.join(root, 'instances', 'llm-wiki.env'), '');
98
+ assert.equal(resolveInstanceEnvPath(root, 'llm-wiki'), path.join(root, 'instances', 'llm-wiki.env'));
99
+ assert.throws(() => resolveInstanceEnvPath(root, '../.env'), /invalid instance name/);
100
+ assert.throws(() => resolveInstanceEnvPath(root, '/tmp/other.env'), /invalid instance name/);
101
+ assert.throws(() => resolveInstanceEnvPath(root, 'bad name'), /invalid instance name/);
102
+ });
@@ -0,0 +1,73 @@
1
+ export const LANGUAGE_PRESETS = {
2
+ ko: {
3
+ key: 'ko',
4
+ label: 'Korean',
5
+ sttLanguage: 'ko',
6
+ voiceLanguage: 'ko',
7
+ ttsVoice: 'ko-KR-InJoonNeural',
8
+ },
9
+ en: {
10
+ key: 'en',
11
+ label: 'English',
12
+ sttLanguage: 'en',
13
+ voiceLanguage: 'en',
14
+ ttsVoice: 'en-US-GuyNeural',
15
+ },
16
+ auto: {
17
+ key: 'auto',
18
+ label: 'Auto-detect STT, English voice',
19
+ sttLanguage: 'auto',
20
+ voiceLanguage: 'en',
21
+ ttsVoice: 'en-US-GuyNeural',
22
+ },
23
+ };
24
+
25
+ export function normalizeLanguageKey(value, fallback = 'ko') {
26
+ const raw = String(value || '').trim().toLowerCase();
27
+ if (['korean', 'kr', 'kor', '한국어', 'ko-kr'].includes(raw)) return 'ko';
28
+ if (['english', 'eng', 'en-us', 'en-gb', '영어'].includes(raw)) return 'en';
29
+ if (['auto', 'detect', 'auto-detect', '자동', '자동감지'].includes(raw)) return 'auto';
30
+ return LANGUAGE_PRESETS[raw] ? raw : fallback;
31
+ }
32
+
33
+ export function languagePreset(value, fallback = 'ko') {
34
+ return LANGUAGE_PRESETS[normalizeLanguageKey(value, fallback)];
35
+ }
36
+
37
+ export function shouldPassWhisperLanguage(language) {
38
+ const raw = String(language || '').trim().toLowerCase();
39
+ return raw && raw !== 'auto' && raw !== 'detect';
40
+ }
41
+
42
+ export function applyLanguagePreset(env = {}, language = 'ko') {
43
+ const preset = languagePreset(language);
44
+ return {
45
+ ...env,
46
+ VOICE_LANGUAGE: preset.voiceLanguage,
47
+ WHISPER_CPP_LANGUAGE: preset.sttLanguage,
48
+ STT_LANGUAGE: preset.sttLanguage,
49
+ TTS_BACKEND: env.TTS_BACKEND || 'edge',
50
+ TTS_VOICE: preset.ttsVoice,
51
+ };
52
+ }
53
+
54
+ export function languageStatus(values = {}) {
55
+ const stt = values.WHISPER_CPP_LANGUAGE || values.STT_LANGUAGE || 'ko';
56
+ const voiceLanguage = values.VOICE_LANGUAGE || (/^en/i.test(String(values.TTS_VOICE || '')) ? 'en' : 'ko');
57
+ const ttsVoice = values.TTS_VOICE || 'ko-KR-InJoonNeural';
58
+ return { sttLanguage: stt, voiceLanguage, ttsVoice };
59
+ }
60
+
61
+ export function voiceLanguageCommandFromTranscript(text) {
62
+ const raw = String(text || '').trim();
63
+ if (!raw) return null;
64
+ const compact = raw.toLowerCase().replace(/\s+/g, '');
65
+ const looksLikeCommand = /\b(change|switch|set)\b.*\b(language|voice|stt|speech)\b/i.test(raw)
66
+ || /\b(language|voice|stt|speech)\b.*\b(to|as)\b/i.test(raw)
67
+ || /(언어|음성|말|말투).*(바꿔|변경|설정|해줘)|로말해|로바꿔/u.test(compact);
68
+ if (!looksLikeCommand) return null;
69
+ if (/(auto.?detect|detect.?language|automatic|자동|자동감지)/iu.test(raw)) return { language: 'auto' };
70
+ if (/(english|영어|en-us|eng)/iu.test(raw)) return { language: 'en' };
71
+ if (/(korean|한국어|한글|ko-kr|kor)/iu.test(raw)) return { language: 'ko' };
72
+ return null;
73
+ }
@@ -0,0 +1,51 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ applyLanguagePreset,
6
+ languagePreset,
7
+ languageStatus,
8
+ normalizeLanguageKey,
9
+ shouldPassWhisperLanguage,
10
+ voiceLanguageCommandFromTranscript,
11
+ } from './language_config.mjs';
12
+
13
+ test('normalizeLanguageKey accepts common English, Korean, and auto aliases', () => {
14
+ assert.equal(normalizeLanguageKey('english'), 'en');
15
+ assert.equal(normalizeLanguageKey('한국어'), 'ko');
16
+ assert.equal(normalizeLanguageKey('auto-detect'), 'auto');
17
+ });
18
+
19
+ test('voiceLanguageCommandFromTranscript detects voice language changes', () => {
20
+ assert.deepEqual(voiceLanguageCommandFromTranscript('change language to English'), { language: 'en' });
21
+ assert.deepEqual(voiceLanguageCommandFromTranscript('영어로 바꿔'), { language: 'en' });
22
+ assert.deepEqual(voiceLanguageCommandFromTranscript('한국어로 말해'), { language: 'ko' });
23
+ assert.deepEqual(voiceLanguageCommandFromTranscript('switch to auto detect language'), { language: 'auto' });
24
+ assert.equal(voiceLanguageCommandFromTranscript('do we have a language option?'), null);
25
+ });
26
+
27
+ test('language presets update STT and TTS together', () => {
28
+ const en = applyLanguagePreset({ TTS_RATE: '+0%' }, 'en');
29
+ assert.equal(en.WHISPER_CPP_LANGUAGE, 'en');
30
+ assert.equal(en.STT_LANGUAGE, 'en');
31
+ assert.equal(en.VOICE_LANGUAGE, 'en');
32
+ assert.equal(en.TTS_VOICE, 'en-US-GuyNeural');
33
+
34
+ const ko = languagePreset('ko');
35
+ assert.equal(ko.ttsVoice, 'ko-KR-InJoonNeural');
36
+ });
37
+
38
+ test('auto language omits forced whisper language while keeping a voice language', () => {
39
+ const auto = applyLanguagePreset({}, 'auto');
40
+ assert.equal(auto.WHISPER_CPP_LANGUAGE, 'auto');
41
+ assert.equal(shouldPassWhisperLanguage(auto.WHISPER_CPP_LANGUAGE), false);
42
+ assert.equal(shouldPassWhisperLanguage('en'), true);
43
+ });
44
+
45
+ test('languageStatus summarizes current env values', () => {
46
+ assert.deepEqual(languageStatus({ WHISPER_CPP_LANGUAGE: 'en', VOICE_LANGUAGE: 'en', TTS_VOICE: 'en-US-GuyNeural' }), {
47
+ sttLanguage: 'en',
48
+ voiceLanguage: 'en',
49
+ ttsVoice: 'en-US-GuyNeural',
50
+ });
51
+ });
@@ -0,0 +1,133 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ function roundMs(value) {
5
+ return Math.max(0, Math.round(Number(value) || 0));
6
+ }
7
+
8
+ function percentile(values, p) {
9
+ const xs = values.filter(Number.isFinite).sort((a, b) => a - b);
10
+ if (!xs.length) return 0;
11
+ const idx = Math.min(xs.length - 1, Math.ceil((p / 100) * xs.length) - 1);
12
+ return xs[idx];
13
+ }
14
+
15
+ export function appendJsonl(file, record) {
16
+ fs.mkdirSync(path.dirname(file), { recursive: true });
17
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
18
+ }
19
+
20
+ export function readJsonlRecords(file, { limit = 200 } = {}) {
21
+ try {
22
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean);
23
+ return lines.slice(Math.max(0, lines.length - limit)).map(line => JSON.parse(line));
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ export function createLatencyTurn({ id, userId, startedAtMs = Date.now(), now = Date.now, writeRecord = () => {} } = {}) {
30
+ const marks = { start: startedAtMs };
31
+ const durations = {};
32
+ const meta = {};
33
+ let finished = false;
34
+
35
+ return {
36
+ id,
37
+ userId,
38
+ marks,
39
+ durations,
40
+ meta,
41
+ mark(name, atMs = now()) {
42
+ marks[name] = atMs;
43
+ return atMs;
44
+ },
45
+ stage(name, durationMs, extra = {}) {
46
+ durations[`${name}_ms`] = roundMs(durationMs);
47
+ Object.assign(meta, extra);
48
+ },
49
+ addMeta(extra = {}) {
50
+ Object.assign(meta, extra);
51
+ },
52
+ finish(extra = {}) {
53
+ if (finished) return null;
54
+ finished = true;
55
+ const endedAtMs = extra.endedAtMs ?? now();
56
+ marks.end = endedAtMs;
57
+ if (marks.voice_first_packet && marks.voice_segment_end) {
58
+ durations.voice_capture_ms = roundMs(marks.voice_segment_end - marks.voice_first_packet);
59
+ }
60
+ if (marks.voice_segment_end && marks.utterance_flush) {
61
+ durations.utterance_idle_wait_ms = roundMs(marks.utterance_flush - marks.voice_segment_end);
62
+ }
63
+ durations.total_ms = roundMs(endedAtMs - marks.start);
64
+ const record = {
65
+ id,
66
+ userId,
67
+ ts: new Date(endedAtMs).toISOString(),
68
+ status: extra.status || 'ok',
69
+ durations,
70
+ meta,
71
+ };
72
+ if (extra.error) record.error = String(extra.error).slice(0, 500);
73
+ writeRecord(record);
74
+ return record;
75
+ },
76
+ };
77
+ }
78
+
79
+ export function summarizeLatencyRecords(records = []) {
80
+ const statuses = {};
81
+ const durationKeys = new Set();
82
+ for (const r of records) {
83
+ statuses[r.status || 'unknown'] = (statuses[r.status || 'unknown'] || 0) + 1;
84
+ for (const key of Object.keys(r.durations || {})) durationKeys.add(key);
85
+ }
86
+ const durations = {};
87
+ for (const key of [...durationKeys].sort()) {
88
+ const values = records.map(r => Number(r.durations?.[key])).filter(Number.isFinite);
89
+ if (!values.length) continue;
90
+ const sum = values.reduce((a, b) => a + b, 0);
91
+ durations[key] = {
92
+ avg: roundMs(sum / values.length),
93
+ p50: percentile(values, 50),
94
+ p95: percentile(values, 95),
95
+ min: Math.min(...values),
96
+ max: Math.max(...values),
97
+ n: values.length,
98
+ };
99
+ }
100
+ return {
101
+ count: records.length,
102
+ ok: statuses.ok || 0,
103
+ statuses,
104
+ durations,
105
+ };
106
+ }
107
+
108
+ export function formatLatencySummary(summary) {
109
+ if (!summary?.count) return 'latency 기록이 아직 없어.';
110
+ const names = [
111
+ 'total_ms',
112
+ 'voice_capture_ms',
113
+ 'utterance_idle_wait_ms',
114
+ 'stt_ms',
115
+ 'agent_ms',
116
+ 'tts_synth_ms',
117
+ 'tts_play_ms',
118
+ 'tts_total_ms',
119
+ ];
120
+ const lines = [`처리량: ${summary.count}턴, 성공 ${summary.ok}턴`];
121
+ for (const name of names) {
122
+ const s = summary.durations[name];
123
+ if (!s) continue;
124
+ const label = name.replace(/_ms$/, '');
125
+ lines.push(`${label}: avg ${s.avg}ms / p95 ${s.p95}ms / max ${s.max}ms (n=${s.n})`);
126
+ }
127
+ const otherStatuses = Object.entries(summary.statuses)
128
+ .filter(([k]) => k !== 'ok')
129
+ .map(([k, v]) => `${k}:${v}`)
130
+ .join(', ');
131
+ if (otherStatuses) lines.push(`기타 상태: ${otherStatuses}`);
132
+ return lines.join('\n');
133
+ }