verbalcoding 0.2.11 → 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 +98 -2
- package/README.es.md +134 -0
- package/README.fr.md +134 -0
- package/README.ja.md +134 -0
- package/README.ko.md +134 -0
- package/README.md +118 -74
- package/README.ru.md +134 -0
- package/README.zh.md +133 -0
- package/app-node/agent_adapters.mjs +37 -5
- package/app-node/agent_adapters.test.mjs +27 -1
- package/app-node/agent_detect.mjs +73 -0
- package/app-node/agent_detect.test.mjs +77 -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 +113 -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 +513 -1058
- 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/notify.mjs +73 -0
- package/app-node/notify.test.mjs +68 -0
- package/app-node/plan_dispatcher.mjs +215 -0
- package/app-node/plan_dispatcher.test.mjs +101 -0
- package/app-node/plan_mode.mjs +203 -0
- package/app-node/plan_mode.test.mjs +231 -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 +94 -0
- package/app-node/smart_progress.test.mjs +66 -0
- package/app-node/stream_sentencer.mjs +91 -0
- package/app-node/stream_sentencer.test.mjs +129 -0
- package/app-node/streaming_tts_queue.mjs +52 -0
- package/app-node/streaming_tts_queue.test.mjs +64 -0
- package/app-node/stt_whisper.mjs +24 -0
- package/app-node/stt_whisper.test.mjs +32 -0
- package/app-node/text_routing.mjs +22 -0
- package/app-node/text_routing.test.mjs +23 -1
- 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 +79 -96
- package/docs/FRESH_INSTALL.md +105 -63
- 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/HERMES_VOICE.md +65 -0
- package/docs/MULTI_INSTANCE.md +16 -0
- package/docs/README.md +50 -0
- package/docs/RELEASE.md +42 -19
- package/docs/ROADMAP.md +53 -0
- package/docs/TROUBLESHOOTING.md +126 -0
- package/docs/TTS_BACKENDS.md +227 -0
- package/docs/USAGE.md +94 -40
- package/docs/assets/figures/verbalcoding-flow.svg +1 -1
- 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/CONFIGURATION.es.md +25 -0
- package/docs/i18n/CONFIGURATION.fr.md +25 -0
- package/docs/i18n/CONFIGURATION.ja.md +25 -0
- package/docs/i18n/CONFIGURATION.ko.md +25 -0
- package/docs/i18n/CONFIGURATION.ru.md +25 -0
- package/docs/i18n/CONFIGURATION.zh.md +25 -0
- package/docs/i18n/FRESH_INSTALL.es.md +27 -2
- package/docs/i18n/FRESH_INSTALL.fr.md +27 -2
- package/docs/i18n/FRESH_INSTALL.ja.md +27 -2
- package/docs/i18n/FRESH_INSTALL.ko.md +27 -2
- package/docs/i18n/FRESH_INSTALL.ru.md +27 -2
- package/docs/i18n/FRESH_INSTALL.zh.md +27 -2
- 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/i18n/HERMES_VOICE.es.md +46 -0
- package/docs/i18n/HERMES_VOICE.fr.md +46 -0
- package/docs/i18n/HERMES_VOICE.ja.md +46 -0
- package/docs/i18n/HERMES_VOICE.ko.md +65 -0
- package/docs/i18n/HERMES_VOICE.ru.md +46 -0
- package/docs/i18n/HERMES_VOICE.zh.md +46 -0
- package/docs/i18n/MULTI_INSTANCE.es.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.fr.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.ja.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.ko.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.ru.md +25 -0
- package/docs/i18n/MULTI_INSTANCE.zh.md +25 -0
- package/docs/i18n/README.es.md +20 -134
- package/docs/i18n/README.fr.md +20 -134
- package/docs/i18n/README.ja.md +20 -134
- package/docs/i18n/README.ko.md +20 -133
- package/docs/i18n/README.ru.md +20 -134
- package/docs/i18n/README.zh.md +20 -133
- package/docs/i18n/RELEASE.es.md +26 -1
- package/docs/i18n/RELEASE.fr.md +26 -1
- package/docs/i18n/RELEASE.ja.md +26 -1
- package/docs/i18n/RELEASE.ko.md +26 -1
- package/docs/i18n/RELEASE.ru.md +26 -1
- package/docs/i18n/RELEASE.zh.md +26 -1
- package/docs/i18n/TROUBLESHOOTING.es.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.fr.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.ja.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.ko.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.ru.md +39 -0
- package/docs/i18n/TROUBLESHOOTING.zh.md +39 -0
- package/docs/i18n/USAGE.es.md +25 -0
- package/docs/i18n/USAGE.fr.md +25 -0
- package/docs/i18n/USAGE.ja.md +25 -0
- package/docs/i18n/USAGE.ko.md +25 -0
- package/docs/i18n/USAGE.ru.md +25 -0
- package/docs/i18n/USAGE.zh.md +25 -0
- package/docs/superpowers/plans/2026-05-13-phase1-streaming-pipeline.md +122 -0
- package/docs/superpowers/plans/2026-05-13-phase10-push-notifications.md +152 -0
- package/docs/superpowers/plans/2026-05-13-phase2-agent-adapters.md +242 -0
- package/docs/superpowers/plans/2026-05-13-phase6-smart-progress.md +172 -0
- package/docs/superpowers/plans/2026-05-13-phase7-voice-plan-mode.md +108 -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 +7 -1
- package/scripts/cli.mjs +88 -3
- package/scripts/doctor.mjs +115 -4
- package/scripts/install.mjs +20 -2
- 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,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
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
parseResearchCommand,
|
|
5
|
+
buildSynthesisPrompt,
|
|
6
|
+
parseSynthesisOutput,
|
|
7
|
+
renderResearchSpeech,
|
|
8
|
+
renderResearchMarkdown,
|
|
9
|
+
buildResearchEmbed,
|
|
10
|
+
runResearchTurn,
|
|
11
|
+
selectSearchBackend,
|
|
12
|
+
buildAgentDelegatedResearchPrompt,
|
|
13
|
+
parseAgentDelegatedOutput,
|
|
14
|
+
} from './research_mode.mjs';
|
|
15
|
+
|
|
16
|
+
test('parseResearchCommand picks up English "research X"', () => {
|
|
17
|
+
const c = parseResearchCommand('research the latest on STORM', 'en');
|
|
18
|
+
assert.equal(c.type, 'research');
|
|
19
|
+
assert.match(c.query, /STORM/);
|
|
20
|
+
assert.equal(c.depth, 'quick');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('parseResearchCommand flags deep mode', () => {
|
|
24
|
+
const c = parseResearchCommand('deep research GraphRAG community detection', 'en');
|
|
25
|
+
assert.equal(c.type, 'research');
|
|
26
|
+
assert.equal(c.depth, 'deep');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('parseResearchCommand recognizes Korean phrasing', () => {
|
|
30
|
+
const c = parseResearchCommand('Mem0 한번 리서치해줘', 'ko');
|
|
31
|
+
assert.equal(c.type, 'research');
|
|
32
|
+
assert.match(c.query, /Mem0/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('parseResearchCommand returns none for unrelated input', () => {
|
|
36
|
+
assert.equal(parseResearchCommand('write a unit test for parseDecisionAnswer', 'en').type, 'none');
|
|
37
|
+
assert.equal(parseResearchCommand('hi how are you', 'en').type, 'none');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('parseResearchCommand does not hijack coding utterances containing the word "research"', () => {
|
|
41
|
+
assert.equal(parseResearchCommand('implement research mode', 'en').type, 'none');
|
|
42
|
+
assert.equal(parseResearchCommand('fix the research_mode regex', 'en').type, 'none');
|
|
43
|
+
assert.equal(parseResearchCommand('add docs about research turn', 'en').type, 'none');
|
|
44
|
+
assert.equal(parseResearchCommand('리서치 모드 코드 좀 봐', 'ko').type, 'none');
|
|
45
|
+
assert.equal(parseResearchCommand('리서치 코드 리팩토링해줘', 'ko').type, 'none');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('parseResearchCommand still triggers on agent-routed forms', () => {
|
|
49
|
+
const c = parseResearchCommand('ask codex to research Node.js streams', 'en');
|
|
50
|
+
assert.equal(c.type, 'research');
|
|
51
|
+
assert.equal(c.query, 'Node.js streams');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('parseResearchCommand preserves internal punctuation in technical queries', () => {
|
|
55
|
+
const cases = [
|
|
56
|
+
['research Node.js streams', 'Node.js streams'],
|
|
57
|
+
['look up GPT-4.1 vs Claude 4.7', 'GPT-4.1 vs Claude 4.7'],
|
|
58
|
+
['research https://example.com/foo?bar=1', 'https://example.com/foo?bar=1'],
|
|
59
|
+
['research what is 1.5 vs 2.0', 'what is 1.5 vs 2.0'],
|
|
60
|
+
];
|
|
61
|
+
for (const [input, expected] of cases) {
|
|
62
|
+
const c = parseResearchCommand(input, 'en');
|
|
63
|
+
assert.equal(c.type, 'research', `failed parse for ${input}`);
|
|
64
|
+
assert.equal(c.query, expected, `expected '${expected}' from '${input}', got '${c.query}'`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('parseResearchCommand strips only trailing sentence punctuation', () => {
|
|
69
|
+
const c = parseResearchCommand('research Node.js streams.', 'en');
|
|
70
|
+
assert.equal(c.query, 'Node.js streams');
|
|
71
|
+
const q = parseResearchCommand('research GPT-4.1?', 'en');
|
|
72
|
+
assert.equal(q.query, 'GPT-4.1');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('buildSynthesisPrompt embeds query and sources', () => {
|
|
76
|
+
const p = buildSynthesisPrompt({
|
|
77
|
+
query: 'STORM autoresearch',
|
|
78
|
+
sources: [{ title: 'STORM paper', url: 'https://arxiv.org/abs/2402.14207', snippet: 'Wikipedia-style writing' }],
|
|
79
|
+
language: 'en',
|
|
80
|
+
});
|
|
81
|
+
assert.match(p, /STORM autoresearch/);
|
|
82
|
+
assert.match(p, /\[1\] STORM paper/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('parseSynthesisOutput extracts bullets and sources block', () => {
|
|
86
|
+
const text = [
|
|
87
|
+
'- STORM uses perspective-grounded Q&A to draft long-form articles.',
|
|
88
|
+
'- It outperforms naive RAG on outline organization metrics.',
|
|
89
|
+
'- Known failure mode is over-association of unrelated facts.',
|
|
90
|
+
'',
|
|
91
|
+
'SOURCES:',
|
|
92
|
+
'[1] STORM paper',
|
|
93
|
+
'[2] STORM repo',
|
|
94
|
+
].join('\n');
|
|
95
|
+
const { bullets, sourceLines } = parseSynthesisOutput(text);
|
|
96
|
+
assert.equal(bullets.length, 3);
|
|
97
|
+
assert.equal(sourceLines.length, 2);
|
|
98
|
+
assert.match(bullets[0], /STORM uses/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('renderResearchSpeech joins intro plus bullets and tags with prefix', () => {
|
|
102
|
+
const speech = renderResearchSpeech(['A.', 'B.', 'C.'], 'en');
|
|
103
|
+
assert.match(speech, /^Research finding:/);
|
|
104
|
+
assert.match(speech, /A\./);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('renderResearchSpeech uses Korean prefix for ko', () => {
|
|
108
|
+
const speech = renderResearchSpeech(['ㄱ.', 'ㄴ.', 'ㄷ.'], 'ko');
|
|
109
|
+
assert.match(speech, /^리서치 결과:/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('renderResearchMarkdown numbers sources', () => {
|
|
113
|
+
const md = renderResearchMarkdown({
|
|
114
|
+
query: 'GraphRAG',
|
|
115
|
+
bullets: ['one'],
|
|
116
|
+
sources: [{ title: 'MS GraphRAG', url: 'https://example.com/g' }],
|
|
117
|
+
language: 'en',
|
|
118
|
+
});
|
|
119
|
+
assert.match(md, /1\. \[MS GraphRAG\]\(https:\/\/example\.com\/g\)/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('runResearchTurn happy path with mocked fetch and synth', async () => {
|
|
123
|
+
const env = { TAVILY_API_KEY: 'fake' };
|
|
124
|
+
const fetchImpl = async () => ({
|
|
125
|
+
ok: true,
|
|
126
|
+
json: async () => ({
|
|
127
|
+
answer: '',
|
|
128
|
+
results: [
|
|
129
|
+
{ title: 'STORM Stanford', url: 'https://arxiv.org/abs/2402.14207', content: 'Wikipedia-style article generation.' },
|
|
130
|
+
{ title: 'STORM Repo', url: 'https://github.com/stanford-oval/storm', content: 'Open-source implementation.' },
|
|
131
|
+
],
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
const synthesize = async () => '- STORM is a Wikipedia-style article writer.\n- Uses perspective-grounded Q&A.\n- Self-hostable and MIT-licensed.\n\nSOURCES:\n[1] STORM Stanford\n[2] STORM Repo';
|
|
135
|
+
const result = await runResearchTurn({ query: 'STORM autoresearch', env, fetchImpl, synthesize, language: 'en' });
|
|
136
|
+
assert.equal(result.status, 'ok');
|
|
137
|
+
assert.equal(result.bullets.length, 3);
|
|
138
|
+
assert.equal(result.sources.length, 2);
|
|
139
|
+
assert.match(result.speech, /STORM is a Wikipedia-style/);
|
|
140
|
+
assert.match(result.markdown, /arxiv\.org/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('runResearchTurn reports no_backend when both keys missing', async () => {
|
|
144
|
+
const result = await runResearchTurn({ query: 'x', env: {}, fetchImpl: async () => {}, synthesize: async () => '' });
|
|
145
|
+
assert.equal(result.status, 'no_backend');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('buildResearchEmbed numbers source fields', () => {
|
|
149
|
+
const embed = buildResearchEmbed({
|
|
150
|
+
query: 'GraphRAG',
|
|
151
|
+
bullets: ['summary'],
|
|
152
|
+
sources: [
|
|
153
|
+
{ title: 'MS GraphRAG', url: 'https://example.com/g' },
|
|
154
|
+
{ title: 'LightRAG', url: 'https://example.com/l' },
|
|
155
|
+
],
|
|
156
|
+
language: 'en',
|
|
157
|
+
});
|
|
158
|
+
assert.match(embed.title, /Research: GraphRAG/);
|
|
159
|
+
assert.equal(embed.fields.length, 2);
|
|
160
|
+
assert.match(embed.fields[0].name, /^\[1\] MS GraphRAG/);
|
|
161
|
+
assert.equal(embed.fields[1].value, 'https://example.com/l');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('selectSearchBackend honors explicit SEARCH_BACKEND override', () => {
|
|
165
|
+
assert.equal(selectSearchBackend({ SEARCH_BACKEND: 'agent' }).kind, 'agent');
|
|
166
|
+
assert.equal(selectSearchBackend({ SEARCH_BACKEND: 'tavily', TAVILY_API_KEY: 'k' }).kind, 'tavily');
|
|
167
|
+
// explicit pick without matching key falls through to discovery order
|
|
168
|
+
assert.equal(selectSearchBackend({ SEARCH_BACKEND: 'brave' }).kind, 'none');
|
|
169
|
+
assert.equal(selectSearchBackend({ SEARCH_BACKEND: 'brave', TAVILY_API_KEY: 'k' }).kind, 'tavily');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('selectSearchBackend default priority is tavily → brave → searxng → none', () => {
|
|
173
|
+
assert.equal(selectSearchBackend({}).kind, 'none');
|
|
174
|
+
assert.equal(selectSearchBackend({ SEARXNG_URL: 'http://localhost:8888' }).kind, 'searxng');
|
|
175
|
+
assert.equal(selectSearchBackend({ BRAVE_API_KEY: 'b', SEARXNG_URL: 'http://x' }).kind, 'brave');
|
|
176
|
+
assert.equal(selectSearchBackend({ TAVILY_API_KEY: 't', BRAVE_API_KEY: 'b' }).kind, 'tavily');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('selectSearchBackend uses agent fallback opt-in when no key is present', () => {
|
|
180
|
+
assert.equal(selectSearchBackend({ SEARCH_BACKEND_AGENT_FALLBACK: '1' }).kind, 'agent');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('runResearchTurn with SEARXNG_URL builds JSON request and synthesises bullets', async () => {
|
|
184
|
+
const env = { SEARXNG_URL: 'http://searx.local' };
|
|
185
|
+
const captured = [];
|
|
186
|
+
const fetchImpl = async (url) => {
|
|
187
|
+
captured.push(url);
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
json: async () => ({
|
|
191
|
+
results: [
|
|
192
|
+
{ title: 'LightRAG', url: 'https://arxiv.org/abs/2410.05779', content: 'LightRAG dual-level retrieval.' },
|
|
193
|
+
{ title: 'HippoRAG', url: 'https://arxiv.org/abs/2405.14831', content: 'PPR-based memory.' },
|
|
194
|
+
],
|
|
195
|
+
}),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
const synthesize = async () => '- LightRAG is dual-level retrieval.\n- HippoRAG uses PPR.\n- GraphRAG uses Leiden clusters.\n\nSOURCES:\n[1] LightRAG\n[2] HippoRAG';
|
|
199
|
+
const result = await runResearchTurn({ query: 'GraphRAG family', env, fetchImpl, synthesize, language: 'en' });
|
|
200
|
+
assert.equal(result.status, 'ok');
|
|
201
|
+
assert.equal(result.sources.length, 2);
|
|
202
|
+
assert.equal(result.bullets.length, 3);
|
|
203
|
+
assert.match(captured[0], /searx\.local\/search\?.*q=GraphRAG/);
|
|
204
|
+
assert.match(captured[0], /format=json/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('runResearchTurn agent-delegated path skips performSearch and parses agent output', async () => {
|
|
208
|
+
const env = { SEARCH_BACKEND: 'agent' };
|
|
209
|
+
let fetchCalled = false;
|
|
210
|
+
const fetchImpl = async () => { fetchCalled = true; return { ok: false, json: async () => ({}) }; };
|
|
211
|
+
const synthCalls = [];
|
|
212
|
+
const synthesize = async (prompt, opts) => {
|
|
213
|
+
synthCalls.push({ prompt, opts });
|
|
214
|
+
return [
|
|
215
|
+
'- ACE evolves a per-project playbook that beats fine-tuning.',
|
|
216
|
+
'- It compounds across sessions without changing model weights.',
|
|
217
|
+
'- Open implementation available from the paper authors.',
|
|
218
|
+
'',
|
|
219
|
+
'SOURCES:',
|
|
220
|
+
'[1] ACE paper | https://arxiv.org/abs/2510.04618',
|
|
221
|
+
'[2] Project README | https://github.com/example/ace-repo',
|
|
222
|
+
].join('\n');
|
|
223
|
+
};
|
|
224
|
+
const result = await runResearchTurn({ query: 'Agentic Context Engineering ACE', env, fetchImpl, synthesize, language: 'en' });
|
|
225
|
+
assert.equal(fetchCalled, false);
|
|
226
|
+
assert.equal(result.status, 'ok');
|
|
227
|
+
assert.equal(result.backend, 'agent');
|
|
228
|
+
assert.equal(result.bullets.length, 3);
|
|
229
|
+
assert.equal(result.sources.length, 2);
|
|
230
|
+
assert.equal(result.sources[0].url, 'https://arxiv.org/abs/2510.04618');
|
|
231
|
+
assert.equal(synthCalls[0].opts.task, true, 'agent-delegated synthesise call sets task=true for longer timeout');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('parseAgentDelegatedOutput tolerates dash separators and missing brackets', () => {
|
|
235
|
+
const text = [
|
|
236
|
+
'- A.',
|
|
237
|
+
'- B.',
|
|
238
|
+
'- C.',
|
|
239
|
+
'',
|
|
240
|
+
'SOURCES:',
|
|
241
|
+
'[1] Foo paper — https://example.com/foo',
|
|
242
|
+
'Bar - https://example.com/bar',
|
|
243
|
+
].join('\n');
|
|
244
|
+
const { bullets, sources } = parseAgentDelegatedOutput(text);
|
|
245
|
+
assert.equal(bullets.length, 3);
|
|
246
|
+
assert.equal(sources.length, 2);
|
|
247
|
+
assert.equal(sources[0].url, 'https://example.com/foo');
|
|
248
|
+
assert.equal(sources[1].url, 'https://example.com/bar');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('buildAgentDelegatedResearchPrompt names the query and the strict format', () => {
|
|
252
|
+
const p = buildAgentDelegatedResearchPrompt({ query: 'STORM autoresearch', language: 'en' });
|
|
253
|
+
assert.match(p, /You have web access/);
|
|
254
|
+
assert.match(p, /STORM autoresearch/);
|
|
255
|
+
assert.match(p, /SOURCES:/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('runResearchTurn handles empty search gracefully', async () => {
|
|
259
|
+
const env = { TAVILY_API_KEY: 'fake' };
|
|
260
|
+
const fetchImpl = async () => ({ ok: true, json: async () => ({ results: [] }) });
|
|
261
|
+
const result = await runResearchTurn({ query: 'X', env, fetchImpl, synthesize: async () => '', language: 'en' });
|
|
262
|
+
assert.equal(result.status, 'empty');
|
|
263
|
+
assert.match(result.speech, /could not find/);
|
|
264
|
+
});
|
|
@@ -15,6 +15,9 @@ export function cleanRestartDetail(detail = '', ttsVoice = '') {
|
|
|
15
15
|
.trim();
|
|
16
16
|
}
|
|
17
17
|
return raw
|
|
18
|
+
.replace(/\bI applied this change:\s*/igu, '')
|
|
19
|
+
.replace(/\bRestarting now\b[.!?\s]*/igu, '')
|
|
20
|
+
.replace(/\bVoice may cut out briefly\b[.!?\s]*/igu, '')
|
|
18
21
|
.replace(/이제\s*재시작할게[.!?。!?\s]*/gu, '')
|
|
19
22
|
.replace(/잠깐\s*음성이\s*끊길\s*수\s*있어[.!?。!?\s]*/gu, '')
|
|
20
23
|
.replace(/\s+/g, ' ')
|
|
@@ -35,3 +35,14 @@ test('restart detail strips restart boilerplate before formatting', () => {
|
|
|
35
35
|
'재시작 완료. 다시 온라인이야. 에이전트 안내 고쳤어.',
|
|
36
36
|
);
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
test('Korean restart detail strips stale English restart boilerplate', () => {
|
|
40
|
+
assert.equal(
|
|
41
|
+
cleanRestartDetail('I applied this change: OmniVoice 한국어 설정 반영. Restarting now. Voice may cut out briefly.', 'ko-KR-InJoonNeural'),
|
|
42
|
+
'OmniVoice 한국어 설정 반영.',
|
|
43
|
+
);
|
|
44
|
+
assert.equal(
|
|
45
|
+
formatRestartCompleteNotice('I applied this change: OmniVoice 한국어 설정 반영. Restarting now. Voice may cut out briefly.', 'ko-KR-InJoonNeural').speech,
|
|
46
|
+
'재시작 완료. 다시 온라인이야. OmniVoice 한국어 설정 반영.',
|
|
47
|
+
);
|
|
48
|
+
});
|