verbalcoding 0.2.12 → 0.2.13
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 +74 -4
- package/README.es.md +3 -1
- package/README.fr.md +3 -1
- package/README.ja.md +3 -1
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/README.ru.md +3 -1
- package/README.zh.md +3 -1
- package/app-node/agent_adapters.test.mjs +14 -0
- package/app-node/agent_routing.mjs +148 -0
- package/app-node/agent_routing.test.mjs +138 -0
- package/app-node/agent_turn.mjs +86 -0
- package/app-node/agent_turn.test.mjs +109 -0
- package/app-node/bridge_context.mjs +73 -0
- package/app-node/bridge_context.test.mjs +54 -0
- package/app-node/bridge_state.mjs +4 -0
- package/app-node/bridge_wireup.test.mjs +462 -0
- package/app-node/cli_install.test.mjs +31 -0
- package/app-node/cross_agent_routing.test.mjs +78 -0
- package/app-node/discord_command_router.mjs +204 -0
- package/app-node/discord_command_router.test.mjs +311 -0
- package/app-node/discord_voice_setup.mjs +251 -0
- package/app-node/discord_voice_setup.test.mjs +86 -0
- package/app-node/hermes_profiles.test.mjs +12 -1
- package/app-node/install_config.mjs +110 -3
- package/app-node/install_config.test.mjs +8 -0
- package/app-node/instance_doctor.test.mjs +9 -0
- package/app-node/instances.test.mjs +8 -1
- package/app-node/main.mjs +488 -1368
- package/app-node/mcp_tools.test.mjs +7 -0
- package/app-node/notification_handler.mjs +89 -0
- package/app-node/notification_handler.test.mjs +187 -0
- package/app-node/plan_dispatcher.mjs +215 -0
- package/app-node/plan_dispatcher.test.mjs +101 -0
- package/app-node/plan_mode.mjs +36 -7
- package/app-node/plan_mode.test.mjs +78 -0
- package/app-node/progress_handler.mjs +220 -0
- package/app-node/progress_handler.test.mjs +193 -0
- package/app-node/progress_speech.mjs +54 -32
- package/app-node/progress_speech.test.mjs +12 -3
- package/app-node/project_sessions.mjs +5 -2
- package/app-node/project_sessions.test.mjs +7 -0
- package/app-node/research_mode.mjs +282 -0
- package/app-node/research_mode.test.mjs +264 -0
- package/app-node/restart_notice.mjs +3 -0
- package/app-node/restart_notice.test.mjs +11 -0
- package/app-node/session_ontology.mjs +271 -0
- package/app-node/session_ontology.test.mjs +130 -0
- package/app-node/smart_progress.mjs +1 -1
- package/app-node/stream_sentencer.mjs +32 -2
- package/app-node/stream_sentencer.test.mjs +65 -0
- package/app-node/streaming_tts_queue.mjs +5 -1
- package/app-node/streaming_tts_queue.test.mjs +7 -1
- package/app-node/stt_whisper.mjs +24 -0
- package/app-node/stt_whisper.test.mjs +32 -0
- package/app-node/text_routing.mjs +4 -2
- package/app-node/tts_backends.mjs +537 -3
- package/app-node/tts_backends.test.mjs +454 -0
- package/app-node/tts_player.mjs +164 -0
- package/app-node/tts_player.test.mjs +202 -0
- package/app-node/tts_runtime.mjs +134 -0
- package/app-node/tts_runtime.test.mjs +89 -0
- package/app-node/tts_settings.mjs +150 -3
- package/app-node/tts_settings.test.mjs +204 -0
- package/app-node/tts_voice_config.mjs +136 -2
- package/app-node/tts_voice_config.test.mjs +94 -0
- package/app-node/utterance_router.mjs +216 -0
- package/app-node/utterance_router.test.mjs +236 -0
- package/app-node/voice_autojoin.mjs +37 -0
- package/app-node/voice_autojoin.test.mjs +59 -0
- package/app-node/voice_io.mjs +272 -0
- package/app-node/voice_io.test.mjs +102 -0
- package/app-node/voice_turn_runner.mjs +449 -0
- package/app-node/voice_turn_runner.test.mjs +289 -0
- package/docs/CONFIGURATION.md +12 -2
- package/docs/HARNESSES.md +58 -0
- package/docs/HARNESS_AIDER.md +50 -0
- package/docs/HARNESS_CLAUDE.md +56 -0
- package/docs/HARNESS_CODEX.md +56 -0
- package/docs/HARNESS_CURSOR.md +45 -0
- package/docs/HARNESS_GEMINI.md +45 -0
- package/docs/HARNESS_HERMES.md +57 -0
- package/docs/HARNESS_OPENCLAW.md +44 -0
- package/docs/HARNESS_OPENCODE.md +44 -0
- package/docs/README.md +1 -0
- package/docs/ROADMAP.md +20 -5
- package/docs/TTS_BACKENDS.md +227 -0
- package/docs/USAGE.md +22 -0
- package/docs/i18n/AGENTS.es.md +34 -0
- package/docs/i18n/AGENTS.fr.md +34 -0
- package/docs/i18n/AGENTS.ja.md +34 -0
- package/docs/i18n/AGENTS.ko.md +34 -0
- package/docs/i18n/AGENTS.ru.md +34 -0
- package/docs/i18n/AGENTS.zh.md +34 -0
- package/docs/i18n/HARNESSES.es.md +58 -0
- package/docs/i18n/HARNESSES.fr.md +58 -0
- package/docs/i18n/HARNESSES.ja.md +58 -0
- package/docs/i18n/HARNESSES.ko.md +58 -0
- package/docs/i18n/HARNESSES.ru.md +58 -0
- package/docs/i18n/HARNESSES.zh.md +58 -0
- package/docs/i18n/HARNESS_AIDER.es.md +48 -0
- package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
- package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
- package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
- package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
- package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
- package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
- package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
- package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
- package/docs/i18n/HARNESS_CODEX.es.md +55 -0
- package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
- package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
- package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
- package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
- package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
- package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
- package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
- package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
- package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
- package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
- package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
- package/docs/i18n/HARNESS_HERMES.es.md +54 -0
- package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
- package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
- package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
- package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
- package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
- package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
- package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
- package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
- package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
- package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
- package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
- package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
- package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
- package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
- package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
- package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
- package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
- package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
- package/integrations/fireredtts2/mlx_llm.py +183 -0
- package/integrations/fireredtts2/synth.py +156 -0
- package/integrations/fireredtts2/synth_mlx.py +196 -0
- package/integrations/mlxaudio/synth.py +74 -0
- package/integrations/neuttsair/synth.py +104 -0
- package/integrations/omnivoice/synth.py +110 -0
- package/package.json +6 -1
- package/scripts/cli.mjs +84 -0
- package/scripts/doctor.mjs +104 -4
- package/scripts/install.mjs +5 -1
- package/scripts/install_fireredtts2.sh +109 -0
- package/scripts/install_mlxaudio.sh +34 -0
- package/scripts/install_mossttsnano.sh +46 -0
- package/scripts/postinstall.mjs +34 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { createSessionOntology, buildExtractionPrompt, parseExtractionJson } from './session_ontology.mjs';
|
|
7
|
+
|
|
8
|
+
const __tempRoots = [];
|
|
9
|
+
test.after(() => {
|
|
10
|
+
for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function tmpDir(label) {
|
|
14
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `vc-onto-${label}-`));
|
|
15
|
+
__tempRoots.push(root);
|
|
16
|
+
return root;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('add inserts nodes and dedupes by (type, lowercase name)', () => {
|
|
20
|
+
const ontology = createSessionOntology({ rootDir: tmpDir('dedup'), channelKey: 'voice/1' });
|
|
21
|
+
ontology.add({ nodes: [{ t: 'D', n: 'OAUTH_PROVIDER=github' }, { t: 'D', n: 'oauth_provider=github' }] });
|
|
22
|
+
assert.equal(ontology.nodeCount, 1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('add applies supersede edges without deleting old node', () => {
|
|
26
|
+
const ontology = createSessionOntology({ rootDir: tmpDir('supersede'), channelKey: 'voice/2' });
|
|
27
|
+
ontology.add({
|
|
28
|
+
nodes: [
|
|
29
|
+
{ id: 'a', t: 'D', n: 'session_store=memory' },
|
|
30
|
+
{ id: 'b', t: 'D', n: 'session_store=redis' },
|
|
31
|
+
],
|
|
32
|
+
supersedes: [{ old: 'a', new: 'b' }],
|
|
33
|
+
});
|
|
34
|
+
assert.equal(ontology.nodeCount, 2);
|
|
35
|
+
const md = ontology.serializeForHandoff({ language: 'en' });
|
|
36
|
+
assert.match(md, /session_store=redis/);
|
|
37
|
+
assert.doesNotMatch(md, /session_store=memory/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('LRU eviction keeps decisions sticky', () => {
|
|
41
|
+
const ontology = createSessionOntology({ rootDir: tmpDir('lru'), channelKey: 'voice/3', maxNodes: 3 });
|
|
42
|
+
ontology.add({ nodes: [{ t: 'D', n: 'a=1' }, { t: 'F', n: 'app.mjs' }, { t: 'F', n: 'lib.mjs' }] });
|
|
43
|
+
assert.equal(ontology.nodeCount, 3);
|
|
44
|
+
ontology.add({ nodes: [{ t: 'F', n: 'main.mjs' }] });
|
|
45
|
+
assert.equal(ontology.nodeCount, 3);
|
|
46
|
+
const snap = ontology.snapshot();
|
|
47
|
+
assert.ok(snap.nodes.some(n => n.t === 'D'), 'decision survives eviction');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('entitiesFromText extracts files, tools, decisions', () => {
|
|
51
|
+
const ontology = createSessionOntology({ rootDir: tmpDir('extract'), channelKey: 'voice/4' });
|
|
52
|
+
const out = ontology.entitiesFromText(
|
|
53
|
+
'Updated app/main.mjs and ran npm test. We settled oauth_provider=github.',
|
|
54
|
+
{ by: 'claude' },
|
|
55
|
+
);
|
|
56
|
+
const types = out.nodes.map(n => n.t).sort();
|
|
57
|
+
assert.ok(types.includes('F'), 'has file node');
|
|
58
|
+
assert.ok(types.includes('T'), 'has tool node');
|
|
59
|
+
assert.ok(types.includes('D'), 'has decision node');
|
|
60
|
+
const decision = out.nodes.find(n => n.t === 'D');
|
|
61
|
+
assert.match(decision.n, /oauth_provider=github/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('serializeForHandoff returns empty marker on empty ontology', () => {
|
|
65
|
+
const ontology = createSessionOntology({ rootDir: tmpDir('empty'), channelKey: 'voice/5' });
|
|
66
|
+
assert.match(ontology.serializeForHandoff({ language: 'en' }), /no prior session context/);
|
|
67
|
+
assert.match(ontology.serializeForHandoff({ language: 'ko' }), /이전 세션 컨텍스트 없음/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('serializeForHandoff groups decisions, files, tools, results', () => {
|
|
71
|
+
const ontology = createSessionOntology({ rootDir: tmpDir('serialize'), channelKey: 'voice/6' });
|
|
72
|
+
const { nodes, edges } = ontology.entitiesFromText(
|
|
73
|
+
'Touched app-node/main.mjs with git commit. oauth_provider=github decided.',
|
|
74
|
+
{ by: 'claude' },
|
|
75
|
+
);
|
|
76
|
+
ontology.add({ nodes, edges });
|
|
77
|
+
ontology.add({ nodes: [{ t: 'R', n: 'OAuth wired up successfully', by: 'codex' }] });
|
|
78
|
+
const md = ontology.serializeForHandoff({ language: 'en' });
|
|
79
|
+
assert.match(md, /Active decisions/);
|
|
80
|
+
assert.match(md, /oauth_provider=github/);
|
|
81
|
+
assert.match(md, /Relevant files/);
|
|
82
|
+
assert.match(md, /main\.mjs/);
|
|
83
|
+
assert.match(md, /Recent results/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('buildExtractionPrompt mentions the schema and the message body', () => {
|
|
87
|
+
const p = buildExtractionPrompt({ text: 'Routed to codex for the auth refactor', language: 'en' });
|
|
88
|
+
assert.match(p, /JSON of shape/);
|
|
89
|
+
assert.match(p, /D=Decision/);
|
|
90
|
+
assert.match(p, /auth refactor/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('parseExtractionJson recovers JSON from fenced output', () => {
|
|
94
|
+
const raw = '```json\n{"nodes":[{"t":"D","n":"db=postgres"}],"edges":[]}\n```';
|
|
95
|
+
const out = parseExtractionJson(raw);
|
|
96
|
+
assert.equal(out.nodes.length, 1);
|
|
97
|
+
assert.equal(out.nodes[0].t, 'D');
|
|
98
|
+
assert.equal(out.nodes[0].n, 'db=postgres');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('parseExtractionJson tolerates surrounding prose', () => {
|
|
102
|
+
const raw = 'Sure, here is the graph: {"nodes":[{"t":"F","n":"app.mjs"}],"edges":[]} let me know if you need more.';
|
|
103
|
+
const out = parseExtractionJson(raw);
|
|
104
|
+
assert.equal(out.nodes.length, 1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('parseExtractionJson resolves edge endpoints by name to canonical node ids', () => {
|
|
108
|
+
const raw = '{"nodes":[{"t":"D","n":"db=postgres"},{"t":"F","n":"schema.sql"}],"edges":[{"s":"db=postgres","p":"t","o":"schema.sql"}]}';
|
|
109
|
+
const out = parseExtractionJson(raw);
|
|
110
|
+
assert.equal(out.nodes.length, 2);
|
|
111
|
+
assert.equal(out.edges.length, 1);
|
|
112
|
+
assert.equal(out.edges[0].s, 'e_D_db=postgres');
|
|
113
|
+
assert.equal(out.edges[0].o, 'e_F_schema.sql');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('parseExtractionJson returns empty on garbage input', () => {
|
|
117
|
+
const out = parseExtractionJson('totally not json');
|
|
118
|
+
assert.deepEqual(out, { nodes: [], edges: [] });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('save/load round-trips state to disk', () => {
|
|
122
|
+
const root = tmpDir('persist');
|
|
123
|
+
const ontology = createSessionOntology({ rootDir: root, channelKey: 'voice/7' });
|
|
124
|
+
ontology.add({ nodes: [{ t: 'D', n: 'db=postgres', by: 'claude' }] });
|
|
125
|
+
assert.ok(ontology.save());
|
|
126
|
+
const restored = createSessionOntology({ rootDir: root, channelKey: 'voice/7' });
|
|
127
|
+
assert.ok(restored.load());
|
|
128
|
+
assert.equal(restored.nodeCount, 1);
|
|
129
|
+
assert.match(restored.serializeForHandoff({ language: 'en' }), /db=postgres/);
|
|
130
|
+
});
|
|
@@ -23,7 +23,7 @@ export function createSmartProgressSummarizer({
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
async function summarize(events) {
|
|
26
|
-
const key = events.join('|')
|
|
26
|
+
const key = `${language}::${events.join('|')}`;
|
|
27
27
|
const cached = cache.get(key);
|
|
28
28
|
if (cached && Date.now() - cached.t < cacheMs) return cached.text;
|
|
29
29
|
const ctl = new AbortController();
|
|
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
|
|
|
3
3
|
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
4
4
|
const BOX_RE = /[╭╮╰╯│┊─]/g;
|
|
5
5
|
const PROGRESS_LINE_RE = /^VERBALCODING_PROGRESS\s*:.*$/i;
|
|
6
|
-
const TERMINAL_RE = /[.!?。!?…]+
|
|
6
|
+
const TERMINAL_RE = /(?<!\b(?:e\.g|i\.e|etc|cf|Mr|Mrs|Dr|Sr|Jr|St|Mt|vs|approx|al|aka|fig|eqn|inc|ltd|co))[.!?。!?…]+["'\)\]\}」』]*(?=\s|$)/;
|
|
7
7
|
|
|
8
8
|
function clean(text) {
|
|
9
9
|
return String(text || '')
|
|
@@ -18,6 +18,8 @@ function clean(text) {
|
|
|
18
18
|
export function createSentencer({ minChars = 40, maxLatencyMs = 800 } = {}) {
|
|
19
19
|
const ee = new EventEmitter();
|
|
20
20
|
let buffer = '';
|
|
21
|
+
let inFence = false;
|
|
22
|
+
let pendingFenceTail = '';
|
|
21
23
|
let lastEmit = Date.now();
|
|
22
24
|
|
|
23
25
|
function emit(text) {
|
|
@@ -27,6 +29,31 @@ export function createSentencer({ minChars = 40, maxLatencyMs = 800 } = {}) {
|
|
|
27
29
|
lastEmit = Date.now();
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function trailingBackticks(text) {
|
|
33
|
+
let n = 0;
|
|
34
|
+
for (let i = text.length - 1; i >= 0 && text[i] === '`' && n < 2; i -= 1) n += 1;
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ingest(text) {
|
|
39
|
+
let remaining = pendingFenceTail + text;
|
|
40
|
+
pendingFenceTail = '';
|
|
41
|
+
while (remaining.length > 0) {
|
|
42
|
+
const fence = remaining.indexOf('```');
|
|
43
|
+
if (fence === -1) {
|
|
44
|
+
const heldCount = trailingBackticks(remaining);
|
|
45
|
+
const safe = heldCount > 0 ? remaining.slice(0, remaining.length - heldCount) : remaining;
|
|
46
|
+
pendingFenceTail = heldCount > 0 ? remaining.slice(remaining.length - heldCount) : '';
|
|
47
|
+
if (!inFence) buffer += safe;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const before = remaining.slice(0, fence);
|
|
51
|
+
if (!inFence) buffer += before;
|
|
52
|
+
inFence = !inFence;
|
|
53
|
+
remaining = remaining.slice(fence + 3);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
30
57
|
function scan() {
|
|
31
58
|
while (true) {
|
|
32
59
|
const match = buffer.match(TERMINAL_RE);
|
|
@@ -50,12 +77,15 @@ export function createSentencer({ minChars = 40, maxLatencyMs = 800 } = {}) {
|
|
|
50
77
|
push(text) {
|
|
51
78
|
const cleaned = clean(text);
|
|
52
79
|
if (!cleaned) return;
|
|
53
|
-
|
|
80
|
+
ingest(cleaned);
|
|
54
81
|
scan();
|
|
55
82
|
},
|
|
56
83
|
flush() {
|
|
84
|
+
if (!inFence) buffer += pendingFenceTail;
|
|
85
|
+
pendingFenceTail = '';
|
|
57
86
|
emit(buffer);
|
|
58
87
|
buffer = '';
|
|
88
|
+
inFence = false;
|
|
59
89
|
},
|
|
60
90
|
};
|
|
61
91
|
}
|
|
@@ -62,3 +62,68 @@ test('emits multiple sentences in one push', () => {
|
|
|
62
62
|
s.push('First. Second. Third.');
|
|
63
63
|
assert.deepEqual(out, ['First.', 'Second.', 'Third.']);
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
test('does not split on common abbreviations', () => {
|
|
67
|
+
const out = [];
|
|
68
|
+
const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
|
|
69
|
+
s.on('sentence', t => out.push(t));
|
|
70
|
+
s.push('Use e.g. main.mjs for the entry point. ');
|
|
71
|
+
assert.deepEqual(out, ['Use e.g. main.mjs for the entry point.']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('treats decimals as one sentence', () => {
|
|
75
|
+
const out = [];
|
|
76
|
+
const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
|
|
77
|
+
s.on('sentence', t => out.push(t));
|
|
78
|
+
s.push('Version 3.14 is out. ');
|
|
79
|
+
assert.deepEqual(out, ['Version 3.14 is out.']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('drops fenced code blocks from speech', () => {
|
|
83
|
+
const out = [];
|
|
84
|
+
const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
|
|
85
|
+
s.on('sentence', t => out.push(t));
|
|
86
|
+
s.push('Here is the change. ```js\nconst x = 1;\n``` Done.');
|
|
87
|
+
s.flush();
|
|
88
|
+
assert.deepEqual(out, ['Here is the change.', 'Done.']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('detects fence marker split 1+1+1 backticks across three pushes', () => {
|
|
92
|
+
const out = [];
|
|
93
|
+
const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
|
|
94
|
+
s.on('sentence', t => out.push(t));
|
|
95
|
+
s.push('Open fence. `');
|
|
96
|
+
s.push('`');
|
|
97
|
+
s.push('`python\nconst x = 1\n```\nClosed.');
|
|
98
|
+
s.flush();
|
|
99
|
+
assert.deepEqual(out, ['Open fence.', 'Closed.']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('detects fence marker split across pushes', () => {
|
|
103
|
+
const out = [];
|
|
104
|
+
const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
|
|
105
|
+
s.on('sentence', t => out.push(t));
|
|
106
|
+
s.push('Open fence. ``');
|
|
107
|
+
s.push('`python\nconst x = 1\n``');
|
|
108
|
+
s.push('` Closed.');
|
|
109
|
+
s.flush();
|
|
110
|
+
assert.deepEqual(out, ['Open fence.', 'Closed.']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('keeps fence state across pushes', () => {
|
|
114
|
+
const out = [];
|
|
115
|
+
const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
|
|
116
|
+
s.on('sentence', t => out.push(t));
|
|
117
|
+
s.push('Open fence. ```python\nimport os');
|
|
118
|
+
s.push('\nprint(os.getcwd())\n``` Closed.');
|
|
119
|
+
s.flush();
|
|
120
|
+
assert.deepEqual(out, ['Open fence.', 'Closed.']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('terminates on Korean closing quote', () => {
|
|
124
|
+
const out = [];
|
|
125
|
+
const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
|
|
126
|
+
s.on('sentence', t => out.push(t));
|
|
127
|
+
s.push('그가 말했다. "안녕." ');
|
|
128
|
+
assert.deepEqual(out, ['그가 말했다.', '"안녕."']);
|
|
129
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
export function createStreamingTTSQueue({ synth, play, signal, cleanup, log = () => {} } = {}) {
|
|
1
|
+
export function createStreamingTTSQueue({ synth, play, signal, cleanup, log = () => {}, onSynthError = null } = {}) {
|
|
2
2
|
if (typeof synth !== 'function') throw new Error('synth is required');
|
|
3
3
|
if (typeof play !== 'function') throw new Error('play is required');
|
|
4
4
|
|
|
5
5
|
const queue = [];
|
|
6
6
|
let pumping = null;
|
|
7
|
+
let droppedCount = 0;
|
|
7
8
|
|
|
8
9
|
async function pump() {
|
|
9
10
|
while (queue.length && !signal?.aborted) {
|
|
@@ -13,6 +14,8 @@ export function createStreamingTTSQueue({ synth, play, signal, cleanup, log = ()
|
|
|
13
14
|
file = await synth(text);
|
|
14
15
|
} catch (e) {
|
|
15
16
|
log('streaming tts synth failed', e?.message || e);
|
|
17
|
+
droppedCount += 1;
|
|
18
|
+
try { onSynthError?.({ text, error: e, droppedCount }); } catch {}
|
|
16
19
|
continue;
|
|
17
20
|
}
|
|
18
21
|
if (!file) continue;
|
|
@@ -44,5 +47,6 @@ export function createStreamingTTSQueue({ synth, play, signal, cleanup, log = ()
|
|
|
44
47
|
while (pumping) await pumping;
|
|
45
48
|
},
|
|
46
49
|
get size() { return queue.length; },
|
|
50
|
+
get droppedCount() { return droppedCount; },
|
|
47
51
|
};
|
|
48
52
|
}
|
|
@@ -40,16 +40,22 @@ test('cleanup runs after play', async () => {
|
|
|
40
40
|
assert.deepEqual(cleaned, ['f-A.']);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
test('synth error skips that sentence but continues', async () => {
|
|
43
|
+
test('synth error skips that sentence but continues, and reports dropped count + onSynthError', async () => {
|
|
44
44
|
const played = [];
|
|
45
|
+
const errors = [];
|
|
45
46
|
const q = createStreamingTTSQueue({
|
|
46
47
|
synth: async (t) => { if (t === 'A.') throw new Error('boom'); return `f-${t}`; },
|
|
47
48
|
play: async (f) => { played.push(f); },
|
|
49
|
+
onSynthError: ev => errors.push(ev),
|
|
48
50
|
});
|
|
49
51
|
q.enqueue('A.');
|
|
50
52
|
q.enqueue('B.');
|
|
51
53
|
await q.drain();
|
|
52
54
|
assert.deepEqual(played, ['f-B.']);
|
|
55
|
+
assert.equal(q.droppedCount, 1);
|
|
56
|
+
assert.equal(errors.length, 1);
|
|
57
|
+
assert.equal(errors[0].text, 'A.');
|
|
58
|
+
assert.match(errors[0].error.message, /boom/);
|
|
53
59
|
});
|
|
54
60
|
|
|
55
61
|
test('throws when synth or play missing', () => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const DEFAULT_WHISPER_TIMEOUT_MS = 90000;
|
|
2
|
+
|
|
3
|
+
export function parsePositiveInt(value, fallback) {
|
|
4
|
+
const n = Number(value);
|
|
5
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
6
|
+
return Math.floor(n);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function whisperTimeoutMs(env = process.env) {
|
|
10
|
+
return parsePositiveInt(env.WHISPER_CPP_TIMEOUT_MS || env.WHISPER_TIMEOUT_MS, DEFAULT_WHISPER_TIMEOUT_MS);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function whisperFailureMessage(error) {
|
|
14
|
+
const signal = error?.signal ? `signal ${error.signal}` : '';
|
|
15
|
+
const code = error?.code !== undefined && error?.code !== null ? `code ${error.code}` : '';
|
|
16
|
+
const killed = error?.killed ? 'killed' : '';
|
|
17
|
+
const message = String(error?.message || '').trim();
|
|
18
|
+
const stderr = String(error?.stderr || '').trim();
|
|
19
|
+
const stdout = String(error?.stdout || '').trim();
|
|
20
|
+
const detail = stderr || stdout || message || 'unknown error';
|
|
21
|
+
const tail = detail.length > 1400 ? detail.slice(-1400) : detail;
|
|
22
|
+
const prefix = [killed, signal, code].filter(Boolean).join(' ');
|
|
23
|
+
return prefix ? `${prefix}: ${tail}` : tail;
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_WHISPER_TIMEOUT_MS, parsePositiveInt, whisperFailureMessage, whisperTimeoutMs } from './stt_whisper.mjs';
|
|
5
|
+
|
|
6
|
+
test('whisperTimeoutMs defaults high enough for cold Metal startup plus transcription', () => {
|
|
7
|
+
assert.equal(DEFAULT_WHISPER_TIMEOUT_MS, 90000);
|
|
8
|
+
assert.equal(whisperTimeoutMs({}), 90000);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('whisperTimeoutMs accepts explicit positive env override', () => {
|
|
12
|
+
assert.equal(whisperTimeoutMs({ WHISPER_CPP_TIMEOUT_MS: '120000' }), 120000);
|
|
13
|
+
assert.equal(whisperTimeoutMs({ WHISPER_TIMEOUT_MS: '45000' }), 45000);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('parsePositiveInt rejects invalid timeout values', () => {
|
|
17
|
+
assert.equal(parsePositiveInt('0', 7), 7);
|
|
18
|
+
assert.equal(parsePositiveInt('-1', 7), 7);
|
|
19
|
+
assert.equal(parsePositiveInt('nope', 7), 7);
|
|
20
|
+
assert.equal(parsePositiveInt('12.8', 7), 12);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('whisperFailureMessage keeps concise tail instead of dumping full backend log', () => {
|
|
24
|
+
const err = new Error('Command failed');
|
|
25
|
+
err.killed = true;
|
|
26
|
+
err.signal = 'SIGTERM';
|
|
27
|
+
err.stderr = `${'load_backend\n'.repeat(300)}final useful line`;
|
|
28
|
+
const msg = whisperFailureMessage(err);
|
|
29
|
+
assert.match(msg, /killed signal SIGTERM/);
|
|
30
|
+
assert.match(msg, /final useful line/);
|
|
31
|
+
assert.ok(msg.length < 1500);
|
|
32
|
+
});
|
|
@@ -7,12 +7,14 @@ export function shouldRouteDiscordTextToAgent({ content = '', channelId = '', tr
|
|
|
7
7
|
return String(channelId || '') === target;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export function appendRecentDiscordText(state, { channelId = '', authorLabel = 'user', content = '', now = Date.now(), maxEntries = 12 } = {}) {
|
|
10
|
+
export function appendRecentDiscordText(state, { channelId = '', authorLabel = 'user', content = '', now = Date.now(), maxEntries = 12, messageId = '' } = {}) {
|
|
11
11
|
const id = String(channelId || '').trim();
|
|
12
12
|
const text = String(content || '').trim();
|
|
13
13
|
if (!id || !text || text.startsWith('!')) return;
|
|
14
14
|
const entries = state.get(id) || [];
|
|
15
|
-
|
|
15
|
+
const mid = String(messageId || '').trim();
|
|
16
|
+
if (mid && entries.some(entry => entry.messageId === mid)) return;
|
|
17
|
+
entries.push({ at: Number(now) || Date.now(), authorLabel: String(authorLabel || 'user'), content: text.slice(0, 500), messageId: mid || undefined });
|
|
16
18
|
state.set(id, entries.slice(-maxEntries));
|
|
17
19
|
}
|
|
18
20
|
|