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
package/README.zh.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# VerbalCoding
|
|
2
|
+
|
|
3
|
+
<p align="center"><strong>像打电话一样,通过 Discord 语音控制 CLI 编程代理。</strong></p>
|
|
4
|
+
|
|
5
|
+
<p align="center"><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.es.md">Español</a> · <a href="./README.fr.md">Français</a> · <a href="./README.ru.md">Русский</a></p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img alt="npm" src="https://img.shields.io/npm/v/verbalcoding?color=CB3837&logo=npm&logoColor=white">
|
|
9
|
+
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-20%2B-339933?logo=node.js&logoColor=white">
|
|
10
|
+
<img alt="Discord" src="https://img.shields.io/badge/Discord-voice%20bridge-5865F2?logo=discord&logoColor=white">
|
|
11
|
+
<img alt="STT" src="https://img.shields.io/badge/STT-whisper.cpp-7C3AED">
|
|
12
|
+
<img alt="TTS" src="https://img.shields.io/badge/TTS-Edge%20%7C%20OpenVoice%20%7C%20SpeechSwift-0EA5E9">
|
|
13
|
+
<img alt="License" src="https://img.shields.io/github/license/ca1773130n/VerbalCoding">
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="docs/assets/figures/verbalcoding-flow.svg" alt="VerbalCoding voice-to-agent flow" width="860">
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
## 为什么需要它
|
|
21
|
+
|
|
22
|
+
VerbalCoding 把 Discord 语音房间变成编码代理的免提驾驶舱。你说出需求,让 CLI 代理工作,并收到简短语音回复和文本记录;diff 和日志不会被 TTS 长篇朗读。
|
|
23
|
+
|
|
24
|
+
> **已经在用 Hermes Agent?** Hermes 本身已经通过 `/voice join` / `/voice channel` 支持 Discord 语音频道:它可以加入你当前所在的 VC,用 Whisper 做语音转文字,并用 TTS 回答。只需要这个基础闭环时,VerbalCoding 不是必需的。VerbalCoding 是加在上面的工作流层:项目/会话路由、语音+文本共享上下文、插话规则、进度语音、语言预设、延迟指标,以及 Hermes 之外的 CLI 后端切换。
|
|
25
|
+
|
|
26
|
+
## 体验亮点
|
|
27
|
+
|
|
28
|
+
| 能力 | 价值 |
|
|
29
|
+
|---|---|
|
|
30
|
+
| 电话式工作流 | 在同一个 Discord 语音频道里说话、收听、打断、继续。 |
|
|
31
|
+
| 面向人的引导设置 | `vc setup` 一次引导 prerequisites、Discord token/client ID、voice channel、transcript target、backend 和 TTS 设置。 |
|
|
32
|
+
| 本地语音闭环 | Discord audio → local `whisper-cli` → selected CLI agent → TTS reply。 |
|
|
33
|
+
| 可选代理 | 支持 Hermes Agent、Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw、Aider、Cursor CLI 或 custom command。`vc setup` 自动检测已安装项。 |
|
|
34
|
+
| 语音切换代理 | `"ask Codex what it thinks"` 单 turn 路由,`"switch to Aider"` 粘性切换,`"back to default"` 回默认。未安装的二进制会被检测并询问是否回退到默认代理。 |
|
|
35
|
+
| 超越 Hermes 内置语音 | 在同一个 VC 语音闭环上增加项目房间、`!ask` 共享上下文、细粒度打断处理、进度/状态语音和多代理后端控制。 |
|
|
36
|
+
| 真实运维支持 | 内置 doctor auto-fix、Docker UDP 指南、latency metrics、multi-instance rooms 和 redacted config checks。 |
|
|
37
|
+
|
|
38
|
+
## 快速开始
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install -g verbalcoding@latest
|
|
42
|
+
vc setup
|
|
43
|
+
vc doctor
|
|
44
|
+
vc start
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
普通用户路径是 `vc setup`。运行时请打开 Discord Developer Portal,并按提示输入 bot token、application/client ID、transcript target 和 voice channel names。
|
|
48
|
+
|
|
49
|
+
自动化场景可以跳过提示,然后再补充 Discord 信息。
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
vc setup --yes
|
|
53
|
+
vc setup token <bot-token> --client-id <discord-client-id>
|
|
54
|
+
vc setup channels "General,Team Voice"
|
|
55
|
+
vc doctor
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 一分钟完成 Discord 设置
|
|
59
|
+
|
|
60
|
+
1. 在 Discord Developer Portal 创建 application 和 bot。
|
|
61
|
+
2. 启用 Message Content privileged intent。
|
|
62
|
+
3. 运行 `vc setup`,粘贴 bot token 和 application/client ID。
|
|
63
|
+
4. 输入要自动加入的精确 voice channel 名称。
|
|
64
|
+
5. 用下面的命令邀请 bot。
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
vc bot invite <discord-client-id>
|
|
68
|
+
vc bot invite <discord-client-id> --guild <guild-id>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 迷你命令地图
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
vc setup # 引导式设置: prerequisites, Discord, backend, voice
|
|
75
|
+
vc setup --yes # 非交互 bootstrap/starter config
|
|
76
|
+
vc setup token # 稍后轮换或添加 Discord bot token/client ID
|
|
77
|
+
vc setup channels "General,Team Voice" # 更新 auto-join voice channel names
|
|
78
|
+
vc bot invite CLIENT_ID # 生成 Discord bot invite URL
|
|
79
|
+
vc status # 显示当前设置
|
|
80
|
+
vc language ko|en|auto # 切换 language preset
|
|
81
|
+
vc doctor # redacted health check 和 auto-fix
|
|
82
|
+
vc start # 启动默认 bridge
|
|
83
|
+
vc instance setup NAME # 创建隔离的 project voice bot
|
|
84
|
+
vc instance start NAME # 后台运行该 bot
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## 了解更多
|
|
88
|
+
|
|
89
|
+
| 指南 | 内容 |
|
|
90
|
+
|---|---|
|
|
91
|
+
| [文档中心](docs/i18n/README.zh.md) | 本地化指南索引。 |
|
|
92
|
+
| [Fresh Install](docs/i18n/FRESH_INSTALL.zh.md) | npm/global setup、Discord 设置、首次运行。 |
|
|
93
|
+
| [Usage](docs/i18n/USAGE.zh.md) | CLI 命令、Discord 命令、运行模式、latency。 |
|
|
94
|
+
| [Harness 使用](docs/i18n/HARNESSES.zh.md) | Claude Code、Codex、Aider 等各后端的安装、配置与语音路由。 |
|
|
95
|
+
| [Configuration](docs/i18n/CONFIGURATION.zh.md) | .env、agent backends、MCP、TTS、运维。 |
|
|
96
|
+
| [Troubleshooting](docs/i18n/TROUBLESHOOTING.zh.md) | Docker UDP、token/channel 缺失检查。 |
|
|
97
|
+
| [Multi-Instance](docs/i18n/MULTI_INSTANCE.zh.md) | 每个项目一个固定语音房间。 |
|
|
98
|
+
|
|
99
|
+
## 要求
|
|
100
|
+
|
|
101
|
+
| 层级 | 默认 |
|
|
102
|
+
|---|---|
|
|
103
|
+
| Runtime | Node.js 20+ 和 npm。 |
|
|
104
|
+
| Audio | `ffmpeg` 和 local `whisper-cli`。 |
|
|
105
|
+
| TTS | 默认 Edge TTS;可选 OpenVoice、SpeechSwift/CosyVoice、Supertonic。 |
|
|
106
|
+
| Discord | Bot token、Message Content intent、voice permissions、匹配的 channel names。 |
|
|
107
|
+
| Agent | 至少一个已认证 CLI harness;默认 Hermes Agent。 |
|
|
108
|
+
|
|
109
|
+
## Docker / 容器说明
|
|
110
|
+
|
|
111
|
+
如果日志出现 `Cannot perform IP discovery - socket closed`,说明 Discord voice UDP 被阻断。在 Linux Docker Compose 中使用:
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
services:
|
|
115
|
+
verbalcoding:
|
|
116
|
+
network_mode: "host"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
不要同时使用 `network_mode: "host"` 和 `ports:`。
|
|
120
|
+
|
|
121
|
+
## 贡献
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
node --check app-node/main.mjs
|
|
125
|
+
npm test
|
|
126
|
+
bash -n run.sh scripts/install.sh scripts/bootstrap_prereqs.sh
|
|
127
|
+
npm pack --dry-run
|
|
128
|
+
vc doctor
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## 状态
|
|
132
|
+
|
|
133
|
+
VerbalCoding 面向公开发布,但仍处于早期阶段。演示视频/GIF、更广泛的 Linux 验证、CI 和安全审查仍是 TODO。
|
|
@@ -23,12 +23,14 @@ export function voiceBridgePrompt(text, options = {}) {
|
|
|
23
23
|
const english = /^en/i.test(String(options.language || ''));
|
|
24
24
|
const lines = english ? [
|
|
25
25
|
'This is a user utterance from a Discord voice call.',
|
|
26
|
+
'Consider Discord voice-channel speech and text-channel messages as one shared conversation context when inferring intent.',
|
|
26
27
|
'Answer in English. For simple conversation/status questions, do not use tools; answer directly in 1-3 sentences.',
|
|
27
28
|
'Use tools only for real work requests such as file edits, command execution, log checks, or web/search tasks.',
|
|
28
29
|
'If code changes are made, do not read diffs or full code aloud; summarize outcome and next checks briefly.',
|
|
29
30
|
'Do not include CLI metadata or session_id in the answer.',
|
|
30
31
|
] : [
|
|
31
32
|
'Discord 음성 대화로 들어온 사용자 발화다.',
|
|
33
|
+
'의도를 판단할 때 음성 채널 발화와 텍스트 채널 메시지를 같은 대화 맥락으로 함께 고려해라.',
|
|
32
34
|
'단순 대화/상태 질문이면 도구를 쓰지 말고 1~3문장으로 바로 한국어 답변해라.',
|
|
33
35
|
'파일 수정, 실행, 로그 확인, 검색 같은 실제 작업 지시일 때만 필요한 도구를 사용해라.',
|
|
34
36
|
'코드 변경을 수행했다면 음성 답변에는 diff나 코드 전문을 읽지 말고, 작업 결과와 다음 확인 사항만 짧게 말해라.',
|
|
@@ -57,6 +59,10 @@ export function voiceBridgePrompt(text, options = {}) {
|
|
|
57
59
|
lines.push(english ? 'Route this turn through the following project/session context:' : '이 턴은 아래 프로젝트/세션 컨텍스트로 처리해라.');
|
|
58
60
|
lines.push(String(options.projectContext).trim());
|
|
59
61
|
}
|
|
62
|
+
if (options.recentDiscordContext) {
|
|
63
|
+
lines.push(english ? 'Recent Discord text-channel context to consider with this voice utterance:' : '이 음성 발화와 함께 고려할 최근 Discord 텍스트 채널 맥락:');
|
|
64
|
+
lines.push(String(options.recentDiscordContext).trim());
|
|
65
|
+
}
|
|
60
66
|
return lines.concat(['', text]).join('\n');
|
|
61
67
|
}
|
|
62
68
|
|
|
@@ -251,6 +257,24 @@ export function buildAgentSettings({ ROOT, env = process.env } = {}) {
|
|
|
251
257
|
sessionFile: env.AGENT_SESSION_FILE || path.join(root, '.agent-sessions', 'openclaw'),
|
|
252
258
|
supportsHermesSession: false,
|
|
253
259
|
},
|
|
260
|
+
aider: {
|
|
261
|
+
label: 'Aider',
|
|
262
|
+
command: env.AIDER_COMMAND || 'aider --no-pretty --yes-always --message',
|
|
263
|
+
sessionFile: env.AGENT_SESSION_FILE || path.join(root, '.agent-sessions', 'aider'),
|
|
264
|
+
supportsHermesSession: false,
|
|
265
|
+
},
|
|
266
|
+
cursor: {
|
|
267
|
+
label: 'Cursor CLI',
|
|
268
|
+
command: env.CURSOR_COMMAND || 'cursor-agent --print --prompt',
|
|
269
|
+
sessionFile: env.AGENT_SESSION_FILE || path.join(root, '.agent-sessions', 'cursor'),
|
|
270
|
+
supportsHermesSession: false,
|
|
271
|
+
},
|
|
272
|
+
'cursor-cli': {
|
|
273
|
+
label: 'Cursor CLI',
|
|
274
|
+
command: env.CURSOR_COMMAND || 'cursor-agent --print --prompt',
|
|
275
|
+
sessionFile: env.AGENT_SESSION_FILE || path.join(root, '.agent-sessions', 'cursor'),
|
|
276
|
+
supportsHermesSession: false,
|
|
277
|
+
},
|
|
254
278
|
custom: {
|
|
255
279
|
label: env.AGENT_LABEL || 'Custom Agent',
|
|
256
280
|
command: env.AGENT_COMMAND || '',
|
|
@@ -294,6 +318,7 @@ export function createAgentAdapter(settings, deps = {}) {
|
|
|
294
318
|
const hermesSessionsDir = deps.hermesSessionsDir || path.join(os.homedir(), '.hermes', 'sessions');
|
|
295
319
|
const spawnProcess = deps.spawn;
|
|
296
320
|
const onProgress = deps.onProgress || (() => {});
|
|
321
|
+
const onStdoutChunk = deps.onStdoutChunk || null;
|
|
297
322
|
const emittedProgress = new Set();
|
|
298
323
|
let activeProgressLanguage = settings.language;
|
|
299
324
|
const capabilities = agentAdapterCapabilities(settings);
|
|
@@ -308,7 +333,7 @@ export function createAgentAdapter(settings, deps = {}) {
|
|
|
308
333
|
}
|
|
309
334
|
|
|
310
335
|
function execWithOptionalProgress(cmd, args, options, verbose) {
|
|
311
|
-
if (!verbose || !spawnProcess) return execFileAsync(cmd, args, options);
|
|
336
|
+
if ((!verbose && !onStdoutChunk) || !spawnProcess) return execFileAsync(cmd, args, options);
|
|
312
337
|
return new Promise((resolve, reject) => {
|
|
313
338
|
const child = spawnProcess(cmd, args, {
|
|
314
339
|
env: options.env,
|
|
@@ -353,7 +378,8 @@ export function createAgentAdapter(settings, deps = {}) {
|
|
|
353
378
|
child.stdout?.on('data', chunk => {
|
|
354
379
|
const s = chunk.toString();
|
|
355
380
|
stdout += s;
|
|
356
|
-
|
|
381
|
+
if (onStdoutChunk) { try { onStdoutChunk(s); } catch (e) { warn('onStdoutChunk failed', e?.stack || e); } }
|
|
382
|
+
if (verbose) emitVerboseProgress(s);
|
|
357
383
|
if (stdout.length + stderr.length > options.maxBuffer) {
|
|
358
384
|
const err = new Error('maxBuffer exceeded');
|
|
359
385
|
err.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
|
|
@@ -364,7 +390,7 @@ export function createAgentAdapter(settings, deps = {}) {
|
|
|
364
390
|
child.stderr?.on('data', chunk => {
|
|
365
391
|
const s = chunk.toString();
|
|
366
392
|
stderr += s;
|
|
367
|
-
emitVerboseProgress(s);
|
|
393
|
+
if (verbose) emitVerboseProgress(s);
|
|
368
394
|
if (stdout.length + stderr.length > options.maxBuffer) {
|
|
369
395
|
const err = new Error('maxBuffer exceeded');
|
|
370
396
|
err.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
|
|
@@ -467,7 +493,12 @@ export function createAgentAdapter(settings, deps = {}) {
|
|
|
467
493
|
function buildArgs(text, options = {}) {
|
|
468
494
|
const argv = shellSplit(settings.command);
|
|
469
495
|
const cmd = argv[0];
|
|
470
|
-
const query = voiceBridgePrompt(text, {
|
|
496
|
+
const query = voiceBridgePrompt(text, {
|
|
497
|
+
verboseProgress: options.verboseProgress,
|
|
498
|
+
language: options.language,
|
|
499
|
+
projectContext: options.projectContext,
|
|
500
|
+
recentDiscordContext: options.recentDiscordContext,
|
|
501
|
+
});
|
|
471
502
|
let args = argv.slice(1);
|
|
472
503
|
if (settings.backend === 'hermes' && options.verboseProgress) {
|
|
473
504
|
// Hermes quiet mode intentionally suppresses tool previews. In verbose
|
|
@@ -491,8 +522,9 @@ export function createAgentAdapter(settings, deps = {}) {
|
|
|
491
522
|
const language = plan.language || settings.language;
|
|
492
523
|
activeProgressLanguage = language;
|
|
493
524
|
const projectContext = plan.projectContext || settings.projectContext || '';
|
|
525
|
+
const recentDiscordContext = plan.recentDiscordContext || '';
|
|
494
526
|
emittedProgress.clear();
|
|
495
|
-
const { cmd, args, sessionId } = buildArgs(text, { verboseProgress, language, projectContext });
|
|
527
|
+
const { cmd, args, sessionId } = buildArgs(text, { verboseProgress, language, projectContext, recentDiscordContext });
|
|
496
528
|
const start = Date.now();
|
|
497
529
|
const label = plan.label || settings.label;
|
|
498
530
|
const { args: finalArgs, outputPath } = addCodexOutputCapture(args);
|
|
@@ -288,6 +288,8 @@ test('Claude, Codex, and Gemini adapters use backend-specific default commands w
|
|
|
288
288
|
{ backend: 'gemini', command: ['gemini', '-p'], label: 'Gemini' },
|
|
289
289
|
{ backend: 'opencode', command: ['opencode', 'run'], label: 'OpenCode' },
|
|
290
290
|
{ backend: 'openclaw', command: ['openclaw', 'run'], label: 'OpenClaw' },
|
|
291
|
+
{ backend: 'aider', command: ['aider', '--no-pretty', '--yes-always', '--message'], label: 'Aider' },
|
|
292
|
+
{ backend: 'cursor', command: ['cursor-agent', '--print', '--prompt'], label: 'Cursor CLI' },
|
|
291
293
|
];
|
|
292
294
|
|
|
293
295
|
for (const item of cases) {
|
|
@@ -352,10 +354,20 @@ test('voiceBridgePrompt keeps voice-specific operating instructions with user te
|
|
|
352
354
|
const prompt = voiceBridgePrompt('파일 수정해줘');
|
|
353
355
|
|
|
354
356
|
assert.match(prompt, /Discord 음성 대화/);
|
|
355
|
-
assert.match(prompt, /파일 수정, 실행, 로그 확인/);
|
|
356
357
|
assert.match(prompt, /파일 수정해줘/);
|
|
357
358
|
});
|
|
358
359
|
|
|
360
|
+
test('voiceBridgePrompt includes recent Discord text context when provided', () => {
|
|
361
|
+
const prompt = voiceBridgePrompt('왜 죽었어?', {
|
|
362
|
+
recentDiscordContext: '최근 텍스트 채널 메시지:\n- user: 음성채널에서만 나가줘',
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
assert.match(prompt, /음성 채널 발화와 텍스트 채널 메시지를 같은 대화 맥락으로 함께 고려/);
|
|
366
|
+
assert.match(prompt, /최근 텍스트 채널 메시지/);
|
|
367
|
+
assert.match(prompt, /음성채널에서만 나가줘/);
|
|
368
|
+
assert.match(prompt, /왜 죽었어\?/);
|
|
369
|
+
});
|
|
370
|
+
|
|
359
371
|
test('voiceBridgePrompt adds optional verbose progress instructions only when enabled', () => {
|
|
360
372
|
const normal = voiceBridgePrompt('파일 수정해줘');
|
|
361
373
|
const verbose = voiceBridgePrompt('파일 수정해줘', { verboseProgress: true });
|
|
@@ -444,6 +456,20 @@ test('signal failure with patch-like output returns a concise interruption messa
|
|
|
444
456
|
assert.doesNotMatch(answer, /@@|review diff|old|new/);
|
|
445
457
|
});
|
|
446
458
|
|
|
459
|
+
test('createAgentAdapter satisfies the agent adapter contract for every known backend', () => {
|
|
460
|
+
const backends = ['hermes', 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'aider', 'cursor'];
|
|
461
|
+
for (const backend of backends) {
|
|
462
|
+
const settings = buildAgentSettings({ ROOT: '/tmp/vc-test', env: { AGENT_BACKEND: backend } });
|
|
463
|
+
const adapter = createAgentAdapter(settings, {
|
|
464
|
+
execFileAsync: async () => ({ stdout: '', stderr: '' }),
|
|
465
|
+
log: () => {},
|
|
466
|
+
warn: () => {},
|
|
467
|
+
});
|
|
468
|
+
assert.doesNotThrow(() => assertAgentAdapterContract(adapter), `${backend} should satisfy contract`);
|
|
469
|
+
assert.equal(adapter.backend, backend);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
447
473
|
test('hermes adapter spawn carries HERMES_HOME from instance env into child env', async () => {
|
|
448
474
|
const { buildHermesSpawnOptions } = await import('./agent_adapters.mjs');
|
|
449
475
|
const opts = buildHermesSpawnOptions({
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const PROBES = [
|
|
5
|
+
{ backend: 'hermes', bin: 'hermes', defaultCommand: 'hermes chat -Q -q', envCommand: 'HERMES_COMMAND', label: 'Hermes Agent' },
|
|
6
|
+
{ backend: 'claude', bin: 'claude', defaultCommand: 'claude -p', envCommand: 'CLAUDE_COMMAND', label: 'Claude Code' },
|
|
7
|
+
{ backend: 'codex', bin: 'codex', defaultCommand: 'codex exec', envCommand: 'CODEX_COMMAND', label: 'Codex' },
|
|
8
|
+
{ backend: 'gemini', bin: 'gemini', defaultCommand: 'gemini -p', envCommand: 'GEMINI_COMMAND', label: 'Gemini' },
|
|
9
|
+
{ backend: 'opencode', bin: 'opencode', defaultCommand: 'opencode run', envCommand: 'OPENCODE_COMMAND', label: 'OpenCode' },
|
|
10
|
+
{ backend: 'openclaw', bin: 'openclaw', defaultCommand: 'openclaw run', envCommand: 'OPENCLAW_COMMAND', label: 'OpenClaw' },
|
|
11
|
+
{ backend: 'aider', bin: 'aider', defaultCommand: 'aider --no-pretty --yes-always --message', envCommand: 'AIDER_COMMAND', label: 'Aider' },
|
|
12
|
+
{ backend: 'cursor', bin: 'cursor-agent', defaultCommand: 'cursor-agent --print --prompt', envCommand: 'CURSOR_COMMAND', label: 'Cursor CLI' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function defaultWhich(bin, { env = process.env, accessSync = fs.accessSync } = {}) {
|
|
16
|
+
const pathVar = env.PATH || '';
|
|
17
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
18
|
+
const exts = process.platform === 'win32' ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
|
|
19
|
+
for (const dir of pathVar.split(sep)) {
|
|
20
|
+
if (!dir) continue;
|
|
21
|
+
for (const ext of exts) {
|
|
22
|
+
const candidate = path.join(dir, bin + ext);
|
|
23
|
+
try {
|
|
24
|
+
accessSync(candidate, fs.constants.X_OK);
|
|
25
|
+
return candidate;
|
|
26
|
+
} catch { /* not here */ }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function detectInstalledAgents(env = process.env, { which } = {}) {
|
|
33
|
+
const probe = which || ((bin) => defaultWhich(bin, { env }));
|
|
34
|
+
return Promise.all(PROBES.map(async (p) => {
|
|
35
|
+
const located = await probe(p.bin);
|
|
36
|
+
return {
|
|
37
|
+
backend: p.backend,
|
|
38
|
+
label: p.label,
|
|
39
|
+
bin: p.bin,
|
|
40
|
+
path: located || null,
|
|
41
|
+
present: Boolean(located),
|
|
42
|
+
command: env[p.envCommand] || p.defaultCommand,
|
|
43
|
+
};
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function listKnownBackends() {
|
|
48
|
+
return PROBES.map(p => ({ backend: p.backend, label: p.label, bin: p.bin }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function pickDefaultBackend(detection, preferred = '') {
|
|
52
|
+
const list = Array.isArray(detection) ? detection : [];
|
|
53
|
+
const pref = String(preferred || '').toLowerCase();
|
|
54
|
+
if (pref) {
|
|
55
|
+
const match = list.find(r => r.backend === pref && r.present);
|
|
56
|
+
if (match) return match.backend;
|
|
57
|
+
}
|
|
58
|
+
const firstPresent = list.find(r => r.present);
|
|
59
|
+
if (firstPresent) return firstPresent.backend;
|
|
60
|
+
return 'hermes';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatAgentDetectionReport(detection) {
|
|
64
|
+
const list = Array.isArray(detection) ? detection : [];
|
|
65
|
+
if (!list.length) return 'No agent backends probed.';
|
|
66
|
+
const rows = list.map(r => {
|
|
67
|
+
const marker = r.present ? '✓' : '·';
|
|
68
|
+
const pathPart = r.present ? r.path : 'not found';
|
|
69
|
+
return ` ${marker} ${r.label.padEnd(14)} ${r.bin.padEnd(14)} ${pathPart}`;
|
|
70
|
+
});
|
|
71
|
+
const presentCount = list.filter(r => r.present).length;
|
|
72
|
+
return `Agent backends (${presentCount}/${list.length} present):\n${rows.join('\n')}`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { detectInstalledAgents, listKnownBackends, pickDefaultBackend, formatAgentDetectionReport } from './agent_detect.mjs';
|
|
4
|
+
|
|
5
|
+
test('detectInstalledAgents marks present when which resolves', async () => {
|
|
6
|
+
const fakeWhich = async (bin) => (bin === 'hermes' ? '/usr/local/bin/hermes' : null);
|
|
7
|
+
const result = await detectInstalledAgents({}, { which: fakeWhich });
|
|
8
|
+
const hermes = result.find(r => r.backend === 'hermes');
|
|
9
|
+
assert.equal(hermes.present, true);
|
|
10
|
+
assert.equal(hermes.path, '/usr/local/bin/hermes');
|
|
11
|
+
const claude = result.find(r => r.backend === 'claude');
|
|
12
|
+
assert.equal(claude.present, false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('detectInstalledAgents includes aider and cursor', async () => {
|
|
16
|
+
const fakeWhich = async () => null;
|
|
17
|
+
const result = await detectInstalledAgents({}, { which: fakeWhich });
|
|
18
|
+
const backends = result.map(r => r.backend);
|
|
19
|
+
assert.ok(backends.includes('aider'));
|
|
20
|
+
assert.ok(backends.includes('cursor'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('detectInstalledAgents honors env overrides for command', async () => {
|
|
24
|
+
const fakeWhich = async (bin) => (bin === 'aider' ? '/opt/aider' : null);
|
|
25
|
+
const result = await detectInstalledAgents({ AIDER_COMMAND: 'aider --foo' }, { which: fakeWhich });
|
|
26
|
+
const aider = result.find(r => r.backend === 'aider');
|
|
27
|
+
assert.equal(aider.command, 'aider --foo');
|
|
28
|
+
assert.equal(aider.present, true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('listKnownBackends returns 8 entries', () => {
|
|
32
|
+
const list = listKnownBackends();
|
|
33
|
+
assert.equal(list.length, 8);
|
|
34
|
+
assert.ok(list.some(b => b.backend === 'hermes'));
|
|
35
|
+
assert.ok(list.some(b => b.backend === 'cursor'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('detectInstalledAgents default which uses PATH iteration', async () => {
|
|
39
|
+
const result = await detectInstalledAgents({ PATH: '/nonexistent/dir' }, {});
|
|
40
|
+
assert.ok(Array.isArray(result));
|
|
41
|
+
for (const r of result) assert.equal(r.present, false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('pickDefaultBackend respects preferred when present', () => {
|
|
45
|
+
const detection = [
|
|
46
|
+
{ backend: 'hermes', present: false },
|
|
47
|
+
{ backend: 'claude', present: true },
|
|
48
|
+
{ backend: 'aider', present: true },
|
|
49
|
+
];
|
|
50
|
+
assert.equal(pickDefaultBackend(detection, 'aider'), 'aider');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('pickDefaultBackend falls back to first present when preferred missing', () => {
|
|
54
|
+
const detection = [
|
|
55
|
+
{ backend: 'hermes', present: false },
|
|
56
|
+
{ backend: 'claude', present: true },
|
|
57
|
+
{ backend: 'aider', present: true },
|
|
58
|
+
];
|
|
59
|
+
assert.equal(pickDefaultBackend(detection, 'codex'), 'claude');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('pickDefaultBackend returns hermes when nothing detected', () => {
|
|
63
|
+
const detection = [{ backend: 'hermes', present: false }, { backend: 'claude', present: false }];
|
|
64
|
+
assert.equal(pickDefaultBackend(detection, ''), 'hermes');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('formatAgentDetectionReport marks present and missing', () => {
|
|
68
|
+
const detection = [
|
|
69
|
+
{ backend: 'hermes', label: 'Hermes Agent', bin: 'hermes', present: true, path: '/usr/local/bin/hermes' },
|
|
70
|
+
{ backend: 'claude', label: 'Claude Code', bin: 'claude', present: false, path: null },
|
|
71
|
+
];
|
|
72
|
+
const out = formatAgentDetectionReport(detection);
|
|
73
|
+
assert.match(out, /1\/2 present/);
|
|
74
|
+
assert.match(out, /✓ Hermes Agent/);
|
|
75
|
+
assert.match(out, /· Claude Code/);
|
|
76
|
+
assert.match(out, /not found/);
|
|
77
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const BACKEND_ALIASES = {
|
|
2
|
+
hermes: ['hermes', '헤르메스'],
|
|
3
|
+
claude: ['claude code', 'claude-code', 'claude'],
|
|
4
|
+
codex: ['codex', '코덱스'],
|
|
5
|
+
gemini: ['gemini cli', 'gemini-cli', 'gemini', '제미나이'],
|
|
6
|
+
opencode: ['opencode', 'open code'],
|
|
7
|
+
openclaw: ['openclaw', 'open claw'],
|
|
8
|
+
aider: ['aider', '에이더'],
|
|
9
|
+
cursor: ['cursor cli', 'cursor-cli', 'cursor agent', 'cursor-agent', 'cursor'],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const BACKEND_LOOKUP = (() => {
|
|
13
|
+
const pairs = [];
|
|
14
|
+
for (const [backend, aliases] of Object.entries(BACKEND_ALIASES)) {
|
|
15
|
+
for (const alias of aliases) pairs.push([alias.toLowerCase(), backend]);
|
|
16
|
+
}
|
|
17
|
+
pairs.sort((a, b) => b[0].length - a[0].length);
|
|
18
|
+
return pairs;
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
const BACKEND_LABELS = {
|
|
22
|
+
hermes: { en: 'Hermes', ko: '헤르메스' },
|
|
23
|
+
claude: { en: 'Claude Code', ko: 'Claude Code' },
|
|
24
|
+
codex: { en: 'Codex', ko: '코덱스' },
|
|
25
|
+
gemini: { en: 'Gemini', ko: 'Gemini' },
|
|
26
|
+
opencode: { en: 'OpenCode', ko: 'OpenCode' },
|
|
27
|
+
openclaw: { en: 'OpenClaw', ko: 'OpenClaw' },
|
|
28
|
+
aider: { en: 'Aider', ko: 'Aider' },
|
|
29
|
+
cursor: { en: 'Cursor CLI', ko: 'Cursor CLI' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ROUTING_SLOT_NAMES = new Set(['which_agent', 'agent', 'who_answers', 'router_agent']);
|
|
33
|
+
|
|
34
|
+
const ASK_EN = /\bask\s+([a-z][a-z0-9 \-]{1,30}?)(?:\s+(?:to|what|if|whether)\b|[?,.]|$)/i;
|
|
35
|
+
const SWITCH_EN = /\bswitch\s+to\s+([a-z][a-z0-9 \-]{1,30}?)(?:[?,.]|$)/i;
|
|
36
|
+
const LET_FINISH_EN = /\blet\s+([a-z][a-z0-9 \-]{1,30}?)\s+(?:finish|handle|do)\b/i;
|
|
37
|
+
const RESTORE_EN = /\b(back\s+to\s+default|use\s+the\s+default\s+agent|default\s+agent)\b/i;
|
|
38
|
+
|
|
39
|
+
const ASK_KO = /([가-힣A-Za-z][가-힣A-Za-z0-9\-]{1,30})(?:한테|에게|에)\s*(물어|질문)/;
|
|
40
|
+
const SWITCH_KO = /([가-힣A-Za-z][가-힣A-Za-z0-9\-]{1,30})(?:로|으로)\s*(전환|바꿔|바꿔줘)/;
|
|
41
|
+
const RESTORE_KO = /(기본(?:으로)?\s*(?:돌아|복귀)|기본\s*에이전트)/;
|
|
42
|
+
|
|
43
|
+
export function isRoutingOnlyUtterance(text) {
|
|
44
|
+
const t = String(text || '').trim();
|
|
45
|
+
if (!t) return false;
|
|
46
|
+
const normalized = t.toLowerCase().replace(/[.,!?]+$/u, '').trim();
|
|
47
|
+
if (/^(?:please\s+)?(?:back\s+to\s+default|use\s+the\s+default\s+agent|default\s+agent)$/i.test(normalized)) return true;
|
|
48
|
+
if (/^기본(?:으로)?\s*(?:돌아(?:가|가줘)?|복귀)$/.test(normalized)) return true;
|
|
49
|
+
const en = normalized.match(/^(?:please\s+)?(?:switch\s+to|use)\s+(.+)$/i);
|
|
50
|
+
if (en) return resolveBackendAlias(en[1], { strict: true }) !== null;
|
|
51
|
+
const ko = normalized.match(/^(.+?)(?:로|으로)\s*(?:전환|바꿔|바꿔줘)$/);
|
|
52
|
+
if (ko) return resolveBackendAlias(ko[1], { strict: true }) !== null;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveBackendAlias(rawName, { strict = false } = {}) {
|
|
57
|
+
const needle = String(rawName || '').toLowerCase().trim();
|
|
58
|
+
if (!needle) return null;
|
|
59
|
+
for (const [alias, backend] of BACKEND_LOOKUP) {
|
|
60
|
+
if (needle === alias) return backend;
|
|
61
|
+
}
|
|
62
|
+
if (strict) return null;
|
|
63
|
+
for (const [alias, backend] of BACKEND_LOOKUP) {
|
|
64
|
+
if (needle.includes(alias)) return backend;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseAgentRoutingCommand(text, language = 'en') {
|
|
70
|
+
const t = String(text || '').trim();
|
|
71
|
+
if (!t) return { type: 'none' };
|
|
72
|
+
if (RESTORE_EN.test(t) || RESTORE_KO.test(t)) return { type: 'restore' };
|
|
73
|
+
const switchMatch = t.match(SWITCH_EN) || t.match(LET_FINISH_EN);
|
|
74
|
+
if (switchMatch) {
|
|
75
|
+
const backend = resolveBackendAlias(switchMatch[1]);
|
|
76
|
+
if (backend) return { type: 'route', backend, sticky: true };
|
|
77
|
+
}
|
|
78
|
+
const switchKo = t.match(SWITCH_KO);
|
|
79
|
+
if (switchKo) {
|
|
80
|
+
const backend = resolveBackendAlias(switchKo[1]);
|
|
81
|
+
if (backend) return { type: 'route', backend, sticky: true };
|
|
82
|
+
}
|
|
83
|
+
const askMatch = t.match(ASK_EN);
|
|
84
|
+
if (askMatch) {
|
|
85
|
+
const backend = resolveBackendAlias(askMatch[1]);
|
|
86
|
+
if (backend) return { type: 'route', backend, sticky: false };
|
|
87
|
+
}
|
|
88
|
+
const askKo = t.match(ASK_KO);
|
|
89
|
+
if (askKo) {
|
|
90
|
+
const backend = resolveBackendAlias(askKo[1]);
|
|
91
|
+
if (backend) return { type: 'route', backend, sticky: false };
|
|
92
|
+
}
|
|
93
|
+
return { type: 'none' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isAgentRoutingDecision(decision) {
|
|
97
|
+
if (!decision || typeof decision !== 'object') return false;
|
|
98
|
+
const slot = String(decision.slot || '').toLowerCase();
|
|
99
|
+
return ROUTING_SLOT_NAMES.has(slot);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderAgentPrefix(backend, language = 'en') {
|
|
103
|
+
const key = String(backend || '').toLowerCase();
|
|
104
|
+
if (!BACKEND_LABELS[key]) return '';
|
|
105
|
+
const en = /^en/i.test(String(language || ''));
|
|
106
|
+
const label = BACKEND_LABELS[key][en ? 'en' : 'ko'];
|
|
107
|
+
return en ? `${label} says: ` : `${label}: `;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function labelFor(backend, language) {
|
|
111
|
+
const key = String(backend || '').toLowerCase();
|
|
112
|
+
if (!BACKEND_LABELS[key]) return key || 'agent';
|
|
113
|
+
return BACKEND_LABELS[key][/^en/i.test(String(language || '')) ? 'en' : 'ko'];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildCrossAgentPrompt({
|
|
117
|
+
prompt, fromBackend, toBackend,
|
|
118
|
+
resolvedDecisions = {}, priorUtterances = [], language = 'en',
|
|
119
|
+
} = {}) {
|
|
120
|
+
const en = /^en/i.test(String(language || ''));
|
|
121
|
+
const fromLabel = labelFor(fromBackend, language);
|
|
122
|
+
const toLabel = labelFor(toBackend, language);
|
|
123
|
+
const lines = [];
|
|
124
|
+
lines.push(en
|
|
125
|
+
? `[Cross-agent handoff from ${fromLabel} to ${toLabel}]`
|
|
126
|
+
: `[에이전트 핸드오프: ${fromLabel} → ${toLabel}]`);
|
|
127
|
+
const decKeys = Object.keys(resolvedDecisions || {});
|
|
128
|
+
if (decKeys.length) {
|
|
129
|
+
const parts = decKeys.map(k => `${k}=${resolvedDecisions[k] === null ? '(agent picks)' : resolvedDecisions[k]}`);
|
|
130
|
+
lines.push(en ? `Prior decisions: ${parts.join(', ')}` : `이전 결정: ${parts.join(', ')}`);
|
|
131
|
+
}
|
|
132
|
+
const utterances = (priorUtterances || []).filter(Boolean).slice(-4);
|
|
133
|
+
if (utterances.length) {
|
|
134
|
+
lines.push(en
|
|
135
|
+
? `Recent user voice: ${utterances.join(' | ')}`
|
|
136
|
+
: `최근 사용자 음성: ${utterances.join(' | ')}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push(en ? `User request: ${prompt}` : `사용자 요청: ${prompt}`);
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function buildFallbackDecision(missingBackend, fallbackLabel, language = 'en') {
|
|
143
|
+
const en = /^en/i.test(String(language || ''));
|
|
144
|
+
const question = en
|
|
145
|
+
? `${missingBackend} is not installed. Use ${fallbackLabel} instead?`
|
|
146
|
+
: `${missingBackend}이(가) 설치되어 있지 않아. ${fallbackLabel}로 대신 진행할까?`;
|
|
147
|
+
return { slot: 'fallback', question, options: ['yes', 'no'] };
|
|
148
|
+
}
|