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
|
@@ -11,20 +11,58 @@ const CATEGORY_LABELS = {
|
|
|
11
11
|
|
|
12
12
|
const CATEGORY_RULES = [
|
|
13
13
|
{ key: 'test', pattern: /(테스트|test|pytest|npm test|node --test)/i },
|
|
14
|
-
{ key: 'edit', pattern: /(파일\s*수정|수정|patch|write_file|쓰기|변경|edit)/i },
|
|
15
|
-
{ key: 'read', pattern: /(파일\s*읽기|read_file
|
|
16
|
-
{ key: 'search', pattern: /(웹\s*검색|검색|web_search|search_files
|
|
17
|
-
{ key: 'terminal', pattern: /(터미널|명령|terminal|shell
|
|
18
|
-
{ key: 'skill', pattern: /(스킬|skill)/i },
|
|
19
|
-
{ key: 'browser', pattern: /(브라우저|browser)/i },
|
|
20
|
-
{ key: 'tool', pattern: /(툴|도구|tool)/i },
|
|
21
|
-
{ key: 'agent', pattern: /(에이전트|agent|hermes)/i },
|
|
14
|
+
{ key: 'edit', pattern: /(파일\s*수정|수정|patch|write_file|쓰기|변경|edit|editing\s+files?)/i },
|
|
15
|
+
{ key: 'read', pattern: /(파일\s*읽기|read_file|읽기|열람|reading\s+files?)/i },
|
|
16
|
+
{ key: 'search', pattern: /(웹\s*검색|검색|web_search|search_files|찾기|searching)/i },
|
|
17
|
+
{ key: 'terminal', pattern: /(터미널|명령|terminal|shell|실행|running\s+terminal\s+commands?)/i },
|
|
18
|
+
{ key: 'skill', pattern: /(스킬|skill|loading\s+skills?)/i },
|
|
19
|
+
{ key: 'browser', pattern: /(브라우저|browser|checking\s+the\s+browser)/i },
|
|
20
|
+
{ key: 'tool', pattern: /(툴|도구|tool|using\s+tools?)/i },
|
|
21
|
+
{ key: 'agent', pattern: /(에이전트|agent|hermes|calling\s+the\s+agent|received\s+agent\s+response)/i },
|
|
22
22
|
];
|
|
23
23
|
|
|
24
24
|
function labelsFor(language = 'ko') {
|
|
25
25
|
return /^en/i.test(String(language || '')) ? CATEGORY_LABELS.en : CATEGORY_LABELS.ko;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function categoryEmoji(key = 'work') {
|
|
29
|
+
return {
|
|
30
|
+
test: '🧪',
|
|
31
|
+
edit: '✏️',
|
|
32
|
+
read: '📖',
|
|
33
|
+
search: '🔎',
|
|
34
|
+
terminal: '💻',
|
|
35
|
+
skill: '📚',
|
|
36
|
+
browser: '🌐',
|
|
37
|
+
agent: '🤖',
|
|
38
|
+
tool: '🛠️',
|
|
39
|
+
work: '⚙️',
|
|
40
|
+
}[key] || '⚙️';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripProgressPrefix(text, category, { keepPaths = false, language = 'ko' } = {}) {
|
|
44
|
+
let detail = String(text || '')
|
|
45
|
+
.replace(/^VERBALCODING_PROGRESS:\s*/i, '')
|
|
46
|
+
.replace(/^(파일\s*읽기|파일\s*수정|웹\s*검색|터미널\s*(명령\s*)?실행|테스트\s*실행|스킬\s*사용|툴\s*사용|브라우저\s*확인|에이전트\s*(호출|처리|응답\s*수신)?|Hermes Agent\s*(호출\s*시작|응답\s*수신)?)\s*/i, '')
|
|
47
|
+
.replace(/^(reading\s+files?|editing\s+files?|searching(?:\s+web)?|running\s+(terminal\s+commands?|tests?)|loading\s+skills?|checking\s+the\s+browser|using\s+tools?|calling\s+the\s+agent|received\s+agent\s+response|working)\s*/i, '')
|
|
48
|
+
.replace(/^(read_file|write_file|patch|web_search|search_files|terminal|skill_view|tool)\s*/i, '')
|
|
49
|
+
.replace(/[`*_#>\[\](){}]/g, '')
|
|
50
|
+
.replace(/\s+/g, ' ')
|
|
51
|
+
.trim();
|
|
52
|
+
const label = category?.label || labelsFor(language).work;
|
|
53
|
+
if (detail.toLowerCase().startsWith(label.toLowerCase())) {
|
|
54
|
+
detail = detail.slice(label.length).replace(/^\s*[-:–—,;.]?\s*/u, '').trim();
|
|
55
|
+
}
|
|
56
|
+
if (!keepPaths) {
|
|
57
|
+
detail = detail
|
|
58
|
+
.replace(/\bHermes\s+Agent\b/gi, '')
|
|
59
|
+
.replace(/\b[a-zA-Z0-9_.\/-]+\.(mjs|js|py|md|json|txt|sh|yaml|yml)\b/g, '')
|
|
60
|
+
.replace(/\s+/g, ' ')
|
|
61
|
+
.trim();
|
|
62
|
+
}
|
|
63
|
+
return detail;
|
|
64
|
+
}
|
|
65
|
+
|
|
28
66
|
export function progressCategory(event, { language = 'ko' } = {}) {
|
|
29
67
|
const text = String(event || '').replace(/\s+/g, ' ').trim();
|
|
30
68
|
if (!text) return null;
|
|
@@ -37,16 +75,10 @@ export function progressDetail(event, { language = 'ko' } = {}) {
|
|
|
37
75
|
const text = String(event || '').replace(/\s+/g, ' ').trim();
|
|
38
76
|
if (!text) return '';
|
|
39
77
|
const category = progressCategory(text, { language });
|
|
40
|
-
let detail = text
|
|
41
|
-
.replace(/^VERBALCODING_PROGRESS:\s*/i, '')
|
|
42
|
-
.replace(/^(파일\s*읽기|파일\s*수정|웹\s*검색|터미널\s*(명령\s*)?실행|테스트\s*실행|스킬\s*사용|툴\s*사용|브라우저\s*확인|에이전트\s*(호출|처리|응답\s*수신)?|Hermes Agent\s*(호출\s*시작|응답\s*수신)?)\s*/i, '')
|
|
43
|
-
.replace(/^(read_file|write_file|patch|web_search|search_files|terminal|skill_view|tool)\s*/i, '')
|
|
44
|
-
.replace(/[`*_#>\[\](){}]/g, '')
|
|
45
|
-
.replace(/\b[a-zA-Z0-9_.\/-]+\.(mjs|js|py|md|json|txt|sh|yaml|yml)\b/g, '')
|
|
46
|
-
.replace(/\s+/g, ' ')
|
|
47
|
-
.trim();
|
|
78
|
+
let detail = stripProgressPrefix(text, category, { language });
|
|
48
79
|
if (!detail || detail.length < 2) return category?.label || '';
|
|
49
|
-
|
|
80
|
+
const codepoints = Array.from(detail);
|
|
81
|
+
if (codepoints.length > 28) detail = codepoints.slice(0, 27).join('').replace(/[\s,.;:,。]+$/u, '');
|
|
50
82
|
return `${category?.label || labelsFor(language).work} ${detail}`.trim();
|
|
51
83
|
}
|
|
52
84
|
|
|
@@ -54,22 +86,12 @@ export function formatProgressMessage(event, { language = 'ko' } = {}) {
|
|
|
54
86
|
const text = String(event || '').replace(/\s+/g, ' ').trim();
|
|
55
87
|
if (!text) return '';
|
|
56
88
|
const category = progressCategory(text, { language });
|
|
57
|
-
|
|
89
|
+
let detail = stripProgressPrefix(text, category, { language, keepPaths: true });
|
|
58
90
|
const english = /^en/i.test(String(language || ''));
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
edit: '✏️',
|
|
64
|
-
read: '📖',
|
|
65
|
-
search: '🔎',
|
|
66
|
-
terminal: '💻',
|
|
67
|
-
skill: '📚',
|
|
68
|
-
browser: '🌐',
|
|
69
|
-
agent: '🤖',
|
|
70
|
-
tool: '🛠️',
|
|
71
|
-
work: '⚙️',
|
|
72
|
-
}[category?.key || 'work'] || '⚙️';
|
|
91
|
+
if (english && /\p{Script=Hangul}/u.test(detail)) detail = '';
|
|
92
|
+
if (!english && /[A-Za-z]/.test(detail) && !/\p{Script=Hangul}/u.test(detail)) detail = '';
|
|
93
|
+
const body = [category?.label || (english ? 'working' : '작업 처리'), detail].filter(Boolean).join(': ');
|
|
94
|
+
const emoji = categoryEmoji(category?.key || 'work');
|
|
73
95
|
return `${emoji} ${body}`.trim();
|
|
74
96
|
}
|
|
75
97
|
|
|
@@ -16,9 +16,18 @@ test('progressDetail preserves the meaningful task name after the generic prefix
|
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
test('formatProgressMessage renders intermediate text in the selected English language', () => {
|
|
19
|
-
assert.equal(formatProgressMessage('
|
|
20
|
-
assert.equal(formatProgressMessage('
|
|
21
|
-
assert.equal(formatProgressMessage('
|
|
19
|
+
assert.equal(formatProgressMessage('reading files app-node/main.mjs', { language: 'en' }), '📖 reading files: app-node/main.mjs');
|
|
20
|
+
assert.equal(formatProgressMessage('running terminal commands npm test', { language: 'en' }), '🧪 running tests: npm test');
|
|
21
|
+
assert.equal(formatProgressMessage('editing files app-node/main.mjs restart notice', { language: 'en' }), '✏️ editing files: app-node/main.mjs restart notice');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('English spoken progress does not repeat category phrases', () => {
|
|
25
|
+
assert.equal(progressDetail('calling the agent Hermes Agent', { language: 'en' }), 'calling the agent');
|
|
26
|
+
assert.equal(summarizeProgressEvents(['calling the agent Hermes Agent'], { language: 'en' }), 'calling the agent.');
|
|
27
|
+
assert.equal(summarizeProgressEvents([
|
|
28
|
+
'calling the agent Hermes Agent',
|
|
29
|
+
'received agent response Hermes Agent',
|
|
30
|
+
], { language: 'en' }), 'calling the agent.');
|
|
22
31
|
});
|
|
23
32
|
|
|
24
33
|
test('agent progress events format as visible and speakable status', () => {
|
|
@@ -26,11 +26,14 @@ export function loadProjectSessions(configPath) {
|
|
|
26
26
|
|
|
27
27
|
export function saveProjectSessions(configPath, state) {
|
|
28
28
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
29
|
-
|
|
29
|
+
const payload = `${JSON.stringify({
|
|
30
30
|
version: 1,
|
|
31
31
|
sessions: state.sessions || {},
|
|
32
32
|
channelSessions: state.channelSessions || {},
|
|
33
|
-
}, null, 2)}\n
|
|
33
|
+
}, null, 2)}\n`;
|
|
34
|
+
const tmpPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
|
|
35
|
+
fs.writeFileSync(tmpPath, payload, { mode: 0o600 });
|
|
36
|
+
fs.renameSync(tmpPath, configPath);
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
export function createProjectSession({ root, state, name, workdir, channelId, voiceChannelId = '', transcriptChannelId = '', mcpContext = '' }) {
|
|
@@ -16,8 +16,14 @@ import {
|
|
|
16
16
|
slugifySessionName,
|
|
17
17
|
} from './project_sessions.mjs';
|
|
18
18
|
|
|
19
|
+
const __tempRoots = [];
|
|
20
|
+
test.after(() => {
|
|
21
|
+
for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
22
|
+
});
|
|
23
|
+
|
|
19
24
|
test('project sessions map Discord text and voice channel ids to isolated Hermes session files', () => {
|
|
20
25
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-sessions-'));
|
|
26
|
+
__tempRoots.push(root);
|
|
21
27
|
const state = loadProjectSessions(path.join(root, 'sessions.json'));
|
|
22
28
|
const session = createProjectSession({ root, state, name: 'LLM Wiki', workdir: '/tmp/llm-wiki', channelId: 'text-1', voiceChannelId: 'voice-1', mcpContext: 'llm-wiki graph' });
|
|
23
29
|
assert.equal(session.slug, 'llm-wiki');
|
|
@@ -30,6 +36,7 @@ test('project sessions map Discord text and voice channel ids to isolated Hermes
|
|
|
30
36
|
|
|
31
37
|
test('project sessions persist and can be rebound to another channel', () => {
|
|
32
38
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-sessions-persist-'));
|
|
39
|
+
__tempRoots.push(root);
|
|
33
40
|
const configPath = path.join(root, 'sessions.json');
|
|
34
41
|
const state = loadProjectSessions(configPath);
|
|
35
42
|
createProjectSession({ root, state, name: 'Other Project', workdir: root, channelId: 'a' });
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
const RESEARCH_EN = /^(?:please\s+)?(?:(?:ask|tell|have|let)\s+[a-z][a-z0-9 \-]{1,30}?\s+(?:to\s+)?)?(?:deep\s+)?(?:research|look\s+up|find\s+out\s+about)\s+(.+)$/i;
|
|
2
|
+
const RESEARCH_KO = /^(.+?)\s*(?:리서치(?:해줘?)?|조사(?:해줘?)?|찾아봐)\s*$/;
|
|
3
|
+
|
|
4
|
+
export function parseResearchCommand(text, language = 'en') {
|
|
5
|
+
const t = String(text || '').trim();
|
|
6
|
+
if (!t) return { type: 'none' };
|
|
7
|
+
const normalized = t.replace(/[.,!?]+$/u, '').trim();
|
|
8
|
+
const en = normalized.match(RESEARCH_EN);
|
|
9
|
+
if (en) {
|
|
10
|
+
const query = en[1].trim();
|
|
11
|
+
if (query.length >= 3) return { type: 'research', query, depth: /\bdeep\b/i.test(normalized) ? 'deep' : 'quick' };
|
|
12
|
+
}
|
|
13
|
+
const ko = normalized.match(RESEARCH_KO);
|
|
14
|
+
if (ko) {
|
|
15
|
+
const query = ko[1].trim();
|
|
16
|
+
if (query.length >= 2 && !/^(this|that|it|이거|저거|그거)$/i.test(query)) {
|
|
17
|
+
return { type: 'research', query, depth: 'quick' };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return { type: 'none' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ensureUrl(url) { try { return new URL(url).toString(); } catch { return null; } }
|
|
24
|
+
|
|
25
|
+
async function tavilySearch(query, { apiKey, fetchImpl, signal, maxResults = 5 }) {
|
|
26
|
+
const res = await fetchImpl('https://api.tavily.com/search', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
29
|
+
body: JSON.stringify({ query, max_results: maxResults, search_depth: 'basic', include_answer: true, include_raw_content: false }),
|
|
30
|
+
signal,
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) throw new Error(`tavily ${res.status}`);
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
const results = Array.isArray(data?.results) ? data.results : [];
|
|
35
|
+
return {
|
|
36
|
+
answer: String(data?.answer || '').trim(),
|
|
37
|
+
sources: results.map(r => ({ title: String(r.title || '').slice(0, 120), url: ensureUrl(r.url), snippet: String(r.content || '').slice(0, 400) })).filter(r => r.url),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function braveSearch(query, { apiKey, fetchImpl, signal, maxResults = 5 }) {
|
|
42
|
+
const url = new URL('https://api.search.brave.com/res/v1/web/search');
|
|
43
|
+
url.searchParams.set('q', query);
|
|
44
|
+
url.searchParams.set('count', String(maxResults));
|
|
45
|
+
const res = await fetchImpl(url.toString(), {
|
|
46
|
+
headers: { 'x-subscription-token': apiKey, accept: 'application/json' },
|
|
47
|
+
signal,
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) throw new Error(`brave ${res.status}`);
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
const results = Array.isArray(data?.web?.results) ? data.web.results : [];
|
|
52
|
+
return {
|
|
53
|
+
answer: '',
|
|
54
|
+
sources: results.map(r => ({ title: String(r.title || '').slice(0, 120), url: ensureUrl(r.url), snippet: String(r.description || '').slice(0, 400) })).filter(r => r.url),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function searxngSearch(query, { baseUrl, fetchImpl, signal, maxResults = 5 }) {
|
|
59
|
+
const u = new URL(String(baseUrl).replace(/\/$/, '') + '/search');
|
|
60
|
+
u.searchParams.set('q', query);
|
|
61
|
+
u.searchParams.set('format', 'json');
|
|
62
|
+
u.searchParams.set('safesearch', '1');
|
|
63
|
+
const res = await fetchImpl(u.toString(), { signal, headers: { accept: 'application/json' } });
|
|
64
|
+
if (!res.ok) throw new Error(`searxng ${res.status}`);
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
const raw = Array.isArray(data?.results) ? data.results : [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const sources = [];
|
|
69
|
+
for (const r of raw) {
|
|
70
|
+
const u2 = ensureUrl(r?.url);
|
|
71
|
+
if (!u2 || seen.has(u2)) continue;
|
|
72
|
+
seen.add(u2);
|
|
73
|
+
sources.push({
|
|
74
|
+
title: String(r.title || '').slice(0, 120),
|
|
75
|
+
url: u2,
|
|
76
|
+
snippet: String(r.content || '').replace(/\s+/g, ' ').slice(0, 400),
|
|
77
|
+
});
|
|
78
|
+
if (sources.length >= maxResults) break;
|
|
79
|
+
}
|
|
80
|
+
return { answer: '', sources };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function selectSearchBackend(env = process.env) {
|
|
84
|
+
const explicit = String(env.SEARCH_BACKEND || '').toLowerCase().trim();
|
|
85
|
+
if (explicit === 'agent') return { kind: 'agent' };
|
|
86
|
+
if (explicit === 'tavily' && env.TAVILY_API_KEY) return { kind: 'tavily', apiKey: env.TAVILY_API_KEY };
|
|
87
|
+
if (explicit === 'brave' && (env.BRAVE_SEARCH_API_KEY || env.BRAVE_API_KEY)) {
|
|
88
|
+
return { kind: 'brave', apiKey: env.BRAVE_SEARCH_API_KEY || env.BRAVE_API_KEY };
|
|
89
|
+
}
|
|
90
|
+
if (explicit === 'searxng' && env.SEARXNG_URL) return { kind: 'searxng', baseUrl: env.SEARXNG_URL };
|
|
91
|
+
if (env.TAVILY_API_KEY) return { kind: 'tavily', apiKey: env.TAVILY_API_KEY };
|
|
92
|
+
const braveKey = env.BRAVE_SEARCH_API_KEY || env.BRAVE_API_KEY;
|
|
93
|
+
if (braveKey) return { kind: 'brave', apiKey: braveKey };
|
|
94
|
+
if (env.SEARXNG_URL) return { kind: 'searxng', baseUrl: env.SEARXNG_URL };
|
|
95
|
+
if (/^1|true|yes|on$/i.test(String(env.SEARCH_BACKEND_AGENT_FALLBACK || ''))) return { kind: 'agent' };
|
|
96
|
+
return { kind: 'none' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function performSearch(query, { env = process.env, fetchImpl = globalThis.fetch, signal, maxResults = 5 } = {}) {
|
|
100
|
+
if (!fetchImpl) throw new Error('fetch is not available');
|
|
101
|
+
const backend = selectSearchBackend(env);
|
|
102
|
+
if (backend.kind === 'tavily') return tavilySearch(query, { apiKey: backend.apiKey, fetchImpl, signal, maxResults });
|
|
103
|
+
if (backend.kind === 'brave') return braveSearch(query, { apiKey: backend.apiKey, fetchImpl, signal, maxResults });
|
|
104
|
+
if (backend.kind === 'searxng') return searxngSearch(query, { baseUrl: backend.baseUrl, fetchImpl, signal, maxResults });
|
|
105
|
+
if (backend.kind === 'agent') throw new Error('agent_delegated');
|
|
106
|
+
throw new Error('no_search_backend');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function buildSynthesisPrompt({ query, sources, language = 'en' }) {
|
|
110
|
+
const en = /^en/i.test(String(language || ''));
|
|
111
|
+
const lines = [];
|
|
112
|
+
lines.push(en ? `You are summarizing web research for a voice answer. Be concise: 3 bullets total, each a single complete sentence, no markdown, no URLs in the spoken text.` : `웹 리서치 결과를 음성 답변용으로 요약해줘. 마크다운 없이 3개 불릿, 각 한 문장으로, 음성에는 URL 넣지 마.`);
|
|
113
|
+
lines.push(en ? `Query: ${query}` : `질문: ${query}`);
|
|
114
|
+
lines.push(en ? `Sources (numbered for your reference, do not read numbers aloud):` : `참고 자료 (번호는 참고용, 음성으로 읽지 마):`);
|
|
115
|
+
sources.slice(0, 5).forEach((s, i) => {
|
|
116
|
+
lines.push(`[${i + 1}] ${s.title} — ${s.snippet.replace(/\s+/g, ' ').slice(0, 240)}`);
|
|
117
|
+
});
|
|
118
|
+
lines.push(en
|
|
119
|
+
? `Output exactly three bullet lines starting with "- ". Then a blank line. Then "SOURCES:" followed by each source on its own line as "[n] title".`
|
|
120
|
+
: `정확히 세 줄의 "- "로 시작하는 불릿을 출력해. 빈 줄 하나 후 "SOURCES:" 와 함께 각 자료를 "[n] 제목" 형식으로 한 줄씩.`);
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function parseSynthesisOutput(text) {
|
|
125
|
+
const t = String(text || '').replace(/\r/g, '');
|
|
126
|
+
const sourcesIdx = t.search(/^SOURCES:/m);
|
|
127
|
+
const head = sourcesIdx >= 0 ? t.slice(0, sourcesIdx) : t;
|
|
128
|
+
const tail = sourcesIdx >= 0 ? t.slice(sourcesIdx) : '';
|
|
129
|
+
const bullets = head.split(/\n/).map(l => l.trim()).filter(l => /^[-•]\s+/.test(l)).map(l => l.replace(/^[-•]\s+/, '').trim()).filter(Boolean).slice(0, 3);
|
|
130
|
+
const sourcesText = tail.replace(/^SOURCES:\s*/m, '').trim();
|
|
131
|
+
const sourceLines = sourcesText.split(/\n/).map(l => l.trim()).filter(Boolean);
|
|
132
|
+
return { bullets, sourceLines };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function renderResearchSpeech(bullets, language = 'en') {
|
|
136
|
+
if (!bullets || !bullets.length) {
|
|
137
|
+
return /^en/i.test(String(language || ''))
|
|
138
|
+
? 'Research finding: I could not find useful sources for that query.'
|
|
139
|
+
: '리서치 결과: 쓸 만한 자료를 찾지 못했어.';
|
|
140
|
+
}
|
|
141
|
+
const en = /^en/i.test(String(language || ''));
|
|
142
|
+
const intro = en ? 'Research finding: here is what I found.' : '리서치 결과: 찾은 내용 정리할게.';
|
|
143
|
+
return [intro, ...bullets].join(' ');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function buildResearchEmbed({ query, bullets, sources, language = 'en' } = {}) {
|
|
147
|
+
const en = /^en/i.test(String(language || ''));
|
|
148
|
+
const description = (bullets || []).map(b => `• ${b}`).join('\n').slice(0, 4000) || (en ? '(no synthesis)' : '(요약 없음)');
|
|
149
|
+
const fields = (sources || []).slice(0, 5).map((s, i) => ({
|
|
150
|
+
name: `[${i + 1}] ${(s.title || 'source').slice(0, 240)}`,
|
|
151
|
+
value: (s.url || '').slice(0, 1000),
|
|
152
|
+
inline: false,
|
|
153
|
+
}));
|
|
154
|
+
return {
|
|
155
|
+
title: `${en ? '🔎 Research:' : '🔎 리서치:'} ${String(query || '').slice(0, 240)}`,
|
|
156
|
+
description,
|
|
157
|
+
color: 0x4F46E5,
|
|
158
|
+
fields,
|
|
159
|
+
footer: { text: en ? 'VerbalCoding research turn' : 'VerbalCoding 리서치 턴' },
|
|
160
|
+
timestamp: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function buildAgentDelegatedResearchPrompt({ query, language = 'en' } = {}) {
|
|
165
|
+
const en = /^en/i.test(String(language || ''));
|
|
166
|
+
const lines = [];
|
|
167
|
+
lines.push(en
|
|
168
|
+
? 'You have web access. Research the topic below using your available tools (web_search, browser, fetch, etc.) and synthesize a voice-friendly answer.'
|
|
169
|
+
: '너는 웹에 접근할 수 있어. 아래 주제를 가용한 도구로 (web_search, browser, fetch 등) 리서치해서 음성용 답변으로 정리해줘.');
|
|
170
|
+
lines.push(en ? `Topic: ${query}` : `주제: ${query}`);
|
|
171
|
+
lines.push(en
|
|
172
|
+
? 'Output format (strict):\n- Three bullet lines starting with "- ", each one complete sentence, no markdown inside the bullet, no URLs spoken.\n- One blank line.\n- "SOURCES:" header followed by up to 5 lines as "[n] Title | https://url"'
|
|
173
|
+
: '출력 형식 (엄수):\n- "- "로 시작하는 불릿 3개, 각 한 문장, 불릿 안에 마크다운/URL 금지.\n- 빈 줄 1개.\n- "SOURCES:" 헤더 후 최대 5줄, "[n] 제목 | https://url" 형식.');
|
|
174
|
+
lines.push(en ? 'Do not preface the answer with explanations or apologies. Just emit the bullets and SOURCES block.' : '답변에 설명이나 사과 문구 붙이지 마. 불릿과 SOURCES 블록만 출력.');
|
|
175
|
+
return lines.join('\n\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function parseAgentDelegatedOutput(text) {
|
|
179
|
+
const t = String(text || '').replace(/\r/g, '');
|
|
180
|
+
const sourcesIdx = t.search(/^SOURCES:/m);
|
|
181
|
+
const head = sourcesIdx >= 0 ? t.slice(0, sourcesIdx) : t;
|
|
182
|
+
const tail = sourcesIdx >= 0 ? t.slice(sourcesIdx) : '';
|
|
183
|
+
const bullets = head.split(/\n/)
|
|
184
|
+
.map(l => l.trim())
|
|
185
|
+
.filter(l => /^[-•]\s+/.test(l))
|
|
186
|
+
.map(l => l.replace(/^[-•]\s+/, '').trim())
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.slice(0, 3);
|
|
189
|
+
const sources = [];
|
|
190
|
+
const sourcesText = tail.replace(/^SOURCES:\s*/m, '');
|
|
191
|
+
for (const raw of sourcesText.split(/\n/)) {
|
|
192
|
+
const line = raw.trim();
|
|
193
|
+
if (!line) continue;
|
|
194
|
+
const m = line.match(/^\[\d+\]\s*(.+?)\s*(?:\||—|-)\s*(https?:\/\/\S+)/);
|
|
195
|
+
if (m) {
|
|
196
|
+
const url = ensureUrl(m[2]);
|
|
197
|
+
if (url) sources.push({ title: m[1].slice(0, 120), url, snippet: '' });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const urlMatch = line.match(/(https?:\/\/\S+)/);
|
|
201
|
+
if (urlMatch) {
|
|
202
|
+
const url = ensureUrl(urlMatch[1]);
|
|
203
|
+
if (url) sources.push({ title: line.replace(urlMatch[1], '').replace(/^\[\d+\]\s*/, '').replace(/[|—-]\s*$/, '').trim().slice(0, 120) || url, url, snippet: '' });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { bullets, sources };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function renderResearchMarkdown({ query, bullets, sources, language = 'en' }) {
|
|
210
|
+
const en = /^en/i.test(String(language || ''));
|
|
211
|
+
const lines = [];
|
|
212
|
+
lines.push(en ? `🔎 **Research:** ${query}` : `🔎 **리서치:** ${query}`);
|
|
213
|
+
if (bullets && bullets.length) {
|
|
214
|
+
lines.push('');
|
|
215
|
+
for (const b of bullets) lines.push(`- ${b}`);
|
|
216
|
+
}
|
|
217
|
+
if (sources && sources.length) {
|
|
218
|
+
lines.push('');
|
|
219
|
+
lines.push(en ? '**Sources**' : '**자료**');
|
|
220
|
+
sources.slice(0, 5).forEach((s, i) => lines.push(`${i + 1}. [${s.title || s.url}](${s.url})`));
|
|
221
|
+
}
|
|
222
|
+
return lines.join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function runResearchTurn({ query, language = 'en', env = process.env, fetchImpl = globalThis.fetch, signal, synthesize, maxResults = 5 } = {}) {
|
|
226
|
+
if (!query || typeof query !== 'string') throw new Error('query is required');
|
|
227
|
+
if (typeof synthesize !== 'function') throw new Error('synthesize callback is required');
|
|
228
|
+
let searchResult;
|
|
229
|
+
try {
|
|
230
|
+
searchResult = await performSearch(query, { env, fetchImpl, signal, maxResults });
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if (e?.message === 'no_search_backend') {
|
|
233
|
+
return { status: 'no_backend', error: 'no_search_backend', query };
|
|
234
|
+
}
|
|
235
|
+
if (e?.message === 'agent_delegated') {
|
|
236
|
+
let agentOut;
|
|
237
|
+
try {
|
|
238
|
+
agentOut = await synthesize(buildAgentDelegatedResearchPrompt({ query, language }), { signal, task: true });
|
|
239
|
+
} catch (synthErr) {
|
|
240
|
+
return { status: 'synth_failed', error: synthErr?.message || String(synthErr), query, sources: [] };
|
|
241
|
+
}
|
|
242
|
+
const { bullets, sources } = parseAgentDelegatedOutput(agentOut);
|
|
243
|
+
if (!bullets.length && !sources.length) {
|
|
244
|
+
return {
|
|
245
|
+
status: 'empty',
|
|
246
|
+
query,
|
|
247
|
+
speech: renderResearchSpeech([], language),
|
|
248
|
+
markdown: renderResearchMarkdown({ query, bullets: [], sources: [], language }),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
status: 'ok',
|
|
253
|
+
query,
|
|
254
|
+
speech: renderResearchSpeech(bullets, language),
|
|
255
|
+
markdown: renderResearchMarkdown({ query, bullets, sources, language }),
|
|
256
|
+
embed: buildResearchEmbed({ query, bullets, sources, language }),
|
|
257
|
+
bullets,
|
|
258
|
+
sources,
|
|
259
|
+
backend: 'agent',
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return { status: 'search_failed', error: e?.message || String(e), query };
|
|
263
|
+
}
|
|
264
|
+
if (!searchResult.sources.length) {
|
|
265
|
+
return { status: 'empty', query, speech: renderResearchSpeech([], language), markdown: renderResearchMarkdown({ query, bullets: [], sources: [], language }) };
|
|
266
|
+
}
|
|
267
|
+
const prompt = buildSynthesisPrompt({ query, sources: searchResult.sources, language });
|
|
268
|
+
let synthText;
|
|
269
|
+
try { synthText = await synthesize(prompt, { signal }); }
|
|
270
|
+
catch (e) { return { status: 'synth_failed', error: e?.message || String(e), query, sources: searchResult.sources }; }
|
|
271
|
+
const { bullets } = parseSynthesisOutput(synthText);
|
|
272
|
+
const useBullets = bullets.length ? bullets : (searchResult.answer ? [searchResult.answer] : []);
|
|
273
|
+
return {
|
|
274
|
+
status: 'ok',
|
|
275
|
+
query,
|
|
276
|
+
speech: renderResearchSpeech(useBullets, language),
|
|
277
|
+
markdown: renderResearchMarkdown({ query, bullets: useBullets, sources: searchResult.sources, language }),
|
|
278
|
+
embed: buildResearchEmbed({ query, bullets: useBullets, sources: searchResult.sources, language }),
|
|
279
|
+
bullets: useBullets,
|
|
280
|
+
sources: searchResult.sources,
|
|
281
|
+
};
|
|
282
|
+
}
|