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,179 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
bargeInThresholdsForMode,
|
|
6
|
+
createLiveBargeInMonitor,
|
|
7
|
+
isBargeInCandidate,
|
|
8
|
+
isRepeatedNoiseTranscript,
|
|
9
|
+
isExplicitBargeInTranscript,
|
|
10
|
+
shouldUseLivePlaybackBargeIn,
|
|
11
|
+
pcm16StereoLevels,
|
|
12
|
+
sensitivityModeFromTranscript,
|
|
13
|
+
} from './barge_in.mjs';
|
|
14
|
+
|
|
15
|
+
function pcmWithConstantSample(sample, sampleCount) {
|
|
16
|
+
const buf = Buffer.alloc(sampleCount * 2);
|
|
17
|
+
for (let i = 0; i < sampleCount; i += 1) buf.writeInt16LE(sample, i * 2);
|
|
18
|
+
return buf;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('isBargeInCandidate requires enough buffered audio before confirming', () => {
|
|
22
|
+
assert.equal(
|
|
23
|
+
isBargeInCandidate(100, { meanDb: -10, maxDb: -3 }, { minBytes: 200, minMeanDb: -35, minMaxDb: -18 }),
|
|
24
|
+
false,
|
|
25
|
+
);
|
|
26
|
+
assert.equal(
|
|
27
|
+
isBargeInCandidate(200, { meanDb: -10, maxDb: -3 }, { minBytes: 200, minMeanDb: -35, minMaxDb: -18 }),
|
|
28
|
+
true,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('pcm16StereoLevels reports silence as negative infinity', () => {
|
|
33
|
+
const levels = pcm16StereoLevels(Buffer.alloc(480));
|
|
34
|
+
assert.equal(levels.meanDb, -Infinity);
|
|
35
|
+
assert.equal(levels.maxDb, -Infinity);
|
|
36
|
+
assert.equal(levels.sampleCount, 240);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('createLiveBargeInMonitor confirms while audio stream is still active', () => {
|
|
40
|
+
const events = [];
|
|
41
|
+
const monitor = createLiveBargeInMonitor({
|
|
42
|
+
minBytes: 400,
|
|
43
|
+
minMeanDb: -35,
|
|
44
|
+
minMaxDb: -18,
|
|
45
|
+
onConfirm: event => events.push(event),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal(monitor.push(pcmWithConstantSample(12000, 100)), false);
|
|
49
|
+
assert.equal(events.length, 0);
|
|
50
|
+
assert.equal(monitor.push(pcmWithConstantSample(12000, 100)), true);
|
|
51
|
+
|
|
52
|
+
assert.equal(events.length, 1);
|
|
53
|
+
assert.equal(events[0].pcmBytes, 400);
|
|
54
|
+
assert.ok(events[0].levels.meanDb > -12);
|
|
55
|
+
assert.equal(monitor.confirmed, true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('playback barge-in requires a longer deliberate utterance by default', () => {
|
|
59
|
+
const pcmBytes = 48000 * 2 * 2 * 0.9;
|
|
60
|
+
const events = [];
|
|
61
|
+
const monitor = createLiveBargeInMonitor({
|
|
62
|
+
minBytes: pcmBytes,
|
|
63
|
+
minMeanDb: -36,
|
|
64
|
+
minMaxDb: -18,
|
|
65
|
+
requireBoth: true,
|
|
66
|
+
onConfirm: event => events.push(event),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
assert.equal(monitor.push(pcmWithConstantSample(5000, pcmBytes / 3 / 2)), false);
|
|
70
|
+
assert.equal(events.length, 0);
|
|
71
|
+
|
|
72
|
+
assert.equal(monitor.push(pcmWithConstantSample(5000, pcmBytes / 3 / 2)), false);
|
|
73
|
+
assert.equal(events.length, 0);
|
|
74
|
+
|
|
75
|
+
assert.equal(monitor.push(pcmWithConstantSample(5000, pcmBytes / 3 / 2)), true);
|
|
76
|
+
assert.equal(events.length, 1);
|
|
77
|
+
assert.equal(events[0].pcmBytes, pcmBytes);
|
|
78
|
+
assert.ok(events[0].levels.meanDb > -36);
|
|
79
|
+
assert.ok(events[0].levels.maxDb > -18);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('playback barge-in requireBoth ignores short clicky peaks without sustained RMS', () => {
|
|
83
|
+
assert.equal(isBargeInCandidate(400, { meanDb: -50, maxDb: -3 }, { minBytes: 400, minMeanDb: -36, minMaxDb: -18, requireBoth: true }), false);
|
|
84
|
+
assert.equal(isBargeInCandidate(400, { meanDb: -35, maxDb: -17 }, { minBytes: 400, minMeanDb: -36, minMaxDb: -18, requireBoth: true }), true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('createLiveBargeInMonitor ignores low-volume candidates after minimum duration', () => {
|
|
88
|
+
const events = [];
|
|
89
|
+
const monitor = createLiveBargeInMonitor({
|
|
90
|
+
minBytes: 400,
|
|
91
|
+
minMeanDb: -35,
|
|
92
|
+
minMaxDb: -18,
|
|
93
|
+
onConfirm: event => events.push(event),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
assert.equal(monitor.push(pcmWithConstantSample(120, 200)), false);
|
|
97
|
+
|
|
98
|
+
assert.equal(events.length, 0);
|
|
99
|
+
assert.equal(monitor.confirmed, false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('default barge-in thresholds are intentionally less sensitive than utterance detection', () => {
|
|
103
|
+
const normal = bargeInThresholdsForMode('normal');
|
|
104
|
+
const conservative = bargeInThresholdsForMode('conservative');
|
|
105
|
+
|
|
106
|
+
assert.equal(normal.minBytes, 48000 * 2 * 2 * 1.4);
|
|
107
|
+
assert.equal(normal.minMeanDb, -30);
|
|
108
|
+
assert.equal(normal.minMaxDb, -14);
|
|
109
|
+
assert.equal(conservative.minBytes, 48000 * 2 * 2 * 1.8);
|
|
110
|
+
assert.equal(conservative.minMeanDb, -27);
|
|
111
|
+
assert.equal(conservative.minMaxDb, -12);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('conservative sensitivity requires a longer barge-in than normal mode', () => {
|
|
115
|
+
const normal = bargeInThresholdsForMode('normal', {
|
|
116
|
+
minSeconds: 0.9,
|
|
117
|
+
minMeanDb: -35,
|
|
118
|
+
minMaxDb: -18,
|
|
119
|
+
});
|
|
120
|
+
const conservative = bargeInThresholdsForMode('conservative', {
|
|
121
|
+
minSeconds: 0.9,
|
|
122
|
+
minMeanDb: -35,
|
|
123
|
+
minMaxDb: -18,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.equal(normal.minBytes, 48000 * 2 * 2 * 0.9);
|
|
127
|
+
assert.equal(conservative.minBytes, 48000 * 2 * 2 * 1.8);
|
|
128
|
+
assert.equal(conservative.minMeanDb, -27);
|
|
129
|
+
assert.equal(conservative.minMaxDb, -12);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('sensitivityModeFromTranscript detects temporary outdoor and indoor mode requests', () => {
|
|
133
|
+
assert.deepEqual(sensitivityModeFromTranscript('밖이라 시끄러워 감도 낮춰'), { mode: 'conservative', reason: 'outdoor' });
|
|
134
|
+
assert.deepEqual(sensitivityModeFromTranscript('외부 보수 모드 켜'), { mode: 'conservative', reason: 'outdoor' });
|
|
135
|
+
assert.deepEqual(sensitivityModeFromTranscript('이제 실내니까 평소 감도로 해'), { mode: 'normal', reason: 'indoor' });
|
|
136
|
+
assert.equal(sensitivityModeFromTranscript('그냥 다음 작업 해줘'), null);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('sensitivityModeFromTranscript ignores complaints that mention sensitivity mode', () => {
|
|
140
|
+
assert.equal(sensitivityModeFromTranscript('누가 외부 보수 모드로 바꾸랬어?'), null);
|
|
141
|
+
assert.equal(sensitivityModeFromTranscript('끼어들기 민감도 설정을 너무 멍청하게 해놨잖아'), null);
|
|
142
|
+
assert.equal(sensitivityModeFromTranscript('외부 보수 모드로 바꾸라는 말 아니야'), null);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('isRepeatedNoiseTranscript rejects short repeated syllable noise', () => {
|
|
146
|
+
assert.equal(isRepeatedNoiseTranscript('너덜너덜너덜'), true);
|
|
147
|
+
assert.equal(isRepeatedNoiseTranscript('쒸'), true);
|
|
148
|
+
assert.equal(isRepeatedNoiseTranscript('잠깐 잠깐 멈춰'), false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('isRepeatedNoiseTranscript keeps common short valid Korean utterances', () => {
|
|
152
|
+
assert.equal(isRepeatedNoiseTranscript('안녕!'), false);
|
|
153
|
+
assert.equal(isRepeatedNoiseTranscript('네'), false);
|
|
154
|
+
assert.equal(isRepeatedNoiseTranscript('응'), false);
|
|
155
|
+
assert.equal(isRepeatedNoiseTranscript('그래'), false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('isRepeatedNoiseTranscript keeps real English sentences with repeated words or low unique ratio', () => {
|
|
159
|
+
assert.equal(isRepeatedNoiseTranscript('Fuck you. Fuck you.'), false);
|
|
160
|
+
assert.equal(isRepeatedNoiseTranscript("The problem is, you ignore my speech sometimes. That's the fucking problem."), false);
|
|
161
|
+
assert.equal(isRepeatedNoiseTranscript("I found the problem. You don't detect my speech."), false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('isExplicitBargeInTranscript only treats clear stop phrases as processing interrupts', () => {
|
|
165
|
+
assert.equal(isExplicitBargeInTranscript('잠깐 잠깐 멈춰'), true);
|
|
166
|
+
assert.equal(isExplicitBargeInTranscript('그만 말해'), true);
|
|
167
|
+
assert.equal(isExplicitBargeInTranscript('stop talking'), true);
|
|
168
|
+
assert.equal(isExplicitBargeInTranscript('shut up'), true);
|
|
169
|
+
assert.equal(isExplicitBargeInTranscript('cancel this'), true);
|
|
170
|
+
assert.equal(isExplicitBargeInTranscript('시청해 주셔서 감사합니다'), false);
|
|
171
|
+
assert.equal(isExplicitBargeInTranscript('너덜너덜너덜'), false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
test('shouldUseLivePlaybackBargeIn avoids aborting an active agent during progress TTS', () => {
|
|
176
|
+
assert.equal(shouldUseLivePlaybackBargeIn({ speaking: true, processing: false }), true);
|
|
177
|
+
assert.equal(shouldUseLivePlaybackBargeIn({ speaking: true, processing: true }), false);
|
|
178
|
+
assert.equal(shouldUseLivePlaybackBargeIn({ speaking: false, processing: true }), false);
|
|
179
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export function isBrokenPipeError(error) {
|
|
2
|
+
return error?.code === 'EPIPE' || /write EPIPE/i.test(String(error?.message || error));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function isTransientNetworkError(error) {
|
|
6
|
+
return ['EPIPE', 'ECONNRESET', 'ETIMEDOUT'].includes(error?.code) || /write EPIPE|socket hang up/i.test(String(error?.message || error));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createTransientErrorReporter({
|
|
10
|
+
now = () => Date.now(),
|
|
11
|
+
intervalMs = 30000,
|
|
12
|
+
warn = () => {},
|
|
13
|
+
isTransientError = isTransientNetworkError,
|
|
14
|
+
} = {}) {
|
|
15
|
+
let nextLogAt = 0;
|
|
16
|
+
let suppressed = 0;
|
|
17
|
+
return function reportTransientError(label, error) {
|
|
18
|
+
if (!isTransientError(error)) return false;
|
|
19
|
+
const time = now();
|
|
20
|
+
if (time < nextLogAt) {
|
|
21
|
+
suppressed += 1;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
const repeated = suppressed ? `repeated ${suppressed} times` : '';
|
|
25
|
+
suppressed = 0;
|
|
26
|
+
nextLogAt = time + intervalMs;
|
|
27
|
+
warn(`suppressed transient ${label}`, error?.code || '', error?.message || error, repeated);
|
|
28
|
+
return true;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createBridgeLogger({
|
|
33
|
+
now = () => new Date().toISOString(),
|
|
34
|
+
stdout = console,
|
|
35
|
+
appendLine = () => {},
|
|
36
|
+
} = {}) {
|
|
37
|
+
let stdioBroken = false;
|
|
38
|
+
|
|
39
|
+
function lineFrom(args) {
|
|
40
|
+
return [now(), ...args].map(String).join(' ');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function emit(kind, args) {
|
|
44
|
+
const line = lineFrom(args);
|
|
45
|
+
try { appendLine(line); } catch {}
|
|
46
|
+
if (stdioBroken) return line;
|
|
47
|
+
try {
|
|
48
|
+
const fn = kind === 'warn' ? stdout.warn : stdout.log;
|
|
49
|
+
fn.call(stdout, line);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (isBrokenPipeError(error)) {
|
|
52
|
+
stdioBroken = true;
|
|
53
|
+
return line;
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
return line;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
log(...args) { return emit('log', args); },
|
|
62
|
+
warn(...args) { return emit('warn', args); },
|
|
63
|
+
markStdioBroken() { stdioBroken = true; },
|
|
64
|
+
get stdioBroken() { return stdioBroken; },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { createBridgeLogger, createTransientErrorReporter, isBrokenPipeError } from './bridge_logger.mjs';
|
|
5
|
+
|
|
6
|
+
test('isBrokenPipeError recognizes stdout/stderr pipe failures', () => {
|
|
7
|
+
assert.equal(isBrokenPipeError({ code: 'EPIPE', message: 'write EPIPE' }), true);
|
|
8
|
+
assert.equal(isBrokenPipeError({ code: 'ECONNRESET', message: 'socket hang up' }), false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('createBridgeLogger disables stdio after EPIPE but keeps file logging', () => {
|
|
12
|
+
const stdioCalls = [];
|
|
13
|
+
const fileLines = [];
|
|
14
|
+
const logger = createBridgeLogger({
|
|
15
|
+
now: () => '2026-05-01T00:00:00.000Z',
|
|
16
|
+
stdout: {
|
|
17
|
+
log: (...args) => {
|
|
18
|
+
stdioCalls.push(args);
|
|
19
|
+
const error = new Error('write EPIPE');
|
|
20
|
+
error.code = 'EPIPE';
|
|
21
|
+
throw error;
|
|
22
|
+
},
|
|
23
|
+
warn: (...args) => stdioCalls.push(args),
|
|
24
|
+
},
|
|
25
|
+
appendLine: line => fileLines.push(line),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
logger.log('first');
|
|
29
|
+
logger.warn('second');
|
|
30
|
+
|
|
31
|
+
assert.equal(stdioCalls.length, 1);
|
|
32
|
+
assert.equal(fileLines.length, 2);
|
|
33
|
+
assert.match(fileLines[0], /first/);
|
|
34
|
+
assert.match(fileLines[1], /second/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('createBridgeLogger can be marked stdio-broken from stream error handlers', () => {
|
|
38
|
+
const stdioCalls = [];
|
|
39
|
+
const fileLines = [];
|
|
40
|
+
const logger = createBridgeLogger({
|
|
41
|
+
now: () => '2026-05-01T00:00:00.000Z',
|
|
42
|
+
stdout: { log: (...args) => stdioCalls.push(args), warn: (...args) => stdioCalls.push(args) },
|
|
43
|
+
appendLine: line => fileLines.push(line),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
logger.markStdioBroken();
|
|
47
|
+
logger.log('file only');
|
|
48
|
+
|
|
49
|
+
assert.equal(stdioCalls.length, 0);
|
|
50
|
+
assert.equal(fileLines.length, 1);
|
|
51
|
+
assert.match(fileLines[0], /file only/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('createTransientErrorReporter rate-limits repeated transient errors', () => {
|
|
55
|
+
let now = 1000;
|
|
56
|
+
const lines = [];
|
|
57
|
+
const reporter = createTransientErrorReporter({
|
|
58
|
+
now: () => now,
|
|
59
|
+
intervalMs: 5000,
|
|
60
|
+
warn: (...args) => lines.push(args.join(' ')),
|
|
61
|
+
});
|
|
62
|
+
const error = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
|
|
63
|
+
|
|
64
|
+
assert.equal(reporter('uncaught exception', error), true);
|
|
65
|
+
assert.equal(reporter('uncaught exception', error), true);
|
|
66
|
+
assert.equal(reporter('uncaught exception', error), true);
|
|
67
|
+
now = 7000;
|
|
68
|
+
assert.equal(reporter('uncaught exception', error), true);
|
|
69
|
+
|
|
70
|
+
assert.equal(lines.length, 2);
|
|
71
|
+
assert.match(lines[0], /suppressed transient uncaught exception EPIPE write EPIPE/);
|
|
72
|
+
assert.match(lines[1], /repeated 2 times/);
|
|
73
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export function createBridgeState({ log = () => {}, cleanupFile = () => {} } = {}) {
|
|
2
|
+
let epoch = 0;
|
|
3
|
+
const pendingUtterances = new Map();
|
|
4
|
+
const deferredUtterances = [];
|
|
5
|
+
|
|
6
|
+
function currentEpoch() {
|
|
7
|
+
return epoch;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function discardQueues(reason = 'state-change') {
|
|
11
|
+
epoch += 1;
|
|
12
|
+
for (const pending of pendingUtterances.values()) {
|
|
13
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
14
|
+
for (const file of pending.files || []) cleanupFile(file);
|
|
15
|
+
}
|
|
16
|
+
pendingUtterances.clear();
|
|
17
|
+
for (const item of deferredUtterances.splice(0)) {
|
|
18
|
+
if (item?.wavPath) cleanupFile(item.wavPath);
|
|
19
|
+
}
|
|
20
|
+
log('discard voice input queues', reason, 'epoch', epoch);
|
|
21
|
+
return epoch;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getPending(userId) {
|
|
25
|
+
return pendingUtterances.get(userId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function setPending(userId, pending) {
|
|
29
|
+
pendingUtterances.set(userId, { ...pending, epoch: pending.epoch ?? epoch });
|
|
30
|
+
return pendingUtterances.get(userId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function deletePending(userId) {
|
|
34
|
+
const pending = pendingUtterances.get(userId);
|
|
35
|
+
pendingUtterances.delete(userId);
|
|
36
|
+
return pending;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clearPendingTimer(userId) {
|
|
40
|
+
const pending = pendingUtterances.get(userId);
|
|
41
|
+
if (pending?.timer) {
|
|
42
|
+
clearTimeout(pending.timer);
|
|
43
|
+
pending.timer = null;
|
|
44
|
+
}
|
|
45
|
+
return pending;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function appendSegment(userId, { file, pcmBytes, startedAtMs = Date.now(), endedAtMs = Date.now(), timerFactory }) {
|
|
49
|
+
let pending = pendingUtterances.get(userId);
|
|
50
|
+
if (pending && pending.epoch !== epoch) {
|
|
51
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
52
|
+
for (const oldFile of pending.files || []) cleanupFile(oldFile);
|
|
53
|
+
pendingUtterances.delete(userId);
|
|
54
|
+
pending = null;
|
|
55
|
+
}
|
|
56
|
+
if (!pending) {
|
|
57
|
+
pending = { files: [], pcmBytes: 0, timer: null, firstPacketAt: startedAtMs, lastSegmentEndAt: endedAtMs, epoch };
|
|
58
|
+
pendingUtterances.set(userId, pending);
|
|
59
|
+
}
|
|
60
|
+
pending.files.push(file);
|
|
61
|
+
pending.pcmBytes += pcmBytes;
|
|
62
|
+
pending.firstPacketAt = Math.min(pending.firstPacketAt || startedAtMs, startedAtMs);
|
|
63
|
+
pending.lastSegmentEndAt = Math.max(pending.lastSegmentEndAt || endedAtMs, endedAtMs);
|
|
64
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
65
|
+
pending.timer = timerFactory?.();
|
|
66
|
+
return pending;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isStale(item) {
|
|
70
|
+
return !item || item.epoch !== epoch;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function enqueueDeferred(item, enqueueFn, maxSize) {
|
|
74
|
+
return enqueueFn(deferredUtterances, { ...item, epoch }, maxSize);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function shiftDeferred() {
|
|
78
|
+
while (deferredUtterances.length > 0) {
|
|
79
|
+
const item = deferredUtterances.shift();
|
|
80
|
+
if (!isStale(item)) return item;
|
|
81
|
+
if (item?.wavPath) cleanupFile(item.wavPath);
|
|
82
|
+
log('drop stale deferred utterance', 'utteranceEpoch', item?.epoch, 'currentEpoch', epoch);
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function deferredSize() {
|
|
88
|
+
return deferredUtterances.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
appendSegment,
|
|
93
|
+
clearPendingTimer,
|
|
94
|
+
currentEpoch,
|
|
95
|
+
deferredSize,
|
|
96
|
+
deletePending,
|
|
97
|
+
discardQueues,
|
|
98
|
+
enqueueDeferred,
|
|
99
|
+
getPending,
|
|
100
|
+
isStale,
|
|
101
|
+
setPending,
|
|
102
|
+
shiftDeferred,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { createBridgeState } from './bridge_state.mjs';
|
|
5
|
+
|
|
6
|
+
test('bridge state discards pending and deferred utterances on epoch change', () => {
|
|
7
|
+
const cleaned = [];
|
|
8
|
+
const logs = [];
|
|
9
|
+
const state = createBridgeState({ cleanupFile: file => cleaned.push(file), log: (...args) => logs.push(args.join(' ')) });
|
|
10
|
+
|
|
11
|
+
state.appendSegment('user', {
|
|
12
|
+
file: '/tmp/one.wav',
|
|
13
|
+
pcmBytes: 10,
|
|
14
|
+
startedAtMs: 1,
|
|
15
|
+
endedAtMs: 2,
|
|
16
|
+
timerFactory: () => setTimeout(() => {}, 10000),
|
|
17
|
+
});
|
|
18
|
+
state.enqueueDeferred({ userId: 'user', wavPath: '/tmp/deferred.wav' }, (queue, item) => {
|
|
19
|
+
queue.push(item);
|
|
20
|
+
return { queued: true };
|
|
21
|
+
}, 10);
|
|
22
|
+
|
|
23
|
+
const nextEpoch = state.discardQueues('language-change');
|
|
24
|
+
|
|
25
|
+
assert.equal(nextEpoch, 1);
|
|
26
|
+
assert.deepEqual(cleaned.sort(), ['/tmp/deferred.wav', '/tmp/one.wav'].sort());
|
|
27
|
+
assert.equal(state.getPending('user'), undefined);
|
|
28
|
+
assert.equal(state.deferredSize(), 0);
|
|
29
|
+
assert.match(logs.join('\n'), /language-change/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('bridge state drops stale pending segment after config change', () => {
|
|
33
|
+
const cleaned = [];
|
|
34
|
+
const state = createBridgeState({ cleanupFile: file => cleaned.push(file) });
|
|
35
|
+
|
|
36
|
+
state.appendSegment('user', { file: '/tmp/old.wav', pcmBytes: 10, startedAtMs: 1, endedAtMs: 2 });
|
|
37
|
+
state.discardQueues('voice-change');
|
|
38
|
+
const pending = state.appendSegment('user', { file: '/tmp/new.wav', pcmBytes: 20, startedAtMs: 3, endedAtMs: 4 });
|
|
39
|
+
|
|
40
|
+
assert.equal(pending.epoch, state.currentEpoch());
|
|
41
|
+
assert.deepEqual(pending.files, ['/tmp/new.wav']);
|
|
42
|
+
assert.equal(pending.pcmBytes, 20);
|
|
43
|
+
assert.ok(cleaned.includes('/tmp/old.wav'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('bridge state skips stale deferred utterances', () => {
|
|
47
|
+
const cleaned = [];
|
|
48
|
+
const state = createBridgeState({ cleanupFile: file => cleaned.push(file) });
|
|
49
|
+
|
|
50
|
+
state.enqueueDeferred({ userId: 'user', wavPath: '/tmp/stale.wav' }, (queue, item) => {
|
|
51
|
+
queue.push(item);
|
|
52
|
+
return { queued: true };
|
|
53
|
+
}, 10);
|
|
54
|
+
state.discardQueues('language-change');
|
|
55
|
+
state.enqueueDeferred({ userId: 'user', wavPath: '/tmp/current.wav' }, (queue, item) => {
|
|
56
|
+
queue.push(item);
|
|
57
|
+
return { queued: true };
|
|
58
|
+
}, 10);
|
|
59
|
+
|
|
60
|
+
const next = state.shiftDeferred();
|
|
61
|
+
assert.equal(next.wavPath, '/tmp/current.wav');
|
|
62
|
+
assert.equal(state.shiftDeferred(), null);
|
|
63
|
+
assert.ok(cleaned.includes('/tmp/stale.wav'));
|
|
64
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
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 { healInstanceProfileFromEnv } from './instance_profile_lifecycle.mjs';
|
|
8
|
+
|
|
9
|
+
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
|
10
|
+
|
|
11
|
+
test('package exposes a short vc shell command', () => {
|
|
12
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
13
|
+
|
|
14
|
+
assert.equal(pkg.private, undefined);
|
|
15
|
+
assert.equal(pkg.license, 'MIT');
|
|
16
|
+
assert.equal(pkg.engines?.node, '>=20');
|
|
17
|
+
assert.equal(pkg.bin?.vc, 'scripts/cli.mjs');
|
|
18
|
+
assert.equal(pkg.bin?.verbalcoding, 'scripts/cli.mjs');
|
|
19
|
+
assert.ok(pkg.files.includes('app-node/'));
|
|
20
|
+
assert.ok(pkg.files.includes('scripts/*.mjs'));
|
|
21
|
+
assert.ok(pkg.files.includes('scripts/*.sh'));
|
|
22
|
+
assert.ok(pkg.files.includes('scripts/*.py'));
|
|
23
|
+
assert.ok(pkg.files.includes('run.sh'));
|
|
24
|
+
assert.ok(pkg.files.includes('LICENSE'));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('CLI includes npm-friendly setup and start commands', () => {
|
|
28
|
+
const cli = fs.readFileSync(path.join(ROOT, 'scripts', 'cli.mjs'), 'utf8');
|
|
29
|
+
|
|
30
|
+
assert.match(cli, /vc setup \[--yes\]/);
|
|
31
|
+
assert.match(cli, /command === 'setup'/);
|
|
32
|
+
assert.match(cli, /VERBALCODING_SKIP_CLI_LINK/);
|
|
33
|
+
assert.match(cli, /command === 'start'/);
|
|
34
|
+
assert.match(cli, /run\.sh/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('installer shell script links the vc command during setup', () => {
|
|
38
|
+
const script = fs.readFileSync(path.join(ROOT, 'scripts', 'install.sh'), 'utf8');
|
|
39
|
+
|
|
40
|
+
assert.match(script, /bootstrap_prereqs\.sh/);
|
|
41
|
+
assert.match(script, /--no-wizard/);
|
|
42
|
+
assert.match(script, /VERBALCODING_SKIP_BOOTSTRAP/);
|
|
43
|
+
assert.match(script, /npm link/);
|
|
44
|
+
assert.match(script, /Installed shell CLI: vc/);
|
|
45
|
+
assert.match(script, /VERBALCODING_SKIP_CLI_LINK/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('bootstrap script installs cross-platform prerequisites and local model helpers', () => {
|
|
49
|
+
const script = fs.readFileSync(path.join(ROOT, 'scripts', 'bootstrap_prereqs.sh'), 'utf8');
|
|
50
|
+
|
|
51
|
+
assert.match(script, /brew install/);
|
|
52
|
+
assert.match(script, /apt-get install/);
|
|
53
|
+
assert.match(script, /dnf install/);
|
|
54
|
+
assert.match(script, /pacman -Sy/);
|
|
55
|
+
assert.match(script, /git clone --depth 1 https:\/\/github\.com\/ggml-org\/whisper\.cpp\.git/);
|
|
56
|
+
assert.match(script, /ggml-small-q5_1\.bin/);
|
|
57
|
+
assert.match(script, /\.venv-tts/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('Ubuntu Docker smoke script validates clean install without secrets', () => {
|
|
61
|
+
const script = fs.readFileSync(path.join(ROOT, 'scripts', 'docker_ubuntu_smoke.sh'), 'utf8');
|
|
62
|
+
|
|
63
|
+
assert.match(script, /ubuntu:24\.04/);
|
|
64
|
+
assert.match(script, /git .*archive --format=tar HEAD/);
|
|
65
|
+
assert.match(script, /\.\/scripts\/install\.sh --yes --no-wizard/);
|
|
66
|
+
assert.match(script, /DISCORD_BOT_TOKEN="smoke-test-token"/);
|
|
67
|
+
assert.match(script, /AGENT_BACKEND="custom"/);
|
|
68
|
+
assert.match(script, /vc doctor/);
|
|
69
|
+
assert.doesNotMatch(script, /npm run vc/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('healInstanceProfileFromEnv ensures profile when HERMES_HOME is set', async () => {
|
|
73
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-home-'));
|
|
74
|
+
const calls = [];
|
|
75
|
+
const ensure = async args => {
|
|
76
|
+
calls.push(args);
|
|
77
|
+
return { created: true, dir: path.join(home, '.hermes/profiles', args.name), name: args.name, warnings: [] };
|
|
78
|
+
};
|
|
79
|
+
const env = {
|
|
80
|
+
HERMES_HOME: path.join(home, '.hermes/profiles/llm-wiki'),
|
|
81
|
+
AGENT_CWD: '/projects/llm-wiki',
|
|
82
|
+
AGENT_PROJECT_CONTEXT: 'LLM-Wiki agent',
|
|
83
|
+
};
|
|
84
|
+
const result = await healInstanceProfileFromEnv('llm-wiki', env, { ensureHermesProfile: ensure });
|
|
85
|
+
assert.equal(calls.length, 1);
|
|
86
|
+
assert.equal(calls[0].name, 'llm-wiki');
|
|
87
|
+
assert.equal(calls[0].workdir, '/projects/llm-wiki');
|
|
88
|
+
assert.equal(result.created, true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('healInstanceProfileFromEnv is a no-op when HERMES_HOME is unset', async () => {
|
|
92
|
+
let invoked = false;
|
|
93
|
+
const ensure = async () => { invoked = true; return null; };
|
|
94
|
+
const result = await healInstanceProfileFromEnv('llm-wiki', {}, { ensureHermesProfile: ensure });
|
|
95
|
+
assert.equal(invoked, false);
|
|
96
|
+
assert.equal(result, null);
|
|
97
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function enqueueDeferredUtterance(queue, item, maxSize) {
|
|
2
|
+
const limit = Number(maxSize);
|
|
3
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
4
|
+
return { queued: false, dropped: item, reason: 'disabled' };
|
|
5
|
+
}
|
|
6
|
+
let dropped = null;
|
|
7
|
+
while (queue.length >= limit) {
|
|
8
|
+
dropped = queue.shift();
|
|
9
|
+
}
|
|
10
|
+
queue.push(item);
|
|
11
|
+
return { queued: true, dropped, reason: dropped ? 'replaced-oldest' : 'queued' };
|
|
12
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { enqueueDeferredUtterance } from './deferred_queue.mjs';
|
|
4
|
+
|
|
5
|
+
test('enqueueDeferredUtterance drops immediately when queue is disabled', () => {
|
|
6
|
+
const queue = [];
|
|
7
|
+
const item = { text: 'do not run this later' };
|
|
8
|
+
const result = enqueueDeferredUtterance(queue, item, 0);
|
|
9
|
+
assert.deepEqual(result, { queued: false, dropped: item, reason: 'disabled' });
|
|
10
|
+
assert.deepEqual(queue, []);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('enqueueDeferredUtterance keeps only latest items up to max size', () => {
|
|
14
|
+
const queue = [{ text: 'old' }];
|
|
15
|
+
const result = enqueueDeferredUtterance(queue, { text: 'new' }, 1);
|
|
16
|
+
assert.equal(result.queued, true);
|
|
17
|
+
assert.equal(result.reason, 'replaced-oldest');
|
|
18
|
+
assert.deepEqual(result.dropped, { text: 'old' });
|
|
19
|
+
assert.deepEqual(queue, [{ text: 'new' }]);
|
|
20
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
|
7
|
+
const CLI = path.join(ROOT, 'scripts', 'cli.mjs');
|
|
8
|
+
|
|
9
|
+
test('vc bot invite prints an OAuth URL for a Discord client id', () => {
|
|
10
|
+
const result = spawnSync(process.execPath, [CLI, 'bot', 'invite', '123456789012345678'], {
|
|
11
|
+
cwd: ROOT,
|
|
12
|
+
encoding: 'utf8',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
assert.equal(result.status, 0, result.stderr);
|
|
16
|
+
assert.match(result.stdout, /Discord bot invite URL:/);
|
|
17
|
+
assert.match(result.stdout, /client_id=123456789012345678/);
|
|
18
|
+
assert.match(result.stdout, /scope=bot\+applications\.commands|scope=bot%20applications\.commands/);
|
|
19
|
+
assert.match(result.stdout, /permissions=277028604928/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('vc bot invite supports a guild id shortcut', () => {
|
|
23
|
+
const result = spawnSync(process.execPath, [CLI, 'bot', 'invite', '123456789012345678', '--guild', '987654321098765432'], {
|
|
24
|
+
cwd: ROOT,
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
assert.equal(result.status, 0, result.stderr);
|
|
29
|
+
assert.match(result.stdout, /guild_id=987654321098765432/);
|
|
30
|
+
assert.match(result.stdout, /disable_guild_select=true/);
|
|
31
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function splitDiscordMessage(text, maxLength = 1900) {
|
|
2
|
+
const body = String(text || '');
|
|
3
|
+
if (!body.length) return [''];
|
|
4
|
+
const chunks = [];
|
|
5
|
+
for (let i = 0; i < body.length; i += maxLength) chunks.push(body.slice(i, i + maxLength));
|
|
6
|
+
return chunks;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function sendDiscordText({ client, channelId, text, log = () => {}, warn = () => {} }) {
|
|
10
|
+
const body = String(text || '');
|
|
11
|
+
if (!channelId) {
|
|
12
|
+
warn('sendText missing transcript channel id; text not delivered', body.slice(0, 200));
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const ch = await client.channels.fetch(channelId);
|
|
17
|
+
if (!ch?.isTextBased?.()) {
|
|
18
|
+
warn('sendText target is not text based', channelId);
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const chunks = splitDiscordMessage(body);
|
|
22
|
+
for (const chunk of chunks) await ch.send(chunk);
|
|
23
|
+
log('sendText delivered', 'chars', body.length, 'chunks', chunks.length);
|
|
24
|
+
return true;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
warn('sendText failed', e?.stack || e);
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|