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,455 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ buildAgentSettings,
6
+ createAgentAdapter,
7
+ extractVerboseProgressEvents,
8
+ interruptedAgentMessage,
9
+ isPatchLikeOutput,
10
+ resolveExecTimeout,
11
+ sanitizeAgentOutput,
12
+ voiceBridgePrompt,
13
+ } from './agent_adapters.mjs';
14
+ import { assertAgentAdapterContract } from './agent_contract.mjs';
15
+
16
+ test('buildAgentSettings defaults to Hermes backend and uses VerbalCoding session file', () => {
17
+ const settings = buildAgentSettings({ ROOT: '/project', env: {} });
18
+
19
+ assert.equal(settings.backend, 'hermes');
20
+ assert.equal(settings.label, 'Hermes Agent');
21
+ assert.equal(settings.command, 'hermes chat -Q -q');
22
+ assert.equal(settings.sessionFile, '/project/.verbalcoding-session');
23
+ assert.equal(settings.cwd, '/project');
24
+ assert.equal(settings.taskTimeoutMs, 0);
25
+ });
26
+
27
+ test('Hermes adapter resumes and saves Hermes CLI session ids', async () => {
28
+ const calls = [];
29
+ const files = new Map([['/tmp/hermes-session', 'old-session\n']]);
30
+ const adapter = createAgentAdapter({
31
+ backend: 'hermes',
32
+ label: 'Hermes Agent',
33
+ command: 'hermes chat -Q -q',
34
+ sessionFile: '/tmp/hermes-session',
35
+ taskTimeoutMs: 300000,
36
+ chatTimeoutMs: 45000,
37
+ }, {
38
+ readFileSync: path => files.get(path),
39
+ writeFileSync: (path, value) => files.set(path, value),
40
+ execFileAsync: async (cmd, args) => {
41
+ calls.push({ cmd, args });
42
+ return { stdout: '좋아, 처리했어.\n', stderr: 'session_id: new-session\n' };
43
+ },
44
+ log: () => {},
45
+ warn: () => {},
46
+ });
47
+
48
+ const answer = await adapter.ask('테스트해줘');
49
+
50
+ assert.equal(answer, '좋아, 처리했어.');
51
+ assert.equal(calls[0].cmd, 'hermes');
52
+ assert.deepEqual(calls[0].args.slice(0, 5), ['chat', '-Q', '--resume', 'old-session', '-q']);
53
+ assert.match(calls[0].args.at(-1), /Discord 음성 대화로 들어온 사용자 발화다/);
54
+ assert.match(calls[0].args.at(-1), /테스트해줘/);
55
+ assert.equal(files.get('/tmp/hermes-session'), 'new-session\n');
56
+ assert.equal(assertAgentAdapterContract(adapter), true);
57
+ assert.equal(adapter.capabilities.supportsSessionResume, true);
58
+ });
59
+
60
+ test('adapter passes project context prompt and cwd for project sessions', async () => {
61
+ const calls = [];
62
+ const adapter = createAgentAdapter({
63
+ backend: 'hermes',
64
+ label: 'Hermes Agent · Wiki',
65
+ command: 'hermes chat -Q -q',
66
+ sessionFile: '/tmp/wiki-session',
67
+ cwd: '/tmp/wiki-workdir',
68
+ taskTimeoutMs: 300000,
69
+ chatTimeoutMs: 45000,
70
+ }, {
71
+ readFileSync: () => '',
72
+ writeFileSync: () => {},
73
+ execFileAsync: async (cmd, args, options) => {
74
+ calls.push({ cmd, args, options });
75
+ return { stdout: '처리했어.\n', stderr: 'session_id: wiki-session\n' };
76
+ },
77
+ log: () => {},
78
+ warn: () => {},
79
+ });
80
+
81
+ const result = await adapter.run('프로젝트 상태 봐줘', undefined, {
82
+ language: 'ko',
83
+ projectContext: 'Project session: Wiki\nWorking directory: /tmp/wiki-workdir\nMCP/project context: wiki graph',
84
+ });
85
+
86
+ assert.equal(result.answer, '처리했어.');
87
+ assert.equal(calls[0].options.cwd, '/tmp/wiki-workdir');
88
+ assert.match(calls[0].args.at(-1), /Project session: Wiki/);
89
+ assert.match(calls[0].args.at(-1), /MCP\/project context: wiki graph/);
90
+ });
91
+
92
+ test('buildAgentSettings accepts per-instance AGENT_CWD and AGENT_PROJECT_CONTEXT aliases', () => {
93
+ const settings = buildAgentSettings({ ROOT: '/repo', env: {
94
+ AGENT_BACKEND: 'hermes',
95
+ AGENT_CWD: '/repo/llm-wiki',
96
+ AGENT_PROJECT_CONTEXT: 'Project session: LLM-Wiki',
97
+ } });
98
+
99
+ assert.equal(settings.cwd, '/repo/llm-wiki');
100
+ assert.equal(settings.projectContext, 'Project session: LLM-Wiki');
101
+ });
102
+
103
+ test('adapter uses default project context from settings when plan omits it', async () => {
104
+ const calls = [];
105
+ const adapter = createAgentAdapter({
106
+ backend: 'codex',
107
+ label: 'Codex · Wiki',
108
+ command: 'codex exec',
109
+ sessionFile: '/tmp/codex-session',
110
+ cwd: '/tmp/wiki-workdir',
111
+ projectContext: 'Project session: LLM-Wiki',
112
+ taskTimeoutMs: 300000,
113
+ chatTimeoutMs: 45000,
114
+ }, {
115
+ execFileAsync: async (cmd, args, options) => {
116
+ calls.push({ cmd, args, options });
117
+ return { stdout: 'done\n', stderr: '' };
118
+ },
119
+ log: () => {},
120
+ warn: () => {},
121
+ });
122
+
123
+ await adapter.run('status?', undefined, { language: 'en' });
124
+ assert.equal(calls[0].options.cwd, '/tmp/wiki-workdir');
125
+ assert.match(calls[0].args.at(-1), /Project session: LLM-Wiki/);
126
+ });
127
+
128
+ test('adapter run returns formal result while ask keeps string compatibility', async () => {
129
+ const adapter = createAgentAdapter({
130
+ backend: 'hermes',
131
+ label: 'Hermes Agent',
132
+ command: 'hermes chat -Q -q',
133
+ sessionFile: '/tmp/hermes-session',
134
+ taskTimeoutMs: 300000,
135
+ chatTimeoutMs: 45000,
136
+ }, {
137
+ readFileSync: () => '',
138
+ writeFileSync: () => {},
139
+ execFileAsync: async () => ({ stdout: 'Done.\n', stderr: 'session_id: new-session\n' }),
140
+ log: () => {},
141
+ warn: () => {},
142
+ });
143
+
144
+ const result = await adapter.run({ text: 'do it' }, undefined, { language: 'en' });
145
+ assert.equal(result.contractVersion, 1);
146
+ assert.equal(result.status, 'ok');
147
+ assert.equal(result.answer, 'Done.');
148
+ assert.equal(result.backend, 'hermes');
149
+ assert.equal(result.sessionId, 'new-session');
150
+ assert.equal(await adapter.ask('do it', undefined, { language: 'en' }), 'Done.');
151
+ });
152
+
153
+ test('Hermes verbose progress drops quiet flag and parses rich CLI final response', async () => {
154
+ const calls = [];
155
+ const progress = [];
156
+ const files = new Map([['/tmp/hermes-session', 'old-session\n']]);
157
+ const adapter = createAgentAdapter({
158
+ backend: 'hermes',
159
+ label: 'Hermes Agent',
160
+ command: 'hermes chat -Q -q',
161
+ sessionFile: '/tmp/hermes-session',
162
+ taskTimeoutMs: 300000,
163
+ chatTimeoutMs: 45000,
164
+ }, {
165
+ readFileSync: path => files.get(path),
166
+ writeFileSync: (path, value) => files.set(path, value),
167
+ spawn: (_cmd, args) => {
168
+ calls.push({ args });
169
+ const listeners = {};
170
+ const child = {
171
+ stdout: { on: (event, cb) => { listeners[`stdout:${event}`] = cb; } },
172
+ stderr: { on: (event, cb) => { listeners[`stderr:${event}`] = cb; } },
173
+ on: (event, cb) => { listeners[event] = cb; },
174
+ kill: () => {},
175
+ };
176
+ queueMicrotask(() => {
177
+ listeners['stdout:data']?.(Buffer.from(' ┊ 💻 $ terminal 0.8s\n'));
178
+ listeners['stdout:data']?.(Buffer.from('╭─ ⚕ Hermes ─╮\n 완료했어.\n╰────────────╯\n\nSession: new-session\n'));
179
+ listeners.close?.(0, null);
180
+ });
181
+ return child;
182
+ },
183
+ execFileAsync: async () => { throw new Error('spawn should be used for verbose progress'); },
184
+ onProgress: event => progress.push(event),
185
+ log: () => {},
186
+ warn: () => {},
187
+ });
188
+
189
+ const answer = await adapter.ask('테스트해줘', undefined, { verboseProgress: true });
190
+
191
+ assert.equal(calls[0].args.includes('-Q'), false);
192
+ assert.deepEqual(calls[0].args.slice(0, 4), ['chat', '--resume', 'old-session', '-q']);
193
+ assert.equal(answer, '완료했어.');
194
+ assert.equal(files.get('/tmp/hermes-session'), 'new-session\n');
195
+ assert.ok(progress.includes('Hermes Agent 호출 시작'));
196
+ assert.ok(progress.includes('터미널 도구 사용 terminal'));
197
+ });
198
+
199
+ test('Hermes verbose progress uses English lifecycle events when language is English', async () => {
200
+ const progress = [];
201
+ const adapter = createAgentAdapter({
202
+ backend: 'hermes',
203
+ label: 'Hermes Agent',
204
+ command: 'hermes chat -Q -q',
205
+ sessionFile: '/tmp/hermes-session',
206
+ taskTimeoutMs: 300000,
207
+ chatTimeoutMs: 45000,
208
+ }, {
209
+ readFileSync: () => '',
210
+ writeFileSync: () => {},
211
+ spawn: () => {
212
+ const listeners = {};
213
+ const child = {
214
+ stdout: { on: (event, cb) => { listeners[`stdout:${event}`] = cb; } },
215
+ stderr: { on: (event, cb) => { listeners[`stderr:${event}`] = cb; } },
216
+ on: (event, cb) => { listeners[event] = cb; },
217
+ kill: () => {},
218
+ };
219
+ queueMicrotask(() => {
220
+ listeners['stdout:data']?.(Buffer.from('╭─ ⚕ Hermes ─╮\n Done.\n╰────────────╯\n'));
221
+ listeners.close?.(0, null);
222
+ });
223
+ return child;
224
+ },
225
+ execFileAsync: async () => { throw new Error('spawn should be used'); },
226
+ onProgress: event => progress.push(event),
227
+ log: () => {},
228
+ warn: () => {},
229
+ });
230
+
231
+ const answer = await adapter.ask('do it', undefined, { verboseProgress: true, language: 'en' });
232
+
233
+ assert.equal(answer, 'Done.');
234
+ assert.ok(progress.includes('calling the agent Hermes Agent'));
235
+ assert.ok(progress.includes('received agent response Hermes Agent'));
236
+ });
237
+
238
+ test('Hermes adapter falls back to saved session final answer when verbose CLI output omits it', async () => {
239
+ const files = new Map([
240
+ ['/tmp/hermes-session', 'old-session\n'],
241
+ ['/tmp/hermes-sessions/session_new-session.json', JSON.stringify({
242
+ messages: [
243
+ { role: 'user', content: '질문' },
244
+ { role: 'assistant', content: 'VERBALCODING_PROGRESS: 로그 확인' },
245
+ { role: 'assistant', content: '실제 최종 답변이야.' },
246
+ ],
247
+ })],
248
+ ]);
249
+ const adapter = createAgentAdapter({
250
+ backend: 'hermes',
251
+ label: 'Hermes Agent',
252
+ command: 'hermes chat -Q -q',
253
+ sessionFile: '/tmp/hermes-session',
254
+ taskTimeoutMs: 300000,
255
+ chatTimeoutMs: 45000,
256
+ }, {
257
+ hermesSessionsDir: '/tmp/hermes-sessions',
258
+ readFileSync: path => files.get(path),
259
+ writeFileSync: (path, value) => files.set(path, value),
260
+ spawn: (_cmd, _args) => {
261
+ const listeners = {};
262
+ const child = {
263
+ stdout: { on: (event, cb) => { listeners[`stdout:${event}`] = cb; } },
264
+ stderr: { on: (event, cb) => { listeners[`stderr:${event}`] = cb; } },
265
+ on: (event, cb) => { listeners[event] = cb; },
266
+ kill: () => {},
267
+ };
268
+ queueMicrotask(() => {
269
+ listeners['stdout:data']?.(Buffer.from('VERBALCODING_PROGRESS: 로그 확인\nSession: new-session\n'));
270
+ listeners.close?.(0, null);
271
+ });
272
+ return child;
273
+ },
274
+ execFileAsync: async () => { throw new Error('spawn should be used'); },
275
+ log: () => {},
276
+ warn: () => {},
277
+ });
278
+
279
+ const answer = await adapter.ask('분석해줘', undefined, { verboseProgress: true });
280
+
281
+ assert.equal(answer, '실제 최종 답변이야.');
282
+ });
283
+
284
+ test('Claude, Codex, and Gemini adapters use backend-specific default commands without Hermes resume', async () => {
285
+ const cases = [
286
+ { backend: 'claude', command: ['claude', '-p'], label: 'Claude Code' },
287
+ { backend: 'codex', command: ['codex', 'exec'], label: 'Codex' },
288
+ { backend: 'gemini', command: ['gemini', '-p'], label: 'Gemini' },
289
+ { backend: 'opencode', command: ['opencode', 'run'], label: 'OpenCode' },
290
+ { backend: 'openclaw', command: ['openclaw', 'run'], label: 'OpenClaw' },
291
+ ];
292
+
293
+ for (const item of cases) {
294
+ const calls = [];
295
+ const settings = buildAgentSettings({ ROOT: '/project', env: { AGENT_BACKEND: item.backend } });
296
+ assert.equal(settings.label, item.label);
297
+
298
+ const adapter = createAgentAdapter(settings, {
299
+ execFileAsync: async (cmd, args) => {
300
+ calls.push({ cmd, args });
301
+ return { stdout: `${item.label} 응답\n`, stderr: '' };
302
+ },
303
+ log: () => {},
304
+ warn: () => {},
305
+ });
306
+
307
+ const answer = await adapter.ask('작업해줘');
308
+
309
+ assert.equal(answer, `${item.label} 응답`);
310
+ assert.equal(calls[0].cmd, item.command[0]);
311
+ assert.deepEqual(calls[0].args.slice(0, item.command.length - 1), item.command.slice(1));
312
+ assert.equal(calls[0].args.includes('--resume'), false);
313
+ assert.match(calls[0].args.at(-1), /작업해줘/);
314
+ }
315
+ });
316
+
317
+ test('custom adapter uses AGENT_COMMAND and AGENT_LABEL', async () => {
318
+ const settings = buildAgentSettings({
319
+ ROOT: '/project',
320
+ env: { AGENT_BACKEND: 'custom', AGENT_COMMAND: 'my-agent --ask', AGENT_LABEL: 'My Agent' },
321
+ });
322
+ const calls = [];
323
+ const adapter = createAgentAdapter(settings, {
324
+ execFileAsync: async (cmd, args) => {
325
+ calls.push({ cmd, args });
326
+ return { stdout: '완료\n', stderr: '' };
327
+ },
328
+ log: () => {},
329
+ warn: () => {},
330
+ });
331
+
332
+ const answer = await adapter.ask('해줘');
333
+
334
+ assert.equal(settings.label, 'My Agent');
335
+ assert.equal(answer, '완료');
336
+ assert.equal(calls[0].cmd, 'my-agent');
337
+ assert.deepEqual(calls[0].args.slice(0, -1), ['--ask']);
338
+ });
339
+
340
+ test('sanitizeAgentOutput strips CLI metadata from spoken/text answer', () => {
341
+ assert.equal(
342
+ sanitizeAgentOutput('↻ Resumed session abc\nsession_id: xyz\n진짜 답변\n'),
343
+ '진짜 답변',
344
+ );
345
+ assert.equal(
346
+ sanitizeAgentOutput('╭─ ⚕ Hermes ─╮\n 박스 답변\n╰────────────╯\nSession: abc\n'),
347
+ '박스 답변',
348
+ );
349
+ });
350
+
351
+ test('voiceBridgePrompt keeps voice-specific operating instructions with user text', () => {
352
+ const prompt = voiceBridgePrompt('파일 수정해줘');
353
+
354
+ assert.match(prompt, /Discord 음성 대화/);
355
+ assert.match(prompt, /파일 수정, 실행, 로그 확인/);
356
+ assert.match(prompt, /파일 수정해줘/);
357
+ });
358
+
359
+ test('voiceBridgePrompt adds optional verbose progress instructions only when enabled', () => {
360
+ const normal = voiceBridgePrompt('파일 수정해줘');
361
+ const verbose = voiceBridgePrompt('파일 수정해줘', { verboseProgress: true });
362
+
363
+ assert.doesNotMatch(normal, /VERBALCODING_PROGRESS/);
364
+ assert.match(verbose, /VERBALCODING_PROGRESS/);
365
+ assert.match(verbose, /파일 읽기|웹 검색|터미널 실행|툴 사용/);
366
+ });
367
+
368
+ test('voiceBridgePrompt uses English instructions and progress language when requested', () => {
369
+ const prompt = voiceBridgePrompt('test this', { verboseProgress: true, language: 'en' });
370
+
371
+ assert.match(prompt, /Answer in English/);
372
+ assert.match(prompt, /VERBALCODING_PROGRESS: reading files/);
373
+ assert.doesNotMatch(prompt, /짧은 한국어 단계/);
374
+ });
375
+
376
+ test('extractVerboseProgressEvents summarizes tool activity without leaking raw logs', () => {
377
+ const events = extractVerboseProgressEvents([
378
+ 'VERBALCODING_PROGRESS: 파일 읽기 app-node/main.mjs',
379
+ 'Calling tool functions.web_search with query secret token abcdef',
380
+ 'tool_use: terminal command="npm test"',
381
+ 'Calling tool functions.skill_view with name discord-voice-hermes-bridge',
382
+ ' ┊ 🔎 web_search 1.2s',
383
+ 'unrelated verbose log line that should not be included',
384
+ ].join('\n'));
385
+
386
+ assert.deepEqual(events, [
387
+ '파일 읽기 app-node/main.mjs',
388
+ '웹 검색 실행',
389
+ '터미널 명령 실행',
390
+ '스킬 사용',
391
+ ]);
392
+ });
393
+
394
+ test('extractVerboseProgressEvents localizes implicit tool activity to English', () => {
395
+ const events = extractVerboseProgressEvents([
396
+ 'Calling tool functions.web_search with query secret token abcdef',
397
+ 'tool_use: terminal command="npm test"',
398
+ 'Calling tool functions.skill_view with name discord-voice-hermes-bridge',
399
+ ' ┊ 📖 read_file 0.5s',
400
+ ' ┊ 🔎 web_search 1.2s',
401
+ ].join('\n'), { language: 'en' });
402
+
403
+ assert.deepEqual(events, [
404
+ 'searching web',
405
+ 'running terminal commands',
406
+ 'loading skills',
407
+ 'using file tool read_file',
408
+ ]);
409
+ });
410
+
411
+ test('resolveExecTimeout disables timeout for zero or invalid task timeout values', () => {
412
+ assert.equal(resolveExecTimeout(0), undefined);
413
+ assert.equal(resolveExecTimeout(-1), undefined);
414
+ assert.equal(resolveExecTimeout(Infinity), undefined);
415
+ assert.equal(resolveExecTimeout(45000), 45000);
416
+ });
417
+
418
+ test('signal failure with patch-like output returns a concise interruption message instead of diff', async () => {
419
+ const adapter = createAgentAdapter({
420
+ backend: 'hermes',
421
+ label: 'Hermes Agent',
422
+ command: 'hermes chat -Q -q',
423
+ sessionFile: '/tmp/hermes-session',
424
+ taskTimeoutMs: 1,
425
+ chatTimeoutMs: 1,
426
+ }, {
427
+ readFileSync: () => '',
428
+ writeFileSync: () => {},
429
+ execFileAsync: async () => {
430
+ const err = new Error('Command failed');
431
+ err.signal = 'SIGINT';
432
+ err.stdout = '┊ review diff\na/file → b/file\n@@ -1 +1 @@\n-old\n+new\n+more\n+more\n+more\n+more\n+more\n+more\n+more\n+more\n';
433
+ err.stderr = '';
434
+ throw err;
435
+ },
436
+ log: () => {},
437
+ warn: () => {},
438
+ });
439
+
440
+ const answer = await adapter.ask('수정해줘');
441
+
442
+ assert.equal(isPatchLikeOutput('┊ review diff\na/file → b/file\n@@ -1 +1 @@\n-old\n+new'), true);
443
+ assert.equal(answer, interruptedAgentMessage('Hermes Agent', true));
444
+ assert.doesNotMatch(answer, /@@|review diff|old|new/);
445
+ });
446
+
447
+ test('hermes adapter spawn carries HERMES_HOME from instance env into child env', async () => {
448
+ const { buildHermesSpawnOptions } = await import('./agent_adapters.mjs');
449
+ const opts = buildHermesSpawnOptions({
450
+ parentEnv: { PATH: '/usr/bin', HERMES_HOME: '/parent/.hermes' },
451
+ instanceEnv: { HERMES_HOME: '/home/you/.hermes/profiles/my-project' },
452
+ });
453
+ assert.equal(opts.env.HERMES_HOME, '/home/you/.hermes/profiles/my-project');
454
+ assert.equal(opts.env.PATH, '/usr/bin');
455
+ });
@@ -0,0 +1,45 @@
1
+ export const AGENT_ADAPTER_CONTRACT_VERSION = 1;
2
+
3
+ export function agentAdapterCapabilities(settings = {}) {
4
+ const backend = settings.backend || 'custom';
5
+ return {
6
+ contractVersion: AGENT_ADAPTER_CONTRACT_VERSION,
7
+ backend,
8
+ label: settings.label || backend,
9
+ supportsSessionResume: Boolean(settings.supportsHermesSession),
10
+ supportsStreamingProgress: true,
11
+ supportsCancellation: true,
12
+ returnsFinalText: true,
13
+ };
14
+ }
15
+
16
+ export function createAgentRunResult({
17
+ status = 'ok',
18
+ answer = '',
19
+ backend = 'custom',
20
+ label = 'Agent',
21
+ elapsedMs = 0,
22
+ sessionId = null,
23
+ recoveredFromSession = false,
24
+ error = null,
25
+ } = {}) {
26
+ return {
27
+ contractVersion: AGENT_ADAPTER_CONTRACT_VERSION,
28
+ status,
29
+ answer: String(answer || ''),
30
+ backend,
31
+ label,
32
+ elapsedMs,
33
+ sessionId,
34
+ recoveredFromSession,
35
+ error,
36
+ };
37
+ }
38
+
39
+ export function assertAgentAdapterContract(adapter) {
40
+ const missing = ['backend', 'label', 'capabilities', 'run', 'ask', 'buildArgs'].filter(key => !(key in (adapter || {})));
41
+ if (missing.length) throw new Error(`Agent adapter contract missing: ${missing.join(', ')}`);
42
+ if (typeof adapter.run !== 'function') throw new Error('Agent adapter contract requires run(request, signal, plan)');
43
+ if (typeof adapter.ask !== 'function') throw new Error('Agent adapter contract requires ask(text, signal, plan)');
44
+ return true;
45
+ }
@@ -0,0 +1,148 @@
1
+ export function pcm16StereoLevels(pcm) {
2
+ if (!pcm || pcm.length < 2) return { meanDb: -Infinity, maxDb: -Infinity, sampleCount: 0 };
3
+ const usable = pcm.length - (pcm.length % 2);
4
+ let sumSquares = 0;
5
+ let maxAbs = 0;
6
+ let sampleCount = 0;
7
+ for (let offset = 0; offset < usable; offset += 2) {
8
+ const sample = pcm.readInt16LE(offset);
9
+ const abs = Math.abs(sample);
10
+ if (abs > maxAbs) maxAbs = abs;
11
+ const normalized = sample / 32768;
12
+ sumSquares += normalized * normalized;
13
+ sampleCount += 1;
14
+ }
15
+ if (!sampleCount) return { meanDb: -Infinity, maxDb: -Infinity, sampleCount: 0 };
16
+ const rms = Math.sqrt(sumSquares / sampleCount);
17
+ return {
18
+ meanDb: rms > 0 ? 20 * Math.log10(rms) : -Infinity,
19
+ maxDb: maxAbs > 0 ? 20 * Math.log10(maxAbs / 32768) : -Infinity,
20
+ sampleCount,
21
+ };
22
+ }
23
+
24
+ export function isBargeInCandidate(pcmBytes, levels, thresholds) {
25
+ const minBytes = Number(thresholds?.minBytes ?? 0);
26
+ const minMeanDb = Number(thresholds?.minMeanDb ?? -Infinity);
27
+ const minMaxDb = Number(thresholds?.minMaxDb ?? -Infinity);
28
+ const requireBoth = Boolean(thresholds?.requireBoth);
29
+ if (pcmBytes < minBytes) return false;
30
+ if (requireBoth) return levels.meanDb >= minMeanDb && levels.maxDb >= minMaxDb;
31
+ return levels.meanDb >= minMeanDb || levels.maxDb >= minMaxDb;
32
+ }
33
+
34
+ export function bargeInThresholdsForMode(mode, base = {}) {
35
+ const minSeconds = Number(base.minSeconds ?? 1.4);
36
+ const minMeanDb = Number(base.minMeanDb ?? -30);
37
+ const minMaxDb = Number(base.minMaxDb ?? -14);
38
+ if (mode === 'conservative') {
39
+ const conservativeSeconds = Number(base.conservativeMinSeconds ?? Math.max(minSeconds, 1.8));
40
+ return {
41
+ minBytes: 48000 * 2 * 2 * conservativeSeconds,
42
+ minSeconds: conservativeSeconds,
43
+ minMeanDb: Number(base.conservativeMinMeanDb ?? Math.max(minMeanDb, -27)),
44
+ minMaxDb: Number(base.conservativeMinMaxDb ?? Math.max(minMaxDb, -12)),
45
+ mode: 'conservative',
46
+ };
47
+ }
48
+ return {
49
+ minBytes: 48000 * 2 * 2 * minSeconds,
50
+ minSeconds,
51
+ minMeanDb,
52
+ minMaxDb,
53
+ mode: 'normal',
54
+ };
55
+ }
56
+
57
+ export function sensitivityModeFromTranscript(text) {
58
+ const compact = String(text || '').toLowerCase().replace(/\s+/g, '');
59
+ if (!compact) return null;
60
+ // Do not treat complaints/questions that merely mention a mode as commands.
61
+ if (/(누가|왜|뭐|아니|안|하지마|말고|싫|바꾸랬|하랬|랬어|랬냐|\?)/u.test(compact)) return null;
62
+ if (/(실내|집|조용|평소|기본|일반).*(감도|모드).*(해|켜|바꿔|변경|설정)|감도(올려|높여|평소|기본|일반)(해|로|으로|켜|바꿔|변경|설정)?/u.test(compact)) {
63
+ return { mode: 'normal', reason: 'indoor' };
64
+ }
65
+ if (/(외부|밖|야외|시끄럽|소음|지하철|버스|길거리|카페).*(감도|모드|낮춰|보수|둔감).*(해|켜|바꿔|변경|설정)|감도(낮춰|내려)(해|줘|라|켜|바꿔|변경|설정)?|보수모드(해|켜|바꿔|변경|설정)/u.test(compact)) {
66
+ return { mode: 'conservative', reason: 'outdoor' };
67
+ }
68
+ return null;
69
+ }
70
+
71
+ export function isRepeatedNoiseTranscript(text) {
72
+ const compact = String(text || '')
73
+ .normalize('NFC')
74
+ .replace(/\s+/g, '')
75
+ .replace(/[\p{P}\p{S}_]+/gu, '');
76
+ if (!compact) return true;
77
+ // Keep common short-but-valid Korean utterances. These are frequent phone-call
78
+ // probes/answers and should not be erased after Whisper correctly hears them.
79
+ if (/^(안녕|안녕하세요|야|네|예|응|그래|좋아|오케이|okay|ok)$/iu.test(compact)) return false;
80
+ if (compact.length <= 1) return true;
81
+ if (/^(쒸|쉬|쉿|씁|흠|음|어|아|으|앗|악)$/u.test(compact)) return true;
82
+ if (compact.length <= 3 && !/(잠깐|멈춰|그만|중지|스톱|stop)/iu.test(compact)) return true;
83
+
84
+ // Repetition/low-unique-ratio filtering is for short Korean noise/hallucination
85
+ // fragments. English sentences often repeat ordinary letters/words ("the problem",
86
+ // "fuck you") and were being deleted here after Whisper recognized them correctly.
87
+ const hasLatin = /\p{Script=Latin}/u.test(compact);
88
+ const hasHangul = /\p{Script=Hangul}/u.test(compact);
89
+ if (hasLatin && !hasHangul) return false;
90
+
91
+ for (let size = 1; size <= Math.floor(compact.length / 2); size += 1) {
92
+ if (compact.length % size !== 0) continue;
93
+ const unit = compact.slice(0, size);
94
+ if (unit.repeat(compact.length / size) === compact) return true;
95
+ }
96
+ const chars = [...compact];
97
+ const uniqueRatio = new Set(chars).size / chars.length;
98
+ return chars.length >= 4 && uniqueRatio <= 0.35 && !/(잠깐|멈춰|그만|중지|스톱|stop)/iu.test(compact);
99
+ }
100
+
101
+ export function isExplicitBargeInTranscript(text) {
102
+ const compact = String(text || '')
103
+ .normalize('NFC')
104
+ .replace(/\s+/g, '')
105
+ .replace(/[\p{P}\p{S}_]+/gu, '')
106
+ .toLowerCase();
107
+ if (!compact || isRepeatedNoiseTranscript(compact)) return false;
108
+ return /(잠깐|멈춰|그만|중지|취소|끊어|스톱|스탑|stop|cancel|abort|shutup|quiet|silence|enough|stopit|stoptalking|말하지마|말그만|조용)/iu.test(compact);
109
+ }
110
+
111
+ export function shouldUseLivePlaybackBargeIn({ speaking, processing }) {
112
+ return Boolean(speaking && !processing);
113
+ }
114
+
115
+ export function createLiveBargeInMonitor({
116
+ minBytes,
117
+ minMeanDb,
118
+ minMaxDb,
119
+ requireBoth = false,
120
+ onConfirm,
121
+ log = () => {},
122
+ }) {
123
+ const chunks = [];
124
+ let bytes = 0;
125
+ let confirmed = false;
126
+ return {
127
+ push(chunk) {
128
+ if (confirmed || !chunk?.length) return false;
129
+ chunks.push(chunk);
130
+ bytes += chunk.length;
131
+ if (bytes < minBytes) return false;
132
+ const levels = pcm16StereoLevels(Buffer.concat(chunks, bytes));
133
+ if (!isBargeInCandidate(bytes, levels, { minBytes, minMeanDb, minMaxDb, requireBoth })) {
134
+ log('live barge-in below volume threshold', 'pcmBytes', bytes, 'meanDb', levels.meanDb, 'maxDb', levels.maxDb);
135
+ return false;
136
+ }
137
+ confirmed = true;
138
+ onConfirm?.({ pcmBytes: bytes, levels });
139
+ return true;
140
+ },
141
+ get confirmed() {
142
+ return confirmed;
143
+ },
144
+ get bytes() {
145
+ return bytes;
146
+ },
147
+ };
148
+ }