mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
19 Commits
v1.9.5
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a90b218c3 | ||
|
|
de9494c0a3 | ||
|
|
e7e1f7a34d | ||
|
|
9a5998eaef | ||
|
|
dff678924e | ||
|
|
4591432a1d | ||
|
|
901628b4d9 | ||
|
|
cf33c06021 | ||
|
|
e0ca1d054c | ||
|
|
6585d0f67c | ||
|
|
e4403ff010 | ||
|
|
9e61e7a90d | ||
|
|
d03af7bd4e | ||
|
|
e8ef955ff9 | ||
|
|
a8ed0cdce5 | ||
|
|
1c3b280c6a | ||
|
|
7a3cc24a00 | ||
|
|
2e7fc428cd | ||
|
|
ad09f38fd1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ data
|
||||
!.codex/prompts/
|
||||
!.codex/prompts/**
|
||||
teach-me
|
||||
credentials.json
|
||||
|
||||
@@ -76,7 +76,9 @@ bun run docs:dev
|
||||
### Runtime & Build
|
||||
|
||||
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。构建时会将 `vendor/audio-capture/` 和 `src/utils/vendor/ripgrep/` 复制到 `dist/vendor/` 下。
|
||||
- **Build (Vite)**: `vite.config.ts` + `scripts/post-build.ts`,chunk 输出到 `dist/chunks/`。post-build 同样复制 vendor 文件到 `dist/vendor/`。
|
||||
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/` 或 `dist/chunks/` 下,vendor 二进制在 `dist/vendor/`。`src/utils/ripgrep.ts` 和 `packages/audio-capture-napi/src/index.ts` 均通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 定位 dist 根目录,再拼接 `vendor/` 子路径,确保不同构建产物层级下路径一致。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
|
||||
10
README.md
10
README.md
@@ -19,15 +19,15 @@
|
||||
|
||||
| 特性 | 说明 | 文档 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
|
||||
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/uds-inbox) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
|
||||
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
|
||||
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
||||
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
||||
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
||||
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
|
||||
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
|
||||
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
|
||||
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
||||
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 (`/login`) | [文档](https://ccb.agent-aura.top/docs/features/all-features-guide) |
|
||||
| Voice Mode | 语音输入,支持豆包语言输入(`/voice doubao`) | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
||||
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
|
||||
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
|
||||
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
|
||||
@@ -233,6 +233,10 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 致谢
|
||||
|
||||
- [doubaoime-asr](https://github.com/starccy/doubaoime-asr) — 豆包 ASR 语音识别 SDK,为 Voice Mode 提供无需 Anthropic OAuth 的语音输入方案
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目仅供学习研究用途。Claude Code 的所有权利归 [Anthropic](https://www.anthropic.com/) 所有。
|
||||
|
||||
@@ -188,7 +188,7 @@ The TUI (REPL) mode requires a real terminal and cannot be launched directly via
|
||||
## Documentation & Links
|
||||
|
||||
- **Online docs (Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — source in [`docs/`](docs/), PR contributions welcome
|
||||
- **DeepWiki**: <https://deepwiki.com/claude-code-best/claude-code>
|
||||
- **DeepWiki**: https://deepwiki.com/claude-code-best/claude-code
|
||||
|
||||
## Contributors
|
||||
|
||||
|
||||
12
build.ts
12
build.ts
@@ -75,10 +75,14 @@ console.log(
|
||||
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for import.meta.require, ${bunPatched} for Bun destructure)`,
|
||||
)
|
||||
|
||||
// Step 4: Copy native .node addon files (audio-capture)
|
||||
const vendorDir = join(outdir, 'vendor', 'audio-capture')
|
||||
await cp('vendor/audio-capture', vendorDir, { recursive: true })
|
||||
console.log(`Copied vendor/audio-capture/ → ${vendorDir}/`)
|
||||
// Step 4: Copy native .node addon files (audio-capture) and vendored binaries (ripgrep)
|
||||
const audioCaptureDir = join(outdir, 'vendor', 'audio-capture')
|
||||
await cp('vendor/audio-capture', audioCaptureDir, { recursive: true })
|
||||
console.log(`Copied vendor/audio-capture/ → ${audioCaptureDir}/`)
|
||||
|
||||
const ripgrepDir = join(outdir, 'vendor', 'ripgrep')
|
||||
await cp('src/utils/vendor/ripgrep', ripgrepDir, { recursive: true })
|
||||
console.log(`Copied src/utils/vendor/ripgrep/ → ${ripgrepDir}/`)
|
||||
|
||||
// Step 5: Generate cli-bun and cli-node executable entry points
|
||||
const cliBun = join(outdir, 'cli-bun.js')
|
||||
|
||||
13
bun.lock
13
bun.lock
@@ -145,6 +145,9 @@
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"doubaoime-asr": "^0.1.0",
|
||||
},
|
||||
},
|
||||
"packages/@ant/claude-for-chrome-mcp": {
|
||||
"name": "@ant/claude-for-chrome-mcp",
|
||||
@@ -1791,6 +1794,8 @@
|
||||
|
||||
"dompurify": ["dompurify@3.4.0", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.0.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg=="],
|
||||
|
||||
"doubaoime-asr": ["doubaoime-asr@0.1.0", "", { "dependencies": { "opus-encdec": "^0.1.1", "protobufjs": "^8.0.0", "ws": "^8.18.0" }, "bin": { "doubaoime-asr": "bin/doubaoime-asr.mjs" } }, "sha512-HYUfHkTxNdOoztXwS18e6GBRLY9dSDWX43K4WvPvEmO6+RevO6WbawMMoUfHKPb4ySQn461un7XyN5l4UGejwg=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
@@ -2343,6 +2348,8 @@
|
||||
|
||||
"openai": ["openai@6.34.0", "https://registry.npmmirror.com/openai/-/openai-6.34.0.tgz", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw=="],
|
||||
|
||||
"opus-encdec": ["opus-encdec@0.1.1", "", {}, "sha512-TDzyGqYqrwn5UEUNaLsfLGu8Ma+HRNrgLYj7Vx5wfTnafAA21G6Bnm/qTIa3orQi/yZPZYmkdpO/gez4nfA1Rw=="],
|
||||
|
||||
"os-tmpdir": ["os-tmpdir@1.0.2", "https://registry.npmmirror.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="],
|
||||
|
||||
"oxc-parser": ["oxc-parser@0.121.0", "https://registry.npmmirror.com/oxc-parser/-/oxc-parser-0.121.0.tgz", { "dependencies": { "@oxc-project/types": "^0.121.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.121.0", "@oxc-parser/binding-android-arm64": "0.121.0", "@oxc-parser/binding-darwin-arm64": "0.121.0", "@oxc-parser/binding-darwin-x64": "0.121.0", "@oxc-parser/binding-freebsd-x64": "0.121.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.121.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.121.0", "@oxc-parser/binding-linux-arm64-gnu": "0.121.0", "@oxc-parser/binding-linux-arm64-musl": "0.121.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-musl": "0.121.0", "@oxc-parser/binding-linux-s390x-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-musl": "0.121.0", "@oxc-parser/binding-openharmony-arm64": "0.121.0", "@oxc-parser/binding-wasm32-wasi": "0.121.0", "@oxc-parser/binding-win32-arm64-msvc": "0.121.0", "@oxc-parser/binding-win32-ia32-msvc": "0.121.0", "@oxc-parser/binding-win32-x64-msvc": "0.121.0" } }, "sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg=="],
|
||||
@@ -2435,7 +2442,7 @@
|
||||
|
||||
"property-information": ["property-information@7.1.0", "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.4.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
"protobufjs": ["protobufjs@8.0.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
@@ -3029,6 +3036,8 @@
|
||||
|
||||
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
||||
|
||||
"@grpc/proto-loader/protobufjs": ["protobufjs@7.5.4", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.4.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"@hono/node-ws/@hono/node-server": ["@hono/node-server@1.19.13", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.13.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
|
||||
@@ -3123,6 +3132,8 @@
|
||||
|
||||
"@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "https://registry.npmmirror.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="],
|
||||
|
||||
"@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.5.4", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.4.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"@opentelemetry/sdk-logs/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
|
||||
|
||||
"@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "https://registry.npmmirror.com/@opentelemetry/resources/-/resources-2.6.1.tgz", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
|
||||
|
||||
@@ -145,8 +145,8 @@ M 键(或 ← / →)用于在两种路由模式之间切换,**无需展开
|
||||
|
||||
```
|
||||
/pipes — 显示所有实例 + 切换选择面板
|
||||
/pipes select <name> — 选中某实例(消息会广播到它)
|
||||
/pipes deselect <name> — 取消选中
|
||||
/pipes select <name> — 选中某实例(消息会广播到它)
|
||||
/pipes deselect <name> — 取消选中
|
||||
/pipes all — 全选
|
||||
/pipes none — 全部取消
|
||||
```
|
||||
@@ -169,7 +169,7 @@ LAN Peers:
|
||||
Selected: cli-da029538
|
||||
```
|
||||
|
||||
### /attach <name>
|
||||
### /attach <name>
|
||||
|
||||
手动 attach 到一个实例,使其成为你的 slave。
|
||||
|
||||
@@ -179,7 +179,7 @@ Selected: cli-da029538
|
||||
|
||||
attach 后,对方变为 slave,你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
|
||||
|
||||
### /detach <name>
|
||||
### /detach <name>
|
||||
|
||||
断开与某个 slave 的连接。
|
||||
|
||||
@@ -187,7 +187,7 @@ attach 后,对方变为 slave,你变为 master。可以向它发送 prompt
|
||||
/detach cli-04d67950
|
||||
```
|
||||
|
||||
### /send <name> <message>
|
||||
### /send <name> <message>
|
||||
|
||||
向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。
|
||||
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
# VOICE_MODE — 语音输入
|
||||
|
||||
> Feature Flag: `FEATURE_VOICE_MODE=1`
|
||||
> 实现状态:完整可用(需要 Anthropic OAuth)
|
||||
> 实现状态:完整可用(双后端:Anthropic OAuth / 豆包 ASR)
|
||||
> 引用数:46
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空格键录音,音频通过 WebSocket 流式传输到 Anthropic STT 端点(Nova 3),实时转录显示在终端中。
|
||||
VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空格键录音,音频流式传输到 STT 后端,实时转录显示在终端中。支持两个后端:
|
||||
|
||||
- **Anthropic STT(默认)**:通过 WebSocket 流式传输到 Nova 3 端点,需要 Anthropic OAuth
|
||||
- **豆包 ASR(Doubao)**:通过 `doubaoime-asr` 包的 AsyncGenerator 协议流式识别,使用独立凭证文件,无需 Anthropic OAuth
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **Push-to-Talk**:长按空格键录音,释放后自动发送
|
||||
- **流式转录**:录音过程中实时显示中间转录结果
|
||||
- **无缝集成**:转录文本直接作为用户消息提交到对话
|
||||
- **双后端切换**:通过 `/voice` 命令参数选择 STT 后端,持久化到 settings.json
|
||||
|
||||
## 二、用户交互
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 长按空格 | 开始录音,显示录音状态 |
|
||||
| 释放空格 | 停止录音,等待最终转录 |
|
||||
| 转录完成 | 自动插入到输入框并提交 |
|
||||
| `/voice` 命令 | 切换语音模式开关 |
|
||||
| 释放空格 | 停止录音,转录结果自动提交 |
|
||||
| `/voice` | 切换语音模式开关(默认使用 Anthropic 后端) |
|
||||
| `/voice doubao` | 启用语音模式并使用豆包 ASR 后端 |
|
||||
| `/voice anthropic` | 切换回 Anthropic STT 后端 |
|
||||
|
||||
### UI 反馈
|
||||
|
||||
@@ -35,26 +40,37 @@ VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空
|
||||
|
||||
文件:`src/voice/voiceModeEnabled.ts`
|
||||
|
||||
三层检查:
|
||||
两层检查函数:
|
||||
|
||||
```ts
|
||||
// Anthropic 后端(需要 OAuth)
|
||||
isVoiceModeEnabled() = hasVoiceAuth() && isVoiceGrowthBookEnabled()
|
||||
|
||||
// 豆包后端 / 通用可用性检查(不需要 OAuth)
|
||||
isVoiceAvailable() = isVoiceGrowthBookEnabled()
|
||||
```
|
||||
|
||||
1. **Feature Flag**:`feature('VOICE_MODE')` — 编译时/运行时开关
|
||||
2. **GrowthBook Kill-Switch**:`!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_quartz_disabled', false)` — 紧急关闭开关(默认 false = 未禁用)
|
||||
3. **Auth 检查**:`hasVoiceAuth()` — 需要 Anthropic OAuth token(非 API key)
|
||||
3. **Auth 检查(仅 Anthropic)**:`hasVoiceAuth()` — 需要 Anthropic OAuth token(非 API key)
|
||||
4. **Provider 检查**:`voiceProvider` 设置决定使用哪个后端,豆包后端跳过 OAuth 检查
|
||||
|
||||
### 3.2 核心模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | Feature flag + GrowthBook + Auth 三层门控 |
|
||||
| `src/hooks/useVoice.ts` | React hook 管理录音状态和 WebSocket 连接 |
|
||||
| `src/services/voiceStreamSTT.ts` | WebSocket 流式传输到 Anthropic STT |
|
||||
| `src/hooks/useVoice.ts` | React hook 管理录音状态和后端连接 |
|
||||
| `src/services/voiceStreamSTT.ts` | Anthropic WebSocket 流式 STT |
|
||||
| `src/services/doubaoSTT.ts` | 豆包 ASR 适配器(AsyncGenerator → VoiceStreamConnection) |
|
||||
| `src/commands/voice/voice.ts` | `/voice` 命令实现,处理后端选择和持久化 |
|
||||
| `src/hooks/useVoiceEnabled.ts` | 语音启用状态 hook,根据 provider 决定是否跳过 OAuth |
|
||||
| `src/utils/settings/types.ts` | `voiceProvider: 'anthropic' | 'doubao'` 设置类型定义 |
|
||||
|
||||
### 3.3 数据流
|
||||
|
||||
#### Anthropic 后端
|
||||
|
||||
```
|
||||
用户按下空格键
|
||||
│
|
||||
@@ -79,20 +95,108 @@ WebSocket 连接到 Anthropic STT 端点
|
||||
转录文本 → 插入输入框 → 自动提交
|
||||
```
|
||||
|
||||
#### 豆包 ASR 后端
|
||||
|
||||
```
|
||||
用户按下空格键
|
||||
│
|
||||
▼
|
||||
useVoice hook 激活(检测到 voiceProvider === 'doubao')
|
||||
│
|
||||
▼
|
||||
macOS 原生音频 / SoX 开始录音
|
||||
│
|
||||
▼
|
||||
connectDoubaoStream() 创建 AudioChunkQueue + VoiceStreamConnection
|
||||
│
|
||||
├──→ onReady 立即触发(无需等待握手)
|
||||
│
|
||||
▼
|
||||
音频数据通过 AudioChunkQueue 传入 transcribeRealtime()
|
||||
│
|
||||
├──→ INTERIM_RESULT → 实时显示中间转录
|
||||
├──→ FINAL_RESULT → 显示最终转录
|
||||
│
|
||||
▼
|
||||
用户释放空格键
|
||||
│
|
||||
▼
|
||||
finalize() 立即返回(豆包在录音过程中已返回结果,无需等待)
|
||||
│
|
||||
▼
|
||||
转录文本 → 插入输入框 → 自动提交
|
||||
```
|
||||
|
||||
### 3.4 音频录制
|
||||
|
||||
支持两种音频后端:
|
||||
支持两种音频后端(两个 STT 后端共享):
|
||||
- **macOS 原生音频**:优先使用,低延迟
|
||||
- **SoX(Sound eXchange)**:回退方案,跨平台
|
||||
|
||||
音频流通过 WebSocket 发送到 Anthropic 的 Nova 3 STT 模型。
|
||||
### 3.5 豆包 ASR 适配器设计
|
||||
|
||||
文件:`src/services/doubaoSTT.ts`
|
||||
|
||||
豆包后端使用适配器模式,将 `doubaoime-asr` 的 AsyncGenerator 协议桥接到 `VoiceStreamConnection` 接口:
|
||||
|
||||
**AudioChunkQueue** — push 式异步队列:
|
||||
- 实现 `AsyncIterable<Uint8Array>` 接口
|
||||
- `push(chunk)` 将音频数据入队,`push(null)` 发送结束信号
|
||||
- 内部维护等待者(waiting)和缓冲队列(chunks)两个状态
|
||||
|
||||
**connectDoubaoStream()** — 连接入口:
|
||||
- 动态导入 `doubaoime-asr`(optionalDependencies)
|
||||
- 从 `~/.claude/tts/doubao/credentials.json` 加载凭证
|
||||
- 创建 AudioChunkQueue 和 VoiceStreamConnection
|
||||
- 立即触发 `onReady`(避免与 useVoice 的音频缓冲死锁)
|
||||
- `finalize()` 立即返回(豆包在录音过程中已返回结果)
|
||||
- 后台 async IIFE 消费 `transcribeRealtime` generator,映射响应类型到回调
|
||||
|
||||
**响应类型映射**:
|
||||
|
||||
| doubaoime-asr ResponseType | 回调映射 |
|
||||
|----------------------------|----------|
|
||||
| SESSION_STARTED | 日志记录 |
|
||||
| VAD_START | 日志记录 |
|
||||
| INTERIM_RESULT | `onTranscript(text, false)` |
|
||||
| FINAL_RESULT | `onTranscript(text, true)` |
|
||||
| ERROR | `onError(errorMsg)` |
|
||||
| SESSION_FINISHED | 日志记录 |
|
||||
|
||||
### 3.6 后端选择逻辑
|
||||
|
||||
文件:`src/hooks/useVoice.ts`
|
||||
|
||||
```ts
|
||||
// 判断当前 provider
|
||||
isDoubaoProvider() → 读取 settings.voiceProvider
|
||||
|
||||
// handleKeyEvent 中的可用性检查
|
||||
const sttAvailable = isDoubaoProvider()
|
||||
? isDoubaoAvailableSync() // 乐观检查(首次返回 true)
|
||||
: isVoiceStreamAvailable() // Anthropic WebSocket 检查
|
||||
|
||||
// attemptConnect 中的连接函数选择
|
||||
const connectFn = isDoubaoProvider()
|
||||
? connectDoubaoStream
|
||||
: connectVoiceStream
|
||||
```
|
||||
|
||||
豆包后端的特殊处理:
|
||||
- 跳过 `getVoiceKeyterms()` 调用(豆包无需关键词提示)
|
||||
- 跳过 Focus Mode(`if (!enabled || !focusMode || isDoubaoProvider())`)
|
||||
|
||||
## 四、关键设计决策
|
||||
|
||||
1. **OAuth 独占**:语音模式使用 `voice_stream` 端点(claude.ai),仅 Anthropic OAuth 用户可用。API key、Bedrock、Vertex 用户无法使用
|
||||
2. **GrowthBook 负向门控**:`tengu_amber_quartz_disabled` 默认 `false`,新安装自动可用(无需等 GrowthBook 初始化)
|
||||
3. **Keychain 缓存**:`getClaudeAIOAuthTokens()` 首次调用访问 macOS keychain(~20-50ms),后续缓存命中
|
||||
4. **独立于主 feature flag**:`isVoiceGrowthBookEnabled()` 在 feature flag 关闭时短路返回 `false`,不触发任何模块加载
|
||||
1. **双后端共存**:豆包后端作为独立适配器与 Anthropic 后端并存,不替换原有流程,通过 `voiceProvider` 设置切换
|
||||
2. **设置持久化**:`voiceProvider` 存储在 `settings.json`,通过 `/voice` 命令修改,跨会话生效
|
||||
3. **OAuth 独占(Anthropic)**:Anthropic 后端使用 `voice_stream` 端点(claude.ai),仅 OAuth 用户可用
|
||||
4. **豆包无需 OAuth**:豆包后端使用独立凭证文件,不依赖 Anthropic 认证,通过 `isVoiceAvailable()` 放宽门控
|
||||
5. **GrowthBook 负向门控**:`tengu_amber_quartz_disabled` 默认 `false`,新安装自动可用
|
||||
6. **onReady 立即触发**:豆包后端在连接建立后立即触发 `onReady`,避免与 useVoice 音频缓冲的时序死锁(Anthropic 需要等待 WebSocket 握手)
|
||||
7. **finalize() 立即返回**:豆包在录音过程中已返回所有结果,用户抬手时无需等待处理
|
||||
8. **乐观可用性检查**:`isDoubaoAvailableSync()` 在首次调用时返回 `true`,实际导入错误在 `connectDoubaoStream` 中处理
|
||||
9. **optionalDependencies**:`doubaoime-asr` 作为可选依赖,安装失败不影响 Anthropic 后端
|
||||
|
||||
## 五、使用方式
|
||||
|
||||
@@ -100,26 +204,60 @@ WebSocket 连接到 Anthropic STT 端点
|
||||
# 启用 feature
|
||||
FEATURE_VOICE_MODE=1 bun run dev
|
||||
|
||||
# 在 REPL 中使用
|
||||
# 在 REPL 中使用 Anthropic 后端
|
||||
# 1. 确保已通过 OAuth 登录(claude.ai 订阅)
|
||||
# 2. 按住空格键说话
|
||||
# 3. 释放空格键等待转录
|
||||
# 4. 或使用 /voice 命令切换开关
|
||||
# 2. 输入 /voice 启用
|
||||
# 3. 按住空格键说话
|
||||
# 4. 释放空格键等待转录
|
||||
|
||||
# 在 REPL 中使用豆包 ASR 后端
|
||||
# 1. 确保 doubaoime-asr 已安装(bun add doubaoime-asr)
|
||||
# 2. 配置凭证文件:~/.claude/tts/doubao/credentials.json
|
||||
# 3. 输入 /voice doubao 启用
|
||||
# 4. 按住空格键说话
|
||||
# 5. 释放空格键,转录结果即刻显示
|
||||
|
||||
# 切换后端
|
||||
/voice doubao # 切换到豆包 ASR
|
||||
/voice anthropic # 切换回 Anthropic STT
|
||||
/voice # 关闭语音模式
|
||||
```
|
||||
|
||||
### 豆包凭证配置
|
||||
|
||||
凭证文件路径:`~/.claude/tts/doubao/credentials.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceId": "...",
|
||||
"installId": "...",
|
||||
"cdid": "...",
|
||||
"openudid": "...",
|
||||
"clientudid": "...",
|
||||
"token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## 六、外部依赖
|
||||
|
||||
| 依赖 | 说明 |
|
||||
|------|------|
|
||||
| Anthropic OAuth | claude.ai 订阅登录,非 API key |
|
||||
| GrowthBook | `tengu_amber_quartz_disabled` 紧急关闭 |
|
||||
| macOS 原生音频 或 SoX | 音频录制 |
|
||||
| Nova 3 STT | 语音转文本模型 |
|
||||
| 依赖 | 说明 | 适用后端 |
|
||||
|------|------|----------|
|
||||
| Anthropic OAuth | claude.ai 订阅登录,非 API key | Anthropic |
|
||||
| GrowthBook | `tengu_amber_quartz_disabled` 紧急关闭 | 通用 |
|
||||
| macOS 原生音频 或 SoX | 音频录制 | 通用 |
|
||||
| Nova 3 STT | Anthropic 语音转文本模型 | Anthropic |
|
||||
| doubaoime-asr | 豆包 ASR SDK(optionalDependencies) | 豆包 |
|
||||
| 凭证文件 | `~/.claude/tts/doubao/credentials.json` | 豆包 |
|
||||
|
||||
## 七、文件索引
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | 54 | 三层门控逻辑 |
|
||||
| `src/hooks/useVoice.ts` | — | React hook(录音状态 + WebSocket) |
|
||||
| `src/services/voiceStreamSTT.ts` | — | STT WebSocket 流式传输 |
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | 三层门控逻辑 + `isVoiceAvailable()` |
|
||||
| `src/hooks/useVoice.ts` | React hook(录音状态 + 后端选择 + 连接管理) |
|
||||
| `src/hooks/useVoiceEnabled.ts` | 语音启用状态 hook(按 provider 决定 OAuth 检查) |
|
||||
| `src/services/voiceStreamSTT.ts` | Anthropic STT WebSocket 流式传输 |
|
||||
| `src/services/doubaoSTT.ts` | 豆包 ASR 适配器(AudioChunkQueue + connectDoubaoStream) |
|
||||
| `src/commands/voice/voice.ts` | `/voice` 命令(开关 + 后端选择) |
|
||||
| `src/commands/voice/index.ts` | 命令注册(去除 availability 限制) |
|
||||
| `src/utils/settings/types.ts` | `voiceProvider` 类型定义 |
|
||||
|
||||
@@ -200,9 +200,9 @@ LSP 服务器通过插件提供。插件的 `manifest.json` 中可以声明 LSP
|
||||
|------|------|------|------|
|
||||
| `command` | string | 是 | LSP 服务器可执行命令(不含空格) |
|
||||
| `args` | string[] | 否 | 命令行参数 |
|
||||
| `extensionToLanguage` | Record<string, string> | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
||||
| `extensionToLanguage` | `Record<string, string>` | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
||||
| `transport` | `"stdio"` \| `"socket"` | 否 | 通信方式,默认 `stdio` |
|
||||
| `env` | Record<string, string> | 否 | 启动服务器时设置的环境变量 |
|
||||
| `env` | `Record<string, string>` | 否 | 启动服务器时设置的环境变量 |
|
||||
| `initializationOptions` | unknown | 否 | 传给服务器的初始化选项 |
|
||||
| `settings` | unknown | 否 | 通过 `workspace/didChangeConfiguration` 传递的设置 |
|
||||
| `workspaceFolder` | string | 否 | 工作区目录路径 |
|
||||
|
||||
@@ -175,7 +175,7 @@ F. getCompletedResults() → 空
|
||||
|
||||
---
|
||||
|
||||
#### #8 stream_event (input_json_delta: '{"file_path":')
|
||||
#### #8 stream_event (input_json_delta: `'{"file_path":'`)
|
||||
|
||||
```
|
||||
D. yield message ✅ → REPL 追加工具输入 JSON 碎片
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.9.5",
|
||||
"version": "1.10.2",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
@@ -205,5 +205,8 @@
|
||||
"xss": "^1.0.15",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"doubaoime-asr": "^0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
// createRequire works in both Bun and Node.js ESM contexts.
|
||||
// Needed because this package is "type": "module" but uses require() for
|
||||
// loading native .node addons — bare require is not available in Node.js ESM.
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
/**
|
||||
* Resolve the "vendor root" directory where native .node binaries live.
|
||||
*
|
||||
* - Dev mode: import.meta.url → packages/audio-capture-napi/src/index.ts
|
||||
* → vendor root = <project>/vendor/
|
||||
* - Bun build: import.meta.url → dist/chunk-xxx.js
|
||||
* → vendor root = <project>/dist/vendor/
|
||||
* - Vite build: import.meta.url → dist/chunks/chunk-xxx.js
|
||||
* → vendor root = <project>/dist/vendor/
|
||||
*/
|
||||
function getVendorRoot(): string {
|
||||
const filePath = fileURLToPath(import.meta.url)
|
||||
const dir = dirname(filePath)
|
||||
const parts = dir.split(sep)
|
||||
const distIdx = parts.lastIndexOf('dist')
|
||||
if (distIdx !== -1) {
|
||||
return parts.slice(0, distIdx + 1).join(sep) + sep + 'vendor'
|
||||
}
|
||||
// Dev mode — go up from packages/audio-capture-napi/src/ to project root
|
||||
return resolve(dir, '..', '..', '..', 'vendor')
|
||||
}
|
||||
|
||||
type AudioCaptureNapi = {
|
||||
startRecording(
|
||||
onData: (data: Buffer) => void,
|
||||
@@ -56,15 +79,18 @@ function loadModule(): AudioCaptureNapi | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Candidates 2-4: npm-install, dev/source, and workspace layouts.
|
||||
// In bundled output, require() resolves relative to cli.js at the package root.
|
||||
// In dev, it resolves relative to this file. When loaded from a workspace
|
||||
// package (packages/audio-capture-napi/src/), we need an absolute path fallback.
|
||||
// Candidates 2-5: resolved vendor path + relative fallbacks.
|
||||
// The primary candidate uses getVendorRoot() to find the correct dist root
|
||||
// regardless of chunk nesting depth. Relative fallbacks cover edge cases.
|
||||
const platformDir = `${process.arch}-${platform}`
|
||||
const binaryRel = `audio-capture/${platformDir}/audio-capture.node`
|
||||
const vendorRoot = getVendorRoot()
|
||||
const fallbacks = [
|
||||
`./vendor/audio-capture/${platformDir}/audio-capture.node`,
|
||||
`../audio-capture/${platformDir}/audio-capture.node`,
|
||||
`${process.cwd()}/vendor/audio-capture/${platformDir}/audio-capture.node`,
|
||||
resolve(vendorRoot, binaryRel),
|
||||
`./vendor/${binaryRel}`,
|
||||
`../vendor/${binaryRel}`,
|
||||
`../../vendor/${binaryRel}`,
|
||||
`${process.cwd()}/vendor/${binaryRel}`,
|
||||
]
|
||||
for (const p of fallbacks) {
|
||||
try {
|
||||
|
||||
@@ -421,7 +421,7 @@ export const PowerShellTool = buildTool({
|
||||
isSearch: boolean
|
||||
isRead: boolean
|
||||
} {
|
||||
if (!input.command) {
|
||||
if (!input?.command) {
|
||||
return { isSearch: false, isRead: false }
|
||||
}
|
||||
return isSearchOrReadPowerShellCommand(input.command)
|
||||
|
||||
@@ -98,22 +98,6 @@ export function storeDeleteToken(token: string): boolean {
|
||||
|
||||
// ---------- Environment ----------
|
||||
|
||||
/** Find an active or offline environment by machineName (optionally filtered by workerType).
|
||||
* Includes "offline" so ACP agents can be reused on reconnect. */
|
||||
export function storeFindEnvironmentByMachineName(
|
||||
machineName: string,
|
||||
workerType?: string,
|
||||
): EnvironmentRecord | undefined {
|
||||
for (const rec of environments.values()) {
|
||||
if (rec.machineName === machineName && (rec.status === "active" || rec.status === "offline")) {
|
||||
if (!workerType || rec.workerType === workerType) {
|
||||
return rec;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function storeCreateEnvironment(req: {
|
||||
secret: string;
|
||||
machineName?: string;
|
||||
@@ -126,23 +110,6 @@ export function storeCreateEnvironment(req: {
|
||||
username?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}): EnvironmentRecord {
|
||||
// ACP: reuse existing active record by machineName
|
||||
if (req.workerType === "acp" && req.machineName) {
|
||||
const existing = storeFindEnvironmentByMachineName(req.machineName, "acp");
|
||||
if (existing) {
|
||||
Object.assign(existing, {
|
||||
status: "active",
|
||||
lastPollAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
maxSessions: req.maxSessions ?? existing.maxSessions,
|
||||
bridgeId: req.bridgeId ?? existing.bridgeId,
|
||||
capabilities: req.capabilities ?? existing.capabilities,
|
||||
username: req.username ?? existing.username,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const id = `env_${uuid().replace(/-/g, "")}`;
|
||||
const now = new Date();
|
||||
const record: EnvironmentRecord = {
|
||||
|
||||
@@ -30,33 +30,33 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
'BUDDY', // 陪伴宠物角色(Squirtle Waddles)
|
||||
'TRANSCRIPT_CLASSIFIER', // 对话分类器,用于标注会话类型
|
||||
'BRIDGE_MODE', // Remote Control / Bridge 模式,远程控制会话
|
||||
'AGENT_TRIGGERS_REMOTE', // Agent 触发远程会话连接
|
||||
'AGENT_TRIGGERS_REMOTE', // sessionIngress 模块级 Map 累积(非 GB 级主因)
|
||||
'CHICAGO_MCP', // Chicago MCP 集成(内部代号)
|
||||
'VOICE_MODE', // Push-to-Talk 语音输入模式
|
||||
'SHOT_STATS', // 单次请求统计信息收集
|
||||
'PROMPT_CACHE_BREAK_DETECTION', // 检测 prompt cache 是否被打破
|
||||
'PROMPT_CACHE_BREAK_DETECTION', // 检测 prompt cache 是否被打破(有 10 条上限,可控)
|
||||
'TOKEN_BUDGET', // Token 预算管理与控制
|
||||
// P0: local features
|
||||
'AGENT_TRIGGERS', // 本地 Agent 触发器(工具调用时启动子代理)
|
||||
'ULTRATHINK', // 超深度思考模式,增加推理链长度
|
||||
'BUILTIN_EXPLORE_PLAN_AGENTS', // 内置 Explore/Plan 子代理类型
|
||||
'LODESTONE', // 上下文锚点,优化长对话的相关性检索
|
||||
'EXTRACT_MEMORIES', // 自动从对话中提取并持久化记忆
|
||||
'VERIFICATION_AGENT', // 验证代理,任务完成后自动校验结果
|
||||
'EXTRACT_MEMORIES', // 每次 turn 结束 fork 完整消息历史(非 GB 级主因)
|
||||
'VERIFICATION_AGENT', // 任务完成后 fork 完整消息(非 GB 级主因)
|
||||
'KAIROS_BRIEF', // Kairos 定时摘要(定时汇报当前状态)
|
||||
'AWAY_SUMMARY', // 离线摘要(用户离开后生成总结)
|
||||
'ULTRAPLAN', // 超级规划模式,深度分析后生成实施计划
|
||||
// 'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(已禁用:内存占用过高)
|
||||
'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(非 GB 级主因)
|
||||
'ACP', // ACP 代理协议,支持外部 agent 接入
|
||||
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD)
|
||||
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||
'CONTEXT_COLLAPSE', // 上下文折叠,自动压缩旧消息
|
||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||
'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务
|
||||
'UDS_INBOX', // Unix Domain Socket 收件箱,跨会话消息传递
|
||||
'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
|
||||
'KAIROS', // Kairos 定时任务系统核心
|
||||
'COORDINATOR_MODE', // 协调者模式,多代理团队任务调度
|
||||
'LAN_PIPES', // 局域网管道,LAN 设备间通信
|
||||
// 'COORDINATOR_MODE', // 已禁用:AgentSummary 30s fork 循环,GB 级泄露主因
|
||||
'LAN_PIPES', // 依赖 UDS_INBOX(已随 UDS_INBOX 恢复)
|
||||
'BG_SESSIONS', // 后台会话管理(ps/logs/attach/kill)
|
||||
'TEMPLATES', // 模板任务(new/list/reply 子命令)
|
||||
// 'REVIEW_ARTIFACT', // 代码审查产物(API 请求无响应,待排查 schema 兼容性)
|
||||
@@ -68,11 +68,11 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
'DIRECT_CONNECT', // 直连模式(claude server / claude open)
|
||||
// Skill search & learning
|
||||
'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索(DiscoverSkills)
|
||||
'SKILL_LEARNING', // 技能学习系统,从对话中自动生成/演化技能
|
||||
'SKILL_LEARNING', // projectContext cache 无淘汰机制(非 GB 级主因)
|
||||
// P3: poor mode
|
||||
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
|
||||
// Team Memory
|
||||
'TEAMMEM', // 团队记忆,代理队友间共享记忆文件
|
||||
// 'TEAMMEM', // 已禁用:依赖 COORDINATOR_MODE,邮箱文件无限增长
|
||||
// SSH Remote
|
||||
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
|
||||
]as const;
|
||||
|
||||
@@ -36,9 +36,13 @@ async function postBuild() {
|
||||
}
|
||||
|
||||
// Step 2: Copy native addon files
|
||||
const vendorDir = join(outdir, "vendor", "audio-capture");
|
||||
await cp("vendor/audio-capture", vendorDir, { recursive: true } as never);
|
||||
console.log(`Copied vendor/audio-capture/ → ${vendorDir}/`);
|
||||
const audioCaptureDir = join(outdir, "vendor", "audio-capture");
|
||||
await cp("vendor/audio-capture", audioCaptureDir, { recursive: true } as never);
|
||||
console.log(`Copied vendor/audio-capture/ → ${audioCaptureDir}/`);
|
||||
|
||||
const ripgrepDir = join(outdir, "vendor", "ripgrep");
|
||||
await cp("src/utils/vendor/ripgrep", ripgrepDir, { recursive: true } as never);
|
||||
console.log(`Copied src/utils/vendor/ripgrep/ → ${ripgrepDir}/`);
|
||||
|
||||
// Step 3: Generate dual entry points
|
||||
const cliBun = join(outdir, "cli-bun.js");
|
||||
|
||||
@@ -190,12 +190,10 @@ export async function mcpListHandler(): Promise<void> {
|
||||
logEvent('tengu_mcp_list', {})
|
||||
const { servers: configs } = await getAllMcpConfigs()
|
||||
if (Object.keys(configs).length === 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
'No MCP servers configured. Use `claude mcp add` to add a server.',
|
||||
)
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Checking MCP server health...\n')
|
||||
|
||||
// Check servers concurrently
|
||||
@@ -213,18 +211,14 @@ export async function mcpListHandler(): Promise<void> {
|
||||
for (const { name, server, status } of results) {
|
||||
// Intentionally excluding sse-ide servers here since they're internal
|
||||
if (server.type === 'sse') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} (SSE) - ${status}`)
|
||||
} else if (server.type === 'http') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} (HTTP) - ${status}`)
|
||||
} else if (server.type === 'claudeai-proxy') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} - ${status}`)
|
||||
} else if (!server.type || server.type === 'stdio') {
|
||||
const stdioServer = server as { command: string; args: string[]; type?: string }
|
||||
const args = Array.isArray(stdioServer.args) ? stdioServer.args : []
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`)
|
||||
}
|
||||
}
|
||||
@@ -244,27 +238,20 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||
cliError(`No MCP server found with name: ${name}`)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}:`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Scope: ${getScopeLabel(server.scope)}`)
|
||||
|
||||
// Check server health
|
||||
const status = await checkMcpServerHealth(name, server)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Status: ${status}`)
|
||||
|
||||
// Intentionally excluding sse-ide servers here since they're internal
|
||||
if (server.type === 'sse') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Type: sse`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` URL: ${server.url}`)
|
||||
if (server.headers) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(' Headers:')
|
||||
for (const [key, value] of Object.entries(server.headers)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}: ${value}`)
|
||||
}
|
||||
}
|
||||
@@ -277,19 +264,14 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||
}
|
||||
if (server.oauth.callbackPort)
|
||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` OAuth: ${parts.join(', ')}`)
|
||||
}
|
||||
} else if (server.type === 'http') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Type: http`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` URL: ${server.url}`)
|
||||
if (server.headers) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(' Headers:')
|
||||
for (const [key, value] of Object.entries(server.headers)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}: ${value}`)
|
||||
}
|
||||
}
|
||||
@@ -302,27 +284,20 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||
}
|
||||
if (server.oauth.callbackPort)
|
||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` OAuth: ${parts.join(', ')}`)
|
||||
}
|
||||
} else if (server.type === 'stdio') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Type: stdio`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Command: ${server.command}`)
|
||||
const args = Array.isArray(server.args) ? server.args : []
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Args: ${args.join(' ')}`)
|
||||
if (server.env) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(' Environment:')
|
||||
for (const [key, value] of Object.entries(server.env)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}=${value}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`,
|
||||
)
|
||||
|
||||
@@ -169,7 +169,7 @@ export async function call(
|
||||
|
||||
if (COMMON_HELP_ARGS.includes(args)) {
|
||||
onDone(
|
||||
'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6 only)\n- auto: Use the default effort level for your model',
|
||||
'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6/4.7, DeepSeek V4 Pro)\n- auto: Use the default effort level for your model',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import {
|
||||
isVoiceGrowthBookEnabled,
|
||||
isVoiceModeEnabled,
|
||||
isVoiceAvailable,
|
||||
} from '../../voice/voiceModeEnabled.js'
|
||||
|
||||
const voice = {
|
||||
type: 'local',
|
||||
name: 'voice',
|
||||
description: 'Toggle voice mode',
|
||||
availability: ['claude-ai'],
|
||||
isEnabled: () => isVoiceGrowthBookEnabled(),
|
||||
description: 'Toggle voice mode. Use /voice doubao for Doubao ASR backend',
|
||||
isEnabled: () => isVoiceAvailable(),
|
||||
get isHidden() {
|
||||
return !isVoiceModeEnabled()
|
||||
return !isVoiceAvailable()
|
||||
},
|
||||
supportsNonInteractive: false,
|
||||
load: () => import('./voice.js'),
|
||||
|
||||
@@ -2,29 +2,19 @@ import { normalizeLanguageForSTT } from '../../hooks/useVoice.js'
|
||||
import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import { isAnthropicAuthEnabled } from '../../utils/auth.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { settingsChangeDetector } from '../../utils/settings/changeDetector.js'
|
||||
import {
|
||||
getInitialSettings,
|
||||
updateSettingsForSource,
|
||||
} from '../../utils/settings/settings.js'
|
||||
import { isVoiceModeEnabled } from '../../voice/voiceModeEnabled.js'
|
||||
import { isVoiceAvailable } from '../../voice/voiceModeEnabled.js'
|
||||
|
||||
const LANG_HINT_MAX_SHOWS = 2
|
||||
|
||||
export const call: LocalCommandCall = async () => {
|
||||
// Check auth and kill-switch before allowing voice mode
|
||||
if (!isVoiceModeEnabled()) {
|
||||
// Differentiate: OAuth-less users get an auth hint, everyone else
|
||||
// gets nothing (command shouldn't be reachable when the kill-switch is on).
|
||||
if (!isAnthropicAuthEnabled()) {
|
||||
return {
|
||||
type: 'text' as const,
|
||||
value:
|
||||
'Voice mode requires a Claude.ai account. Please run /login to sign in.',
|
||||
}
|
||||
}
|
||||
export const call: LocalCommandCall = async (args) => {
|
||||
// Check kill-switch before allowing voice mode
|
||||
if (!isVoiceAvailable()) {
|
||||
return {
|
||||
type: 'text' as const,
|
||||
value: 'Voice mode is not available.',
|
||||
@@ -33,6 +23,47 @@ export const call: LocalCommandCall = async () => {
|
||||
|
||||
const currentSettings = getInitialSettings()
|
||||
const isCurrentlyEnabled = currentSettings.voiceEnabled === true
|
||||
const providerArg = args?.trim().toLowerCase()
|
||||
|
||||
// Handle provider argument when already enabled — switch backend only
|
||||
if (isCurrentlyEnabled && providerArg === 'doubao') {
|
||||
const result = updateSettingsForSource('userSettings', {
|
||||
voiceProvider: 'doubao',
|
||||
})
|
||||
if (result.error) {
|
||||
return {
|
||||
type: 'text' as const,
|
||||
value:
|
||||
'Failed to update settings. Check your settings file for syntax errors.',
|
||||
}
|
||||
}
|
||||
settingsChangeDetector.notifyChange('userSettings')
|
||||
const key = getShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
|
||||
return {
|
||||
type: 'text' as const,
|
||||
value: `Voice mode switched to Doubao ASR. Hold ${key} to record.`,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle provider argument when already enabled — switch to anthropic
|
||||
if (isCurrentlyEnabled && providerArg === 'anthropic') {
|
||||
const result = updateSettingsForSource('userSettings', {
|
||||
voiceProvider: 'anthropic',
|
||||
})
|
||||
if (result.error) {
|
||||
return {
|
||||
type: 'text' as const,
|
||||
value:
|
||||
'Failed to update settings. Check your settings file for syntax errors.',
|
||||
}
|
||||
}
|
||||
settingsChangeDetector.notifyChange('userSettings')
|
||||
const key = getShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
|
||||
return {
|
||||
type: 'text' as const,
|
||||
value: `Voice mode switched to Anthropic STT. Hold ${key} to record.`,
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle OFF — no checks needed
|
||||
if (isCurrentlyEnabled) {
|
||||
@@ -54,7 +85,10 @@ export const call: LocalCommandCall = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle ON — run pre-flight checks first
|
||||
// Toggle ON — determine provider from argument or default
|
||||
const provider = providerArg === 'doubao' ? 'doubao' : 'anthropic'
|
||||
|
||||
// Run pre-flight checks
|
||||
const { isVoiceStreamAvailable } = await import(
|
||||
'../../services/voiceStreamSTT.js'
|
||||
)
|
||||
@@ -70,8 +104,8 @@ export const call: LocalCommandCall = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
if (!isVoiceStreamAvailable()) {
|
||||
// Check for API key (only for Anthropic backend — Doubao uses its own credentials)
|
||||
if (provider !== 'doubao' && !isVoiceStreamAvailable()) {
|
||||
return {
|
||||
type: 'text' as const,
|
||||
value:
|
||||
@@ -111,8 +145,11 @@ export const call: LocalCommandCall = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed — enable voice
|
||||
const result = updateSettingsForSource('userSettings', { voiceEnabled: true })
|
||||
// All checks passed — enable voice with provider
|
||||
const result = updateSettingsForSource('userSettings', {
|
||||
voiceEnabled: true,
|
||||
...(provider === 'doubao' ? { voiceProvider: 'doubao' } : {}),
|
||||
})
|
||||
if (result.error) {
|
||||
return {
|
||||
type: 'text' as const,
|
||||
@@ -123,28 +160,30 @@ export const call: LocalCommandCall = async () => {
|
||||
settingsChangeDetector.notifyChange('userSettings')
|
||||
logEvent('tengu_voice_toggled', { enabled: true })
|
||||
const key = getShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
|
||||
const stt = normalizeLanguageForSTT(currentSettings.language)
|
||||
const cfg = getGlobalConfig()
|
||||
// Reset the hint counter whenever the resolved STT language changes
|
||||
// (including first-ever enable, where lastLanguage is undefined).
|
||||
const langChanged = cfg.voiceLangHintLastLanguage !== stt.code
|
||||
const priorCount = langChanged ? 0 : (cfg.voiceLangHintShownCount ?? 0)
|
||||
const showHint = !stt.fellBackFrom && priorCount < LANG_HINT_MAX_SHOWS
|
||||
let langNote = ''
|
||||
if (stt.fellBackFrom) {
|
||||
langNote = ` Note: "${stt.fellBackFrom}" is not a supported dictation language; using English. Change it via /config.`
|
||||
} else if (showHint) {
|
||||
langNote = ` Dictation language: ${stt.code} (/config to change).`
|
||||
}
|
||||
if (langChanged || showHint) {
|
||||
saveGlobalConfig(prev => ({
|
||||
...prev,
|
||||
voiceLangHintShownCount: priorCount + (showHint ? 1 : 0),
|
||||
voiceLangHintLastLanguage: stt.code,
|
||||
}))
|
||||
const providerLabel = provider === 'doubao' ? 'Doubao ASR' : 'Anthropic'
|
||||
// Doubao backend handles all languages natively — skip language hints
|
||||
if (provider !== 'doubao') {
|
||||
const stt = normalizeLanguageForSTT(currentSettings.language)
|
||||
const cfg = getGlobalConfig()
|
||||
const langChanged = cfg.voiceLangHintLastLanguage !== stt.code
|
||||
const priorCount = langChanged ? 0 : (cfg.voiceLangHintShownCount ?? 0)
|
||||
const showHint = !stt.fellBackFrom && priorCount < LANG_HINT_MAX_SHOWS
|
||||
if (stt.fellBackFrom) {
|
||||
langNote = ` Note: "${stt.fellBackFrom}" is not a supported dictation language; using English. Change it via /config.`
|
||||
} else if (showHint) {
|
||||
langNote = ` Dictation language: ${stt.code} (/config to change).`
|
||||
}
|
||||
if (langChanged || showHint) {
|
||||
saveGlobalConfig(prev => ({
|
||||
...prev,
|
||||
voiceLangHintShownCount: priorCount + (showHint ? 1 : 0),
|
||||
voiceLangHintLastLanguage: stt.code,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'text' as const,
|
||||
value: `Voice mode enabled. Hold ${key} to record.${langNote}`,
|
||||
value: `Voice mode enabled (${providerLabel}). Hold ${key} to record.${langNote}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ import { usePrStatus } from '../../hooks/usePrStatus.js'
|
||||
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { useTasksV2 } from '../../hooks/useTasksV2.js'
|
||||
import { formatDuration } from '../../utils/format.js'
|
||||
import { formatDuration, formatFileSize } from '../../utils/format.js'
|
||||
import { VoiceWarmupHint } from './VoiceIndicator.js'
|
||||
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'
|
||||
import { useVoiceState } from '../../context/voice.js'
|
||||
@@ -63,6 +63,26 @@ const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}
|
||||
const NULL = () => null
|
||||
const MAX_VOICE_HINT_SHOWS = 3
|
||||
|
||||
const RSS_UPDATE_INTERVAL_MS = 5_000
|
||||
|
||||
type RssState = { text: string; level: 'normal' | 'warning' | 'error' }
|
||||
|
||||
function useRssDisplay(): RssState | null {
|
||||
const [state, setState] = useState<RssState | null>(null)
|
||||
useEffect(() => {
|
||||
function update(): void {
|
||||
const mb = process.memoryUsage().rss / (1024 * 1024)
|
||||
const level = mb >= 1024 ? 'error' : mb >= 512 ? 'warning' : 'normal'
|
||||
const text = formatFileSize(mb * 1024 * 1024)
|
||||
setState(prev => (prev?.text === text ? prev : { text, level }))
|
||||
}
|
||||
update()
|
||||
const timer = setInterval(update, RSS_UPDATE_INTERVAL_MS)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
return state
|
||||
}
|
||||
|
||||
type Props = {
|
||||
exitMessage: {
|
||||
show: boolean
|
||||
@@ -315,6 +335,7 @@ function ModeIndicator({
|
||||
const isKillAgentsConfirmShowing = useAppState(
|
||||
s => s.notifications.current?.key === 'kill-agents-confirm',
|
||||
)
|
||||
const rssState = useRssDisplay()
|
||||
|
||||
// Derive team info from teamContext (no filesystem I/O needed)
|
||||
// Match the same logic as TeamStatus to avoid trailing separator
|
||||
@@ -428,6 +449,18 @@ function ModeIndicator({
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
// RSS memory indicator — always visible
|
||||
...(rssState
|
||||
? [
|
||||
<Text
|
||||
key="rss"
|
||||
dimColor={rssState.level === 'normal'}
|
||||
color={rssState.level === 'error' ? 'error' : rssState.level === 'warning' ? 'warning' : undefined}
|
||||
>
|
||||
{rssState.text}
|
||||
</Text>,
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
// Check if any in-process teammates exist (for hint text cycling)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
permissionModeTitle,
|
||||
permissionModeShortTitle,
|
||||
permissionModeFromString,
|
||||
toExternalPermissionMode,
|
||||
isExternalPermissionMode,
|
||||
@@ -153,7 +154,7 @@ export function Config({
|
||||
const initialLanguage = React.useRef(currentLanguage);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [isSearchMode, setIsSearchMode] = useState(true);
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
const isTerminalFocused = useTerminalFocus();
|
||||
const { rows } = useTerminalSize();
|
||||
// contentHeight is set by Settings.tsx (same value passed to Tabs to fix
|
||||
@@ -167,6 +168,9 @@ export function Config({
|
||||
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
|
||||
const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false));
|
||||
const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled);
|
||||
const currentDefaultPermissionMode = permissionModeFromString(
|
||||
settingsData?.permissions?.defaultMode ?? 'default',
|
||||
);
|
||||
// Show auto in the default-mode dropdown when the user has opted in OR the
|
||||
// config is fully 'enabled' — even if currently circuit-broken ('disabled'),
|
||||
// an opted-in user should still see it in settings (it's a temporary state).
|
||||
@@ -558,27 +562,23 @@ export function Config({
|
||||
{
|
||||
id: 'defaultPermissionMode',
|
||||
label: 'Default permission mode',
|
||||
value: settingsData?.permissions?.defaultMode || 'default',
|
||||
value: currentDefaultPermissionMode,
|
||||
options: (() => {
|
||||
const priorityOrder: PermissionMode[] = ['default', 'plan'];
|
||||
const allModes: readonly PermissionMode[] = feature('TRANSCRIPT_CLASSIFIER')
|
||||
? PERMISSION_MODES
|
||||
: EXTERNAL_PERMISSION_MODES;
|
||||
const excluded: PermissionMode[] = ['bypassPermissions'];
|
||||
if (feature('TRANSCRIPT_CLASSIFIER') && !showAutoInDefaultModePicker) {
|
||||
excluded.push('auto');
|
||||
}
|
||||
return [...priorityOrder, ...allModes.filter(m => !priorityOrder.includes(m) && !excluded.includes(m))];
|
||||
return [...priorityOrder, ...PERMISSION_MODES.filter(m => !priorityOrder.includes(m))];
|
||||
})(),
|
||||
type: 'enum' as const,
|
||||
onChange(mode: string) {
|
||||
const parsedMode = permissionModeFromString(mode);
|
||||
// Internal modes (e.g. auto) are stored directly
|
||||
const validatedMode = isExternalPermissionMode(parsedMode) ? toExternalPermissionMode(parsedMode) : parsedMode;
|
||||
// auto is an internal-only mode — store it directly, don't convert
|
||||
// to its external mapping ('default') which would make it invisible.
|
||||
const validatedMode = parsedMode === 'auto'
|
||||
? parsedMode
|
||||
: (isExternalPermissionMode(parsedMode) ? toExternalPermissionMode(parsedMode) : parsedMode);
|
||||
const result = updateSettingsForSource('userSettings', {
|
||||
permissions: {
|
||||
...settingsData?.permissions,
|
||||
defaultMode: validatedMode as ExternalPermissionMode,
|
||||
defaultMode: validatedMode as (typeof PERMISSION_MODES)[number],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1548,6 +1548,8 @@ export function Config({
|
||||
'scroll:lineUp': () => moveSelection(-1),
|
||||
'scroll:lineDown': () => moveSelection(1),
|
||||
'select:accept': toggleSetting,
|
||||
'select:previousValue': () => toggleSetting(),
|
||||
'select:nextValue': () => toggleSetting(),
|
||||
'settings:search': () => {
|
||||
setIsSearchMode(true);
|
||||
setSearchQuery('');
|
||||
@@ -1936,13 +1938,13 @@ export function Config({
|
||||
|
||||
return (
|
||||
<React.Fragment key={setting.id}>
|
||||
<Box>
|
||||
<Box width="100%">
|
||||
<Box width={44}>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>
|
||||
{isSelected ? figures.pointer : ' '} {setting.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box key={isSelected ? 'selected' : 'unselected'}>
|
||||
<Box flexGrow={1}>
|
||||
{setting.type === 'boolean' ? (
|
||||
<>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{setting.value.toString()}</Text>
|
||||
@@ -1963,7 +1965,7 @@ export function Config({
|
||||
</Text>
|
||||
) : setting.id === 'defaultPermissionMode' ? (
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>
|
||||
{permissionModeTitle(setting.value as PermissionMode)}
|
||||
{permissionModeShortTitle(setting.value as PermissionMode)}
|
||||
</Text>
|
||||
) : setting.id === 'autoUpdatesChannel' && autoUpdaterDisabledReason ? (
|
||||
<Box flexDirection="column">
|
||||
|
||||
@@ -792,26 +792,30 @@ export function useTypeahead({
|
||||
}
|
||||
|
||||
// Determine whether to display the argument hint and command suggestions.
|
||||
// Only consider text up to the cursor — when the cursor is mid-input (e.g.,
|
||||
// user typed "/com" before existing text), text after the cursor shouldn't
|
||||
// affect command matching or argument detection.
|
||||
const commandInput = value.substring(0, effectiveCursorOffset);
|
||||
if (
|
||||
mode === 'prompt' &&
|
||||
isCommandInput(value) &&
|
||||
isCommandInput(commandInput) &&
|
||||
effectiveCursorOffset > 0 &&
|
||||
!hasCommandWithArguments(isAtEndWithWhitespace, value)
|
||||
!hasCommandWithArguments(isAtEndWithWhitespace, commandInput)
|
||||
) {
|
||||
let commandArgumentHint: string | undefined;
|
||||
if (value.length > 1) {
|
||||
if (commandInput.length > 1) {
|
||||
// We have a partial or complete command without arguments
|
||||
// Check if it matches a command exactly and has an argument hint
|
||||
|
||||
// Extract command name: everything after / until the first space (or end)
|
||||
const spaceIndex = value.indexOf(' ');
|
||||
const commandName = spaceIndex === -1 ? value.slice(1) : value.slice(1, spaceIndex);
|
||||
const spaceIndex = commandInput.indexOf(' ');
|
||||
const commandName = spaceIndex === -1 ? commandInput.slice(1) : commandInput.slice(1, spaceIndex);
|
||||
|
||||
// Check if there are real arguments (non-whitespace after the command)
|
||||
const hasRealArguments = spaceIndex !== -1 && value.slice(spaceIndex + 1).trim().length > 0;
|
||||
const hasRealArguments = spaceIndex !== -1 && commandInput.slice(spaceIndex + 1).trim().length > 0;
|
||||
|
||||
// Check if input is exactly "command + single space" (ready for arguments)
|
||||
const hasExactlyOneTrailingSpace = spaceIndex !== -1 && value.length === spaceIndex + 1;
|
||||
const hasExactlyOneTrailingSpace = spaceIndex !== -1 && commandInput.length === spaceIndex + 1;
|
||||
|
||||
// If input has a space after the command, don't show suggestions
|
||||
// This prevents Enter from selecting a different command after Tab completion
|
||||
@@ -826,8 +830,8 @@ export function useTypeahead({
|
||||
commandArgumentHint = exactMatch.argumentHint;
|
||||
}
|
||||
// Priority 2: Progressive hint from argNames (show when trailing space)
|
||||
else if (exactMatch?.type === 'prompt' && exactMatch.argNames?.length && value.endsWith(' ')) {
|
||||
const argsText = value.slice(spaceIndex + 1);
|
||||
else if (exactMatch?.type === 'prompt' && exactMatch.argNames?.length && commandInput.endsWith(' ')) {
|
||||
const argsText = commandInput.slice(spaceIndex + 1);
|
||||
const typedArgs = parseArguments(argsText);
|
||||
commandArgumentHint = generateProgressiveArgumentHint(exactMatch.argNames, typedArgs);
|
||||
}
|
||||
@@ -846,7 +850,7 @@ export function useTypeahead({
|
||||
// (set above when hasExactlyOneTrailingSpace is true)
|
||||
}
|
||||
|
||||
const commandItems = generateCommandSuggestions(value, commands);
|
||||
const commandItems = generateCommandSuggestions(commandInput, commands);
|
||||
setSuggestionsState(() => ({
|
||||
commandArgumentHint,
|
||||
suggestions: commandItems,
|
||||
@@ -867,7 +871,7 @@ export function useTypeahead({
|
||||
// because there may be relevant @ symbol and file suggestions.
|
||||
debouncedFetchFileSuggestions.cancel();
|
||||
clearSuggestions();
|
||||
} else if (isCommandInput(value) && hasCommandWithArguments(isAtEndWithWhitespace, value)) {
|
||||
} else if (isCommandInput(commandInput) && hasCommandWithArguments(isAtEndWithWhitespace, commandInput)) {
|
||||
// If we have a command with arguments (no trailing space), clear any stale hint
|
||||
// This prevents the hint from flashing when transitioning between states
|
||||
setSuggestionsState(prev => (prev.commandArgumentHint ? { ...prev, commandArgumentHint: undefined } : prev));
|
||||
@@ -1030,14 +1034,20 @@ export function useTypeahead({
|
||||
|
||||
if (suggestionType === 'command' && index < suggestions.length) {
|
||||
if (suggestion) {
|
||||
applyCommandSuggestion(
|
||||
suggestion,
|
||||
false, // don't execute on tab
|
||||
commands,
|
||||
onInputChange,
|
||||
setCursorOffset,
|
||||
onSubmit,
|
||||
);
|
||||
// Splice the completed command at the cursor position, preserving
|
||||
// any text after the cursor (e.g., user typed "/com" before existing text).
|
||||
const metadata = suggestion.metadata;
|
||||
if (
|
||||
metadata &&
|
||||
typeof metadata === 'object' &&
|
||||
'name' in metadata &&
|
||||
'type' in metadata
|
||||
) {
|
||||
const commandName = getCommandName(metadata as Command);
|
||||
const replacement = `/${commandName} `;
|
||||
onInputChange(replacement + input.slice(cursorOffset));
|
||||
setCursorOffset(replacement.length);
|
||||
}
|
||||
clearSuggestions();
|
||||
}
|
||||
} else if (suggestionType === 'custom-title' && suggestions.length > 0) {
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
isVoiceStreamAvailable,
|
||||
type VoiceStreamConnection,
|
||||
} from '../services/voiceStreamSTT.js'
|
||||
import {
|
||||
connectDoubaoStream,
|
||||
isDoubaoAvailableSync,
|
||||
} from '../services/doubaoSTT.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { toError } from '../utils/errors.js'
|
||||
import { getSystemLocaleLanguage } from '../utils/intl.js'
|
||||
@@ -27,6 +31,10 @@ import { logError } from '../utils/log.js'
|
||||
import { getInitialSettings } from '../utils/settings/settings.js'
|
||||
import { sleep } from '../utils/sleep.js'
|
||||
|
||||
function isDoubaoProvider(): boolean {
|
||||
return getInitialSettings().voiceProvider === 'doubao'
|
||||
}
|
||||
|
||||
// ─── Language normalization ─────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_STT_LANGUAGE = 'en'
|
||||
@@ -574,7 +582,7 @@ export function useVoice({
|
||||
// stop when it loses focus. This enables a "multi-clauding army"
|
||||
// workflow where voice input follows window focus.
|
||||
useEffect(() => {
|
||||
if (!enabled || !focusMode) {
|
||||
if (!enabled || !focusMode || isDoubaoProvider()) {
|
||||
// Focus mode was disabled while a focus-driven recording was active —
|
||||
// stop the recording so it doesn't linger until the silence timer fires.
|
||||
if (focusTriggeredRef.current && stateRef.current === 'recording') {
|
||||
@@ -778,7 +786,11 @@ export function useVoice({
|
||||
|
||||
const attemptConnect = (keyterms: string[]): void => {
|
||||
const myAttemptGen = attemptGenRef.current
|
||||
void connectVoiceStream(
|
||||
// Select STT backend based on settings.voiceProvider
|
||||
const connectFn = isDoubaoProvider()
|
||||
? (cbs: Parameters<typeof connectDoubaoStream>[0], opts: Parameters<typeof connectDoubaoStream>[1]) => connectDoubaoStream(cbs, opts)
|
||||
: (cbs: Parameters<typeof connectVoiceStream>[0], opts: Parameters<typeof connectVoiceStream>[1]) => connectVoiceStream(cbs, opts)
|
||||
void connectFn(
|
||||
{
|
||||
onTranscript: (text: string, isFinal: boolean) => {
|
||||
if (isStale()) return
|
||||
@@ -1007,7 +1019,12 @@ export function useVoice({
|
||||
})
|
||||
}
|
||||
|
||||
void getVoiceKeyterms().then(attemptConnect)
|
||||
// Doubao backend doesn't use keyterms — skip the async fetch
|
||||
if (isDoubaoProvider()) {
|
||||
attemptConnect([])
|
||||
} else {
|
||||
void getVoiceKeyterms().then(attemptConnect)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hold-to-talk handler ────────────────────────────────────────────
|
||||
@@ -1021,7 +1038,8 @@ export function useVoice({
|
||||
// delay of ~500ms on macOS).
|
||||
const handleKeyEvent = useCallback(
|
||||
(fallbackMs = REPEAT_FALLBACK_MS): void => {
|
||||
if (!enabled || !isVoiceStreamAvailable()) {
|
||||
const sttAvailable = isDoubaoProvider() ? isDoubaoAvailableSync() : isVoiceStreamAvailable()
|
||||
if (!enabled || !sttAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,19 +7,22 @@ import {
|
||||
|
||||
/**
|
||||
* Combines user intent (settings.voiceEnabled) with auth + GB kill-switch.
|
||||
* When using Doubao backend, auth check is skipped (Doubao has its own credentials).
|
||||
* Only the auth half is memoized on authVersion — it's the expensive one
|
||||
* (cold getClaudeAIOAuthTokens memoize → sync `security` spawn, ~60ms/call,
|
||||
* ~180ms total in profile v5 when token refresh cleared the cache mid-session).
|
||||
* GB is a cheap cached-map lookup and stays outside the memo so a mid-session
|
||||
* kill-switch flip still takes effect on the next render.
|
||||
*
|
||||
* authVersion bumps on /login only. Background token refresh leaves it alone
|
||||
* (user is still authed), so the auth memo stays correct without re-eval.
|
||||
*/
|
||||
export function useVoiceEnabled(): boolean {
|
||||
const userIntent = useAppState(s => s.settings.voiceEnabled === true)
|
||||
const provider = useAppState(s => s.settings.voiceProvider)
|
||||
// All hooks must be called unconditionally (Rules of Hooks)
|
||||
const authVersion = useAppState(s => s.authVersion)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const authed = useMemo(hasVoiceAuth, [authVersion])
|
||||
if (provider === 'doubao') {
|
||||
return userIntent && isVoiceGrowthBookEnabled()
|
||||
}
|
||||
return userIntent && authed && isVoiceGrowthBookEnabled()
|
||||
}
|
||||
|
||||
@@ -117,6 +117,9 @@ export const DEFAULT_BINDINGS: KeybindingBlock[] = [
|
||||
j: 'select:next',
|
||||
'ctrl+p': 'select:previous',
|
||||
'ctrl+n': 'select:next',
|
||||
// Cycle enum values left/right (same as left/right arrow in handleKeyDown)
|
||||
left: 'select:previousValue',
|
||||
right: 'select:nextValue',
|
||||
// Toggle/activate the selected setting (space only — enter saves & closes)
|
||||
space: 'select:accept',
|
||||
// Save and close the config panel
|
||||
|
||||
@@ -168,6 +168,8 @@ export const KEYBINDING_ACTIONS = [
|
||||
'settings:search',
|
||||
'settings:retry',
|
||||
'settings:close',
|
||||
'select:previousValue',
|
||||
'select:nextValue',
|
||||
// Voice actions
|
||||
'voice:pushToTalk',
|
||||
] as const
|
||||
|
||||
@@ -1340,7 +1340,10 @@ async function* queryModel(
|
||||
// media stripping) but before Anthropic-specific logic (betas, thinking, caching).
|
||||
if (getAPIProvider() === 'openai') {
|
||||
const { queryModelOpenAI } = await import('./openai/index.js')
|
||||
yield* queryModelOpenAI(messagesForAPI, systemPrompt, filteredTools, signal, options)
|
||||
// OpenAI emulates Anthropic's dynamic tool loading client-side. It needs
|
||||
// the full tool pool so ToolSearchTool can search deferred MCP tools that
|
||||
// were intentionally filtered out of the initial API tool list above.
|
||||
yield* queryModelOpenAI(messagesForAPI, systemPrompt, tools, signal, options)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -196,10 +196,52 @@ async function runQueryModel(
|
||||
// We mock at module level. Bun's mock.module replaces the module for the
|
||||
// entire file, so we configure the stream per-test via a shared variable.
|
||||
let _nextEvents: BetaRawMessageStreamEvent[] = []
|
||||
let _toolSearchEnabled = false
|
||||
|
||||
/** Captured arguments from the last chat.completions.create() call */
|
||||
let _lastCreateArgs: Record<string, any> | null = null
|
||||
|
||||
mock.module('@ant/model-provider', () => ({
|
||||
resolveOpenAIModel: (m: string) => m,
|
||||
adaptOpenAIStreamToAnthropic: (_stream: any, _model: string) =>
|
||||
eventStream(_nextEvents),
|
||||
anthropicMessagesToOpenAI: (messages: any[]) =>
|
||||
messages.map(msg => ({
|
||||
role: msg.message?.role ?? 'user',
|
||||
content: msg.message?.content ?? '',
|
||||
})),
|
||||
anthropicToolsToOpenAI: (tools: any[]) =>
|
||||
tools.map(tool => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description ?? '',
|
||||
parameters: tool.input_schema ?? { type: 'object', properties: {} },
|
||||
},
|
||||
})),
|
||||
anthropicToolChoiceToOpenAI: () => undefined,
|
||||
}))
|
||||
|
||||
mock.module('../../../../utils/envUtils.js', () => ({
|
||||
isEnvTruthy: (value: string | undefined) =>
|
||||
value === '1' || value === 'true' || value === 'yes' || value === 'on',
|
||||
isEnvDefinedFalsy: (value: string | undefined) =>
|
||||
value === '0' || value === 'false' || value === 'no' || value === 'off',
|
||||
}))
|
||||
|
||||
mock.module('../../../../services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: (_key: string, fallback: unknown) =>
|
||||
fallback,
|
||||
}))
|
||||
|
||||
mock.module('src/bootstrap/state.js', () => ({
|
||||
isReplBridgeActive: () => false,
|
||||
}))
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: () => false,
|
||||
}))
|
||||
|
||||
mock.module('../client.js', () => ({
|
||||
getOpenAIClient: () => ({
|
||||
chat: {
|
||||
@@ -252,6 +294,13 @@ mock.module('../../../../utils/context.js', () => ({
|
||||
mock.module('../../../../utils/messages.js', () => ({
|
||||
normalizeMessagesForAPI: (msgs: any) => msgs,
|
||||
normalizeContentFromAPI: (blocks: any[]) => blocks,
|
||||
createUserMessage: (opts: any) => ({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: opts.content },
|
||||
uuid: 'user-uuid',
|
||||
timestamp: new Date().toISOString(),
|
||||
isMeta: opts.isMeta,
|
||||
}),
|
||||
createAssistantAPIErrorMessage: (opts: any) => ({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
@@ -268,8 +317,9 @@ mock.module('../../../../utils/api.js', () => ({
|
||||
}))
|
||||
|
||||
mock.module('../../../../utils/toolSearch.js', () => ({
|
||||
isToolSearchEnabled: async () => false,
|
||||
isToolSearchEnabled: async () => _toolSearchEnabled,
|
||||
extractDiscoveredToolNames: () => new Set(),
|
||||
isDeferredToolsDeltaEnabled: () => false,
|
||||
}))
|
||||
|
||||
mock.module('../../../../tools/ToolSearchTool/prompt.js', () => ({
|
||||
@@ -297,6 +347,16 @@ mock.module('../../../../utils/modelCost.js', () => ({
|
||||
getModelPricingString: () => undefined,
|
||||
}))
|
||||
|
||||
mock.module('../../../../services/langfuse/tracing.js', () => ({
|
||||
recordLLMObservation: () => {},
|
||||
}))
|
||||
|
||||
mock.module('../../../../services/langfuse/convert.js', () => ({
|
||||
convertMessagesToLangfuse: () => [],
|
||||
convertOutputToLangfuse: () => ({}),
|
||||
convertToolsToLangfuse: () => [],
|
||||
}))
|
||||
|
||||
mock.module('../../../../utils/debug.js', () => ({
|
||||
logForDebugging: () => {},
|
||||
logAntError: () => {},
|
||||
@@ -543,3 +603,59 @@ describe('queryModelOpenAI — max_tokens forwarded to request', () => {
|
||||
expect(_lastCreateArgs!.max_tokens).toBe(8192)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryModelOpenAI — deferred MCP tool visibility', () => {
|
||||
test('prepends available deferred MCP tools to OpenAI messages', async () => {
|
||||
_toolSearchEnabled = true
|
||||
_nextEvents = [makeMessageStart(), makeMessageStop()]
|
||||
|
||||
try {
|
||||
const { queryModelOpenAI } = await import('../index.js')
|
||||
const tools: any[] = [
|
||||
{
|
||||
name: 'ToolSearch',
|
||||
isMcp: false,
|
||||
input_schema: { type: 'object', properties: {} },
|
||||
prompt: async () => 'Search deferred tools',
|
||||
},
|
||||
{
|
||||
name: 'mcp__wechat__send_message',
|
||||
isMcp: true,
|
||||
input_schema: { type: 'object', properties: {} },
|
||||
prompt: async () => 'Send a WeChat message',
|
||||
},
|
||||
]
|
||||
|
||||
const options: any = {
|
||||
model: 'test-model',
|
||||
tools: [],
|
||||
agents: [],
|
||||
querySource: 'main_loop',
|
||||
getToolPermissionContext: async () => ({
|
||||
alwaysAllow: [],
|
||||
alwaysDeny: [],
|
||||
needsPermission: [],
|
||||
mode: 'default',
|
||||
isBypassingPermissions: false,
|
||||
}),
|
||||
}
|
||||
|
||||
for await (const _item of queryModelOpenAI(
|
||||
[],
|
||||
{ type: 'text', text: '' } as any,
|
||||
tools as any,
|
||||
new AbortController().signal,
|
||||
options,
|
||||
)) {
|
||||
// Exhaust generator so request body is built.
|
||||
}
|
||||
|
||||
expect(_lastCreateArgs).not.toBeNull()
|
||||
expect(JSON.stringify(_lastCreateArgs!.messages)).toContain(
|
||||
'<available-deferred-tools>\\nmcp__wechat__send_message\\n</available-deferred-tools>',
|
||||
)
|
||||
} finally {
|
||||
_toolSearchEnabled = false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
StreamEvent,
|
||||
SystemAPIErrorMessage,
|
||||
AssistantMessage,
|
||||
UserMessage,
|
||||
} from '../../../types/message.js'
|
||||
import type { AgentId } from '../../../types/ids.js'
|
||||
import type { Tools } from '../../../Tool.js'
|
||||
@@ -32,18 +33,58 @@ import type { Options } from '../claude.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import {
|
||||
createAssistantAPIErrorMessage,
|
||||
createUserMessage,
|
||||
normalizeContentFromAPI,
|
||||
} from '../../../utils/messages.js'
|
||||
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
|
||||
import {
|
||||
isToolSearchEnabled,
|
||||
extractDiscoveredToolNames,
|
||||
isDeferredToolsDeltaEnabled,
|
||||
} from '../../../utils/toolSearch.js'
|
||||
import {
|
||||
formatDeferredToolLine,
|
||||
isDeferredTool,
|
||||
TOOL_SEARCH_TOOL_NAME,
|
||||
} from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
|
||||
|
||||
/**
|
||||
* Mirrors the Anthropic request path's deferred-tool announcement for OpenAI.
|
||||
*
|
||||
* OpenAI-compatible endpoints cannot consume Anthropic's `defer_loading` or
|
||||
* `tool_reference` beta payloads directly, so the model needs the same textual
|
||||
* list of deferred MCP tool names that Anthropic receives before it can ask
|
||||
* ToolSearchTool to load their full schemas.
|
||||
*/
|
||||
function prependDeferredToolListIfNeeded(
|
||||
messages: (AssistantMessage | UserMessage)[],
|
||||
tools: Tools,
|
||||
deferredToolNames: Set<string>,
|
||||
useToolSearch: boolean,
|
||||
): (AssistantMessage | UserMessage)[] {
|
||||
if (!useToolSearch || isDeferredToolsDeltaEnabled()) return messages
|
||||
|
||||
const deferredToolList = tools
|
||||
.filter(tool => deferredToolNames.has(tool.name))
|
||||
.map(formatDeferredToolLine)
|
||||
.sort()
|
||||
.join('\n')
|
||||
|
||||
if (!deferredToolList) return messages
|
||||
|
||||
return [
|
||||
createUserMessage({
|
||||
content: `<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>`,
|
||||
isMeta: true,
|
||||
}),
|
||||
...messages,
|
||||
]
|
||||
}
|
||||
|
||||
function isOpenAIConvertibleMessage(msg: Message): msg is AssistantMessage | UserMessage {
|
||||
return msg.type === 'assistant' || msg.type === 'user'
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the final AssistantMessage (and optional max_tokens error) from
|
||||
* accumulated stream state. Extracted to avoid duplication between the
|
||||
@@ -176,9 +217,18 @@ export async function* queryModelOpenAI(
|
||||
|
||||
// 8. Convert messages and tools to OpenAI format
|
||||
const enableThinking = isOpenAIThinkingEnabled(openaiModel)
|
||||
const openaiMessages = anthropicMessagesToOpenAI(messagesForAPI, systemPrompt, {
|
||||
enableThinking,
|
||||
})
|
||||
const openAIConvertibleMessages = messagesForAPI.filter(isOpenAIConvertibleMessage)
|
||||
const messagesWithDeferredToolList = prependDeferredToolListIfNeeded(
|
||||
openAIConvertibleMessages,
|
||||
tools,
|
||||
deferredToolNames,
|
||||
useToolSearch,
|
||||
)
|
||||
const openaiMessages = anthropicMessagesToOpenAI(
|
||||
messagesWithDeferredToolList,
|
||||
systemPrompt,
|
||||
{ enableThinking },
|
||||
)
|
||||
const openaiTools = anthropicToolsToOpenAI(standardTools)
|
||||
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
|
||||
|
||||
@@ -356,7 +406,7 @@ export async function* queryModelOpenAI(
|
||||
recordLLMObservation(options.langfuseTrace ?? null, {
|
||||
model: openaiModel,
|
||||
provider: 'openai',
|
||||
input: convertMessagesToLangfuse(messagesForAPI, systemPrompt),
|
||||
input: convertMessagesToLangfuse(openaiMessages),
|
||||
output: convertOutputToLangfuse(collectedMessages),
|
||||
usage: {
|
||||
input_tokens: usage.input_tokens,
|
||||
|
||||
230
src/services/doubaoSTT.ts
Normal file
230
src/services/doubaoSTT.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
// Doubao (豆包) ASR speech-to-text adapter for voice mode.
|
||||
//
|
||||
// Wraps the doubaoime-asr npm package to expose the same interface as
|
||||
// voiceStreamSTT.ts. The doubao backend uses an AsyncGenerator-based
|
||||
// streaming protocol internally; this adapter bridges it to the
|
||||
// send/finalize/close pattern used by useVoice.ts.
|
||||
|
||||
import { homedir } from 'node:os'
|
||||
import type { ASRResponse } from 'doubaoime-asr'
|
||||
import type { FinalizeSource, VoiceStreamCallbacks, VoiceStreamConnection } from './voiceStreamSTT.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
|
||||
// Re-export FinalizeSource so useVoice can import from either module
|
||||
export type { FinalizeSource } from './voiceStreamSTT.js'
|
||||
|
||||
// Maximum time to wait for the generator to finish after end-of-stream signal.
|
||||
const FINALIZE_SAFETY_TIMEOUT_MS = 5_000
|
||||
|
||||
// ─── AsyncIterable audio queue ─────────────────────────────────────────
|
||||
|
||||
// A push-based queue that implements AsyncIterable<Uint8Array>.
|
||||
// send() pushes chunks; push(null) signals end-of-stream.
|
||||
class AudioChunkQueue {
|
||||
private chunks: (Uint8Array | null)[] = []
|
||||
private waiting: ((result: IteratorResult<Uint8Array>) => void) | null = null
|
||||
private done = false
|
||||
|
||||
push(chunk: Uint8Array | null): void {
|
||||
if (this.done) return
|
||||
if (chunk === null) {
|
||||
this.done = true
|
||||
if (this.waiting) {
|
||||
const resolve = this.waiting
|
||||
this.waiting = null
|
||||
resolve({ value: undefined, done: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (this.waiting) {
|
||||
const resolve = this.waiting
|
||||
this.waiting = null
|
||||
resolve({ value: chunk, done: false })
|
||||
} else {
|
||||
this.chunks.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.done = true
|
||||
this.chunks.length = 0
|
||||
if (this.waiting) {
|
||||
const resolve = this.waiting
|
||||
this.waiting = null
|
||||
resolve({ value: undefined, done: true })
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<Uint8Array> {
|
||||
return {
|
||||
next: async (): Promise<IteratorResult<Uint8Array>> => {
|
||||
if (this.chunks.length > 0) {
|
||||
const chunk = this.chunks.shift()!
|
||||
return { value: chunk, done: false }
|
||||
}
|
||||
if (this.done) {
|
||||
return { value: undefined, done: true }
|
||||
}
|
||||
return new Promise<IteratorResult<Uint8Array>>((resolve) => {
|
||||
this.waiting = resolve
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Availability ────────────────────────────────────────────────────────
|
||||
|
||||
let doubaoAvailable: boolean | null = null
|
||||
|
||||
export async function isDoubaoAvailable(): Promise<boolean> {
|
||||
if (doubaoAvailable !== null) return doubaoAvailable
|
||||
try {
|
||||
await import('doubaoime-asr')
|
||||
doubaoAvailable = true
|
||||
} catch {
|
||||
doubaoAvailable = false
|
||||
}
|
||||
return doubaoAvailable
|
||||
}
|
||||
|
||||
// Synchronous check — returns cached result or optimistic true when
|
||||
// VOICE_PROVIDER=doubao is set and no cached result exists yet.
|
||||
// The actual import happens in connectDoubaoStream which reports errors.
|
||||
export function isDoubaoAvailableSync(): boolean {
|
||||
if (doubaoAvailable !== null) return doubaoAvailable
|
||||
return true
|
||||
}
|
||||
|
||||
// ─── Connection ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function connectDoubaoStream(
|
||||
callbacks: VoiceStreamCallbacks,
|
||||
_options?: { language?: string },
|
||||
): Promise<VoiceStreamConnection | null> {
|
||||
let doubaoAsr: typeof import('doubaoime-asr')
|
||||
try {
|
||||
doubaoAsr = await import('doubaoime-asr')
|
||||
} catch {
|
||||
logError(new Error('[doubao-asr] Failed to import doubaoime-asr package'))
|
||||
callbacks.onError('doubaoime-asr package is not installed. Install it with: bun add doubaoime-asr', { fatal: true })
|
||||
return null
|
||||
}
|
||||
|
||||
const { transcribeRealtime, ASRConfig, ResponseType } = doubaoAsr
|
||||
|
||||
const queue = new AudioChunkQueue()
|
||||
let finalized = false
|
||||
|
||||
// Resolve handle for finalize() promise — wrapped in an object to avoid
|
||||
// TypeScript closure-scope type narrowing issues (TS2349 "not callable").
|
||||
const finalizeHandle: { resolve: ((source: FinalizeSource) => void) | null } = { resolve: null }
|
||||
|
||||
const connection: VoiceStreamConnection = {
|
||||
send(audioChunk: Buffer): void {
|
||||
if (finalized) return
|
||||
queue.push(new Uint8Array(audioChunk.buffer, audioChunk.byteOffset, audioChunk.byteLength))
|
||||
},
|
||||
finalize(): Promise<FinalizeSource> {
|
||||
if (finalized) return Promise.resolve<FinalizeSource>('ws_already_closed')
|
||||
finalized = true
|
||||
queue.push(null) // signal end-of-stream to the generator
|
||||
// Doubao returns FINAL_RESULT during recording — by the time the user
|
||||
// releases the key, all transcripts are already in accumulatedRef.
|
||||
// Resolve immediately so the UI skips the 'processing' state and goes
|
||||
// straight to displaying the result.
|
||||
logForDebugging('[doubao-asr] Finalize — resolving immediately')
|
||||
return Promise.resolve<FinalizeSource>('post_closestream_endpoint')
|
||||
},
|
||||
close(): void {
|
||||
finalized = true
|
||||
queue.abort()
|
||||
const r = finalizeHandle.resolve
|
||||
finalizeHandle.resolve = null
|
||||
if (r) r('ws_close')
|
||||
callbacks.onClose()
|
||||
},
|
||||
isConnected(): boolean {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// Start the ASR session in the background
|
||||
const config = new ASRConfig({ credentialPath: `${homedir()}/.claude/tts/doubao/credentials.json` })
|
||||
|
||||
// Ensure credentials are initialized (may auto-generate)
|
||||
try {
|
||||
await config.ensureCredentials()
|
||||
} catch (err) {
|
||||
logError(new Error(`[doubao-asr] Credential initialization failed: ${String(err)}`))
|
||||
callbacks.onError(`Doubao ASR 凭证初始化失败: ${String(err)}`, { fatal: true })
|
||||
return null
|
||||
}
|
||||
|
||||
// Fire onReady immediately — unlike the Anthropic WebSocket which needs to
|
||||
// wait for a handshake, the doubao backend accepts audio through the queue
|
||||
// and handles connection internally. The caller (useVoice.ts) needs onReady
|
||||
// to fire before it will route audio chunks via connection.send().
|
||||
logForDebugging('[doubao-asr] Firing onReady immediately')
|
||||
callbacks.onReady(connection)
|
||||
|
||||
// Consume the AsyncGenerator in the background
|
||||
void (async () => {
|
||||
try {
|
||||
const audioSource: AsyncIterable<Uint8Array> = queue
|
||||
const gen: AsyncGenerator<ASRResponse> = transcribeRealtime(audioSource, { config })
|
||||
|
||||
for await (const resp of gen) {
|
||||
if (finalized && resp.type !== ResponseType.FINAL_RESULT && resp.type !== ResponseType.SESSION_FINISHED) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch (resp.type) {
|
||||
case ResponseType.SESSION_STARTED:
|
||||
logForDebugging('[doubao-asr] Session started')
|
||||
break
|
||||
case ResponseType.VAD_START:
|
||||
logForDebugging('[doubao-asr] VAD detected speech start')
|
||||
break
|
||||
case ResponseType.INTERIM_RESULT:
|
||||
if (resp.text) {
|
||||
callbacks.onTranscript(resp.text, false)
|
||||
}
|
||||
break
|
||||
case ResponseType.FINAL_RESULT:
|
||||
if (resp.text) {
|
||||
callbacks.onTranscript(resp.text, true)
|
||||
}
|
||||
break
|
||||
case ResponseType.ERROR:
|
||||
logError(new Error(`[doubao-asr] Error: ${resp.errorMsg}`))
|
||||
if (!finalized) {
|
||||
callbacks.onError(resp.errorMsg || 'Doubao ASR 识别错误')
|
||||
}
|
||||
break
|
||||
case ResponseType.SESSION_FINISHED:
|
||||
logForDebugging('[doubao-asr] Session finished')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Generator exhausted naturally
|
||||
const r = finalizeHandle.resolve
|
||||
finalizeHandle.resolve = null
|
||||
if (r) r('post_closestream_endpoint')
|
||||
} catch (err) {
|
||||
logError(new Error(`[doubao-asr] Stream error: ${String(err)}`))
|
||||
if (!finalized) {
|
||||
callbacks.onError(`Doubao ASR 连接错误: ${String(err)}`)
|
||||
}
|
||||
const r2 = finalizeHandle.resolve
|
||||
finalizeHandle.resolve = null
|
||||
if (r2) r2('ws_close')
|
||||
}
|
||||
})()
|
||||
|
||||
return connection
|
||||
}
|
||||
@@ -184,6 +184,101 @@ describe('Langfuse integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertMessagesToLangfuse', () => {
|
||||
test('preserves OpenAI-style messages including deferred tool announcements', async () => {
|
||||
const { convertMessagesToLangfuse } = await import('../convert.js')
|
||||
const result = convertMessagesToLangfuse([
|
||||
{
|
||||
role: 'system',
|
||||
content: 'system prompt',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'<available-deferred-tools>\nmcp__wechat__send_message\n</available-deferred-tools>',
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{ role: 'system', content: 'system prompt' },
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'<available-deferred-tools>\nmcp__wechat__send_message\n</available-deferred-tools>',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('preserves roles for OpenAI-style array content messages', async () => {
|
||||
const { convertMessagesToLangfuse } = await import('../convert.js')
|
||||
const result = convertMessagesToLangfuse([
|
||||
{
|
||||
role: 'system',
|
||||
content: [{ type: 'text', text: 'system reminder' }],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_1',
|
||||
content: [{ type: 'text', text: 'tool output' }],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{ role: 'system', content: 'system reminder' },
|
||||
{ role: 'tool', content: 'tool output', tool_call_id: 'call_1' },
|
||||
])
|
||||
})
|
||||
|
||||
test('merges assistant tool calls from OpenAI-style array content', async () => {
|
||||
const { convertMessagesToLangfuse } = await import('../convert.js')
|
||||
// Content part with embedded tool_calls is non-standard; cast for defensive test
|
||||
const result = convertMessagesToLangfuse([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'calling a tool',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_from_part',
|
||||
type: 'function',
|
||||
function: { name: 'part_tool', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_from_message',
|
||||
type: 'function',
|
||||
function: { name: 'message_tool', arguments: '{"ok":true}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'calling a tool',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_from_message',
|
||||
type: 'function',
|
||||
function: { name: 'message_tool', arguments: '{"ok":true}' },
|
||||
},
|
||||
{
|
||||
id: 'call_from_part',
|
||||
type: 'function',
|
||||
function: { name: 'part_tool', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// ── client tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isLangfuseEnabled', () => {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* - tool_result blocks → separate { role: 'tool' } messages
|
||||
*/
|
||||
|
||||
import type { Message, AssistantMessage, UserMessage } from 'src/types/message.js'
|
||||
import type { AssistantMessage, UserMessage } from 'src/types/message.js'
|
||||
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions/completions.mjs'
|
||||
|
||||
type LangfuseContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
@@ -30,6 +31,61 @@ type LangfuseChatMessage = {
|
||||
tool_call_id?: string
|
||||
}
|
||||
|
||||
function isLangfuseRole(value: unknown): value is LangfuseChatMessage['role'] {
|
||||
switch (value) {
|
||||
case 'user':
|
||||
case 'assistant':
|
||||
case 'system':
|
||||
case 'tool':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isLangfuseToolCall(value: unknown): value is LangfuseToolCall {
|
||||
if (!isRecord(value)) return false
|
||||
const fn = value.function
|
||||
return (
|
||||
typeof value.id === 'string' &&
|
||||
value.type === 'function' &&
|
||||
isRecord(fn) &&
|
||||
typeof fn.name === 'string' &&
|
||||
typeof fn.arguments === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function getToolCalls(value: unknown): LangfuseToolCall[] {
|
||||
return Array.isArray(value) ? value.filter(isLangfuseToolCall) : []
|
||||
}
|
||||
|
||||
function getContentToolCalls(content: unknown[]): LangfuseToolCall[] {
|
||||
return content.flatMap(block =>
|
||||
isRecord(block) ? getToolCalls(block.tool_calls) : [],
|
||||
)
|
||||
}
|
||||
|
||||
function mergeToolCalls(
|
||||
...groups: readonly LangfuseToolCall[][]
|
||||
): LangfuseToolCall[] {
|
||||
const merged = new Map<string, LangfuseToolCall>()
|
||||
for (const toolCall of groups.flat()) {
|
||||
const key = toolCall.id || `${toolCall.function.name}:${toolCall.function.arguments}`
|
||||
if (!merged.has(key)) merged.set(key, toolCall)
|
||||
}
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
/** Union of all message formats accepted by Langfuse converters. */
|
||||
type LangfuseInputMessage =
|
||||
| UserMessage
|
||||
| AssistantMessage
|
||||
| ChatCompletionMessageParam
|
||||
|
||||
/** Normalize a content block into a LangfuseContentPart (non-tool_use, non-tool_result) */
|
||||
function toContentPart(block: Record<string, unknown>): LangfuseContentPart | null {
|
||||
const type = block.type as string | undefined
|
||||
@@ -121,15 +177,15 @@ function collapseContent(parts: LangfuseContentPart[]): string | LangfuseContent
|
||||
return parts
|
||||
}
|
||||
|
||||
function toRole(msg: Message): 'user' | 'assistant' | 'system' {
|
||||
function toRoleFromWrappedMessage(msg: Record<string, unknown>): 'user' | 'assistant' | 'system' {
|
||||
if (msg.type === 'assistant') return 'assistant'
|
||||
if (msg.type === 'system') return 'system'
|
||||
return 'user'
|
||||
}
|
||||
|
||||
/** Convert messagesForAPI (UserMessage | AssistantMessage)[] → Langfuse input format */
|
||||
/** Convert internal or OpenAI-style messages → Langfuse input format */
|
||||
export function convertMessagesToLangfuse(
|
||||
messages: (UserMessage | AssistantMessage)[],
|
||||
messages: readonly LangfuseInputMessage[],
|
||||
systemPrompt?: readonly string[],
|
||||
): LangfuseChatMessage[] {
|
||||
const result: LangfuseChatMessage[] = []
|
||||
@@ -139,18 +195,34 @@ export function convertMessagesToLangfuse(
|
||||
}
|
||||
}
|
||||
for (const msg of messages) {
|
||||
const inner = msg.message
|
||||
if (!inner) continue
|
||||
const role = (inner.role as 'user' | 'assistant' | undefined) ?? toRole(msg)
|
||||
if (!isRecord(msg)) continue
|
||||
const wrappedMessage = msg.message
|
||||
const isWrappedMessage = isRecord(wrappedMessage)
|
||||
const inner = isWrappedMessage ? wrappedMessage : msg
|
||||
const role =
|
||||
isLangfuseRole(inner.role) ? inner.role : isWrappedMessage ? toRoleFromWrappedMessage(msg) : 'user'
|
||||
const rawContent = inner.content
|
||||
if (typeof rawContent === 'string' || !Array.isArray(rawContent)) {
|
||||
result.push({ role, content: String(rawContent ?? '') })
|
||||
const toolCalls = getToolCalls(inner.tool_calls)
|
||||
result.push({
|
||||
role,
|
||||
content: String(rawContent ?? ''),
|
||||
...('tool_call_id' in inner && typeof inner.tool_call_id === 'string'
|
||||
? { tool_call_id: inner.tool_call_id }
|
||||
: {}),
|
||||
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
// Extract tool_use → tool_calls at message level
|
||||
const { tool_calls, rest } = extractToolCalls(rawContent)
|
||||
const allToolCalls = mergeToolCalls(
|
||||
tool_calls,
|
||||
getToolCalls(inner.tool_calls),
|
||||
getContentToolCalls(rest),
|
||||
)
|
||||
const parts = rest
|
||||
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
|
||||
.map(b => toContentPart(b))
|
||||
@@ -158,7 +230,7 @@ export function convertMessagesToLangfuse(
|
||||
result.push({
|
||||
role: 'assistant',
|
||||
content: collapseContent(parts),
|
||||
...(tool_calls.length > 0 && { tool_calls }),
|
||||
...(allToolCalls.length > 0 && { tool_calls: allToolCalls }),
|
||||
})
|
||||
} else {
|
||||
// User messages: extract tool_result → separate tool messages
|
||||
@@ -168,7 +240,18 @@ export function convertMessagesToLangfuse(
|
||||
.map(b => toContentPart(b))
|
||||
.filter((p): p is LangfuseContentPart => p !== null)
|
||||
if (parts.length > 0 || toolMessages.length === 0) {
|
||||
result.push({ role: 'user', content: collapseContent(parts) })
|
||||
const toolCalls = mergeToolCalls(
|
||||
getToolCalls(inner.tool_calls),
|
||||
getContentToolCalls(rest),
|
||||
)
|
||||
result.push({
|
||||
role,
|
||||
content: collapseContent(parts),
|
||||
...('tool_call_id' in inner && typeof inner.tool_call_id === 'string'
|
||||
? { tool_call_id: inner.tool_call_id }
|
||||
: {}),
|
||||
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||
})
|
||||
}
|
||||
result.push(...toolMessages)
|
||||
}
|
||||
|
||||
@@ -30,9 +30,11 @@ export type PermissionMode = InternalPermissionMode
|
||||
|
||||
// Runtime validation set: modes that are user-addressable (settings.json
|
||||
// defaultMode, --permission-mode CLI flag, conversation recovery).
|
||||
// 'auto' is always available — when TRANSCRIPT_CLASSIFIER is off, the
|
||||
// classifier is unavailable and auto mode falls back to prompting.
|
||||
export const INTERNAL_PERMISSION_MODES = [
|
||||
...EXTERNAL_PERMISSION_MODES,
|
||||
...(feature('TRANSCRIPT_CLASSIFIER') ? (['auto'] as const) : ([] as const)),
|
||||
'auto' as const,
|
||||
] as const satisfies readonly PermissionMode[]
|
||||
|
||||
export const PERMISSION_MODES = INTERNAL_PERMISSION_MODES
|
||||
|
||||
@@ -819,6 +819,10 @@ export async function getAttachments(
|
||||
!options?.skipSkillDiscovery
|
||||
? [
|
||||
maybe('skill_discovery', async () => {
|
||||
if (suppressNextDiscovery) {
|
||||
suppressNextDiscovery = false
|
||||
return []
|
||||
}
|
||||
const result = await skillSearchModules.prefetch.getTurnZeroSkillDiscovery(
|
||||
input,
|
||||
messages ?? [],
|
||||
@@ -2638,6 +2642,7 @@ const sentSkillNames = new Map<string, Set<string>>()
|
||||
export function resetSentSkillNames(): void {
|
||||
sentSkillNames.clear()
|
||||
suppressNext = false
|
||||
suppressNextDiscovery = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2661,6 +2666,18 @@ export function suppressNextSkillListing(): void {
|
||||
}
|
||||
let suppressNext = false
|
||||
|
||||
/**
|
||||
* Suppress the next skill-discovery injection on resume. Same rationale as
|
||||
* suppressNextSkillListing: skill_discovery attachments are not persisted to
|
||||
* transcript for non-ant users, so the prior process's discovery result is
|
||||
* already in the conversation history the model sees. Re-generating it would
|
||||
* inject duplicate content and bust the prompt cache prefix.
|
||||
*/
|
||||
export function suppressNextSkillDiscovery(): void {
|
||||
suppressNextDiscovery = true
|
||||
}
|
||||
let suppressNextDiscovery = false
|
||||
|
||||
// When skill-search is enabled and the filtered (bundled + MCP) listing exceeds
|
||||
// this count, fall back to bundled-only. Protects MCP-heavy users (100+ servers)
|
||||
// from truncation while keeping the turn-0 guarantee for typical setups.
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
NormalizedUserMessage,
|
||||
} from '../types/message.js'
|
||||
import { PERMISSION_MODES } from '../types/permissions.js'
|
||||
import { suppressNextSkillListing } from './attachments.js'
|
||||
import { suppressNextSkillDiscovery, suppressNextSkillListing } from './attachments.js'
|
||||
import {
|
||||
copyFileHistoryForResume,
|
||||
type FileHistorySnapshot,
|
||||
@@ -403,6 +403,16 @@ export function restoreSkillStateFromMessages(messages: Message[]): void {
|
||||
suppressNextSkillListing()
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditionally suppress skill_listing and skill_discovery on resume.
|
||||
// Attachments are not persisted to transcript for non-ant users
|
||||
// (isLoggableMessage filters them out), so the per-type checks above may
|
||||
// never find them even though the prior process already injected the content
|
||||
// into the conversation via <system-reminder> blocks. Without this, every
|
||||
// resume re-injects ~1K tokens of duplicate content and busts the Anthropic
|
||||
// prompt cache prefix (which requires 100% byte-identical segments).
|
||||
suppressNextSkillListing()
|
||||
suppressNextSkillDiscovery()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,7 +36,8 @@ export function modelSupportsEffort(model: string): boolean {
|
||||
if (
|
||||
m.includes('opus-4-7') ||
|
||||
m.includes('opus-4-6') ||
|
||||
m.includes('sonnet-4-6')
|
||||
m.includes('sonnet-4-6') ||
|
||||
m.includes('deepseek-v4-pro')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
@@ -57,11 +58,16 @@ export function modelSupportsEffort(model: string): boolean {
|
||||
|
||||
// @[MODEL LAUNCH]: Add the new model to the allowlist if it supports 'max' effort.
|
||||
// Per API docs, 'max' is Opus 4.6/4.7 only for public models — other models return an error.
|
||||
// However, DeepSeek V4 Pro also supports max effort when using Anthropic-compatible API.
|
||||
export function modelSupportsMaxEffort(model: string): boolean {
|
||||
const supported3P = get3PModelCapabilityOverride(model, 'max_effort')
|
||||
if (supported3P !== undefined) {
|
||||
return supported3P
|
||||
}
|
||||
// Support DeepSeek V4 Pro specifically (Anthropic-compatible API)
|
||||
if (model.toLowerCase().includes('deepseek-v4-pro')) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
model.toLowerCase().includes('opus-4-7') ||
|
||||
model.toLowerCase().includes('opus-4-6')
|
||||
@@ -267,7 +273,7 @@ export function getEffortLevelDescription(level: EffortLevel): string {
|
||||
case 'xhigh':
|
||||
return 'Extended reasoning beyond high, short of max (Opus 4.7 only)'
|
||||
case 'max':
|
||||
return 'Maximum capability with deepest reasoning (Opus 4.6/4.7 only)'
|
||||
return 'Maximum capability with deepest reasoning (Opus 4.6/4.7/DeepSeek V4 Pro)'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const PERMISSION_MODE_CONFIG: Partial<
|
||||
external: 'acceptEdits',
|
||||
},
|
||||
bypassPermissions: {
|
||||
title: 'Bypass Permissions',
|
||||
title: 'Bypass',
|
||||
shortTitle: 'Bypass',
|
||||
symbol: '⏵⏵',
|
||||
color: 'error',
|
||||
@@ -77,17 +77,13 @@ const PERMISSION_MODE_CONFIG: Partial<
|
||||
color: 'error',
|
||||
external: 'dontAsk',
|
||||
},
|
||||
...(feature('TRANSCRIPT_CLASSIFIER')
|
||||
? {
|
||||
auto: {
|
||||
title: 'Auto mode',
|
||||
shortTitle: 'Auto',
|
||||
symbol: '⏵⏵',
|
||||
color: 'warning' as ModeColorKey,
|
||||
external: 'default' as ExternalPermissionMode,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
auto: {
|
||||
title: 'Auto',
|
||||
shortTitle: 'Auto',
|
||||
symbol: '⏵⏵',
|
||||
color: 'warning' as ModeColorKey,
|
||||
external: 'default' as ExternalPermissionMode,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("permissionModeTitle", () => {
|
||||
expect(permissionModeTitle("default")).toBe("Default");
|
||||
expect(permissionModeTitle("plan")).toBe("Plan Mode");
|
||||
expect(permissionModeTitle("acceptEdits")).toBe("Accept edits");
|
||||
expect(permissionModeTitle("bypassPermissions")).toBe("Bypass Permissions");
|
||||
expect(permissionModeTitle("bypassPermissions")).toBe("Bypass");
|
||||
expect(permissionModeTitle("dontAsk")).toBe("Don't Ask");
|
||||
});
|
||||
|
||||
|
||||
@@ -16,10 +16,24 @@ import { countCharInString } from './stringUtils.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
// we use node:path.join instead of node:url.resolve because the former doesn't encode spaces
|
||||
const __dirname = path.join(
|
||||
__filename,
|
||||
process.env.NODE_ENV === 'test' ? '../../../' : '../',
|
||||
)
|
||||
// In dev mode: __filename = <root>/src/utils/ripgrep.ts → __dirname = <root>/src/utils/
|
||||
// In built mode (bun): __filename = <root>/dist/chunk-xxx.js → need <root>/dist/
|
||||
// In built mode (vite): __filename = <root>/dist/chunks/chunk-xxx.js → need <root>/dist/
|
||||
// Both built modes: the dist root is at <root>/dist/ where dist/vendor/ripgrep/ lives.
|
||||
const __dirname = (() => {
|
||||
const dir = path.dirname(__filename)
|
||||
// Test mode: from src/utils/ → project root
|
||||
if (process.env.NODE_ENV === 'test') return path.resolve(dir, '../../../')
|
||||
// Check if we're inside a dist directory at any depth
|
||||
// (dist/ or dist/chunks/) — vendor lives at <dist-root>/vendor/ripgrep/
|
||||
const parts = dir.split(path.sep)
|
||||
const distIdx = parts.lastIndexOf('dist')
|
||||
if (distIdx !== -1) {
|
||||
return parts.slice(0, distIdx + 1).join(path.sep)
|
||||
}
|
||||
// Dev mode: from src/utils/ → src/utils/
|
||||
return dir
|
||||
})()
|
||||
|
||||
type RipgrepConfig = {
|
||||
mode: 'system' | 'builtin' | 'embedded'
|
||||
|
||||
@@ -57,11 +57,7 @@ export const PermissionsSchema = lazySchema(() =>
|
||||
'List of permission rules that should always prompt for confirmation',
|
||||
),
|
||||
defaultMode: z
|
||||
.enum(
|
||||
feature('TRANSCRIPT_CLASSIFIER')
|
||||
? PERMISSION_MODES
|
||||
: EXTERNAL_PERMISSION_MODES,
|
||||
)
|
||||
.enum(PERMISSION_MODES)
|
||||
.optional()
|
||||
.describe('Default permission mode when Claude Code needs access'),
|
||||
disableBypassPermissionsMode: z
|
||||
@@ -880,6 +876,10 @@ export const SettingsSchema = lazySchema(() =>
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Enable voice mode (hold-to-talk dictation)'),
|
||||
voiceProvider: z
|
||||
.enum(['anthropic', 'doubao'])
|
||||
.optional()
|
||||
.describe('Voice STT backend: "anthropic" (default) or "doubao" (Doubao ASR)'),
|
||||
}
|
||||
: {}),
|
||||
...(feature('KAIROS')
|
||||
|
||||
373
src/utils/suggestions/__tests__/commandSuggestions.test.ts
Normal file
373
src/utils/suggestions/__tests__/commandSuggestions.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type Command,
|
||||
getCommandName,
|
||||
} from '../../../commands.js'
|
||||
import type { SuggestionItem } from '../../../components/PromptInput/PromptInputFooterSuggestions.js'
|
||||
import {
|
||||
applyCommandSuggestion,
|
||||
findMidInputSlashCommand,
|
||||
formatCommand,
|
||||
generateCommandSuggestions,
|
||||
getBestCommandMatch,
|
||||
hasCommandArgs,
|
||||
isCommandInput,
|
||||
} from '../commandSuggestions.js'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function makeCommand(name: string, opts?: Partial<Command>): Command {
|
||||
return {
|
||||
name,
|
||||
description: opts?.description ?? `${name} command`,
|
||||
type: 'local',
|
||||
handler: () => {},
|
||||
...opts,
|
||||
} as unknown as Command
|
||||
}
|
||||
|
||||
function makePromptCommand(
|
||||
name: string,
|
||||
opts?: Partial<Command>,
|
||||
): Command {
|
||||
return {
|
||||
name,
|
||||
description: opts?.description ?? `${name} skill`,
|
||||
type: 'prompt',
|
||||
handler: () => {},
|
||||
source: 'userSettings',
|
||||
...opts,
|
||||
} as unknown as Command
|
||||
}
|
||||
|
||||
// ─── isCommandInput ───────────────────────────────────────────────────
|
||||
|
||||
describe('isCommandInput', () => {
|
||||
test('returns true for slash-prefixed input', () => {
|
||||
expect(isCommandInput('/commit')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for non-slash input', () => {
|
||||
expect(isCommandInput('commit')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true for just a slash', () => {
|
||||
expect(isCommandInput('/')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── hasCommandArgs ───────────────────────────────────────────────────
|
||||
|
||||
describe('hasCommandArgs', () => {
|
||||
test('returns false when no space in input', () => {
|
||||
expect(hasCommandArgs('/commit')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when only trailing space', () => {
|
||||
expect(hasCommandArgs('/commit ')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true when there are real arguments', () => {
|
||||
expect(hasCommandArgs('/commit msg')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for non-command input', () => {
|
||||
expect(hasCommandArgs('commit msg')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── formatCommand ────────────────────────────────────────────────────
|
||||
|
||||
describe('formatCommand', () => {
|
||||
test('formats command with leading slash and trailing space', () => {
|
||||
expect(formatCommand('commit')).toBe('/commit ')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── findMidInputSlashCommand ─────────────────────────────────────────
|
||||
|
||||
describe('findMidInputSlashCommand', () => {
|
||||
test('returns null when input starts with slash', () => {
|
||||
expect(findMidInputSlashCommand('/commit some args', 7)).toBeNull()
|
||||
})
|
||||
|
||||
test('finds slash command after whitespace', () => {
|
||||
const result = findMidInputSlashCommand('help me /com', 12)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.token).toBe('/com')
|
||||
expect(result!.startPos).toBe(8)
|
||||
expect(result!.partialCommand).toBe('com')
|
||||
})
|
||||
|
||||
test('returns null when no whitespace before slash', () => {
|
||||
expect(findMidInputSlashCommand('help/com', 8)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when cursor is past the command with trailing text', () => {
|
||||
expect(findMidInputSlashCommand('help /commit msg', 15)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── generateCommandSuggestions ────────────────────────────────────────
|
||||
|
||||
describe('generateCommandSuggestions', () => {
|
||||
const commands: Command[] = [
|
||||
makeCommand('commit'),
|
||||
makeCommand('compact'),
|
||||
makePromptCommand('sdd-global-read'),
|
||||
makePromptCommand('sdd-archive'),
|
||||
]
|
||||
|
||||
test('returns empty for non-slash input', () => {
|
||||
expect(generateCommandSuggestions('commit', commands)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('returns all commands for bare slash', () => {
|
||||
const results = generateCommandSuggestions('/', commands)
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('filters by partial command name', () => {
|
||||
const results = generateCommandSuggestions('/com', commands)
|
||||
const names = results.map(r => r.displayText)
|
||||
expect(names.some(n => n.includes('commit'))).toBe(true)
|
||||
expect(names.some(n => n.includes('compact'))).toBe(true)
|
||||
})
|
||||
|
||||
test('returns empty when command has arguments', () => {
|
||||
expect(generateCommandSuggestions('/commit msg', commands)).toHaveLength(0)
|
||||
})
|
||||
|
||||
// ★ Core regression test: cursor-aware commandInput should not be
|
||||
// affected by text after the cursor. Previously, passing the full input
|
||||
// "/sdd-existing text" would fail because hasCommandArgs detected the
|
||||
// space from the post-cursor text. The fix slices value to cursorOffset
|
||||
// before calling generateCommandSuggestions.
|
||||
test('suggests commands when called with cursor-sliced input (post-cursor text ignored)', () => {
|
||||
// Simulates: input="/sdd-existing text", cursor at position 5
|
||||
// The caller now passes input.substring(0, cursorOffset) = "/sdd-"
|
||||
const cursorOffset = 5
|
||||
const fullInput = '/sdd-existing text'
|
||||
const commandInput = fullInput.substring(0, cursorOffset)
|
||||
|
||||
expect(hasCommandArgs(commandInput)).toBe(false)
|
||||
const results = generateCommandSuggestions(commandInput, commands)
|
||||
const names = results.map(r => r.displayText)
|
||||
expect(names.some(n => n.includes('sdd-global-read'))).toBe(true)
|
||||
expect(names.some(n => n.includes('sdd-archive'))).toBe(true)
|
||||
})
|
||||
|
||||
test('shows suggestions for bare slash even with text after cursor', () => {
|
||||
// input="/hello world", cursor at position 1 → commandInput="/"
|
||||
const commandInput = '/'.substring(0, 1)
|
||||
const results = generateCommandSuggestions(commandInput, commands)
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getBestCommandMatch ──────────────────────────────────────────────
|
||||
|
||||
describe('getBestCommandMatch', () => {
|
||||
const commands: Command[] = [
|
||||
makeCommand('commit'),
|
||||
makeCommand('compact'),
|
||||
makePromptCommand('sdd-global-read'),
|
||||
]
|
||||
|
||||
test('returns matching suffix for prefix match', () => {
|
||||
const result = getBestCommandMatch('com', commands)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.suffix.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('returns null for no match', () => {
|
||||
expect(getBestCommandMatch('xyz', commands)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null for empty query', () => {
|
||||
expect(getBestCommandMatch('', commands)).toBeNull()
|
||||
})
|
||||
|
||||
// ★ Verifies that slicing to cursor position lets the fuzzy matching work
|
||||
test('finds match when partial includes dash separator', () => {
|
||||
const result = getBestCommandMatch('sdd', commands)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.fullCommand).toBe('sdd-global-read')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── applyCommandSuggestion (Enter behavior) ──────────────────────────
|
||||
|
||||
describe('applyCommandSuggestion', () => {
|
||||
const commands: Command[] = [
|
||||
makeCommand('commit', { argumentHint: '[message]' }),
|
||||
]
|
||||
|
||||
test('replaces entire input with formatted command', () => {
|
||||
let newInput = ''
|
||||
let newCursor = -1
|
||||
const suggestion: SuggestionItem = {
|
||||
id: 'commit:local',
|
||||
displayText: '/commit',
|
||||
description: 'commit command',
|
||||
metadata: commands[0],
|
||||
}
|
||||
|
||||
applyCommandSuggestion(
|
||||
suggestion,
|
||||
false,
|
||||
commands,
|
||||
v => { newInput = v },
|
||||
c => { newCursor = c },
|
||||
() => {},
|
||||
)
|
||||
|
||||
expect(newInput).toBe('/commit ')
|
||||
expect(newCursor).toBe('/commit '.length)
|
||||
})
|
||||
|
||||
test('executes command when shouldExecute is true', () => {
|
||||
let submitted = ''
|
||||
const suggestion: SuggestionItem = {
|
||||
id: 'commit:local',
|
||||
displayText: '/commit',
|
||||
description: 'commit command',
|
||||
metadata: commands[0],
|
||||
}
|
||||
|
||||
applyCommandSuggestion(
|
||||
suggestion,
|
||||
true,
|
||||
commands,
|
||||
() => {},
|
||||
() => {},
|
||||
v => { submitted = v },
|
||||
)
|
||||
|
||||
expect(submitted).toBe('/commit ')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Tab completion splice behavior ───────────────────────────────────
|
||||
// Tests the splice-at-cursor logic that was added to handle Tab completion
|
||||
// preserving text after the cursor. This mirrors the inline logic in
|
||||
// handleTab (useTypeahead.tsx) where applyCommandSuggestion is bypassed
|
||||
// in favor of direct splice.
|
||||
|
||||
describe('Tab completion splice behavior', () => {
|
||||
// Simulates the handleTab splice logic:
|
||||
// const replacement = `/${commandName} `
|
||||
// onInputChange(replacement + input.slice(cursorOffset))
|
||||
// setCursorOffset(replacement.length)
|
||||
|
||||
function simulateTabCompletion(
|
||||
commandName: string,
|
||||
input: string,
|
||||
cursorOffset: number,
|
||||
): { newInput: string; newCursorOffset: number } {
|
||||
const replacement = `/${commandName} `
|
||||
return {
|
||||
newInput: replacement + input.slice(cursorOffset),
|
||||
newCursorOffset: replacement.length,
|
||||
}
|
||||
}
|
||||
|
||||
test('preserves text after cursor when completing mid-input command', () => {
|
||||
// User has "existing text here", types "/sdd-" at beginning, then
|
||||
// presses Tab to accept "sdd-global-read" suggestion
|
||||
const input = '/sdd-existing text here'
|
||||
const cursorOffset = 5 // after "/sdd-"
|
||||
|
||||
const result = simulateTabCompletion('sdd-global-read', input, cursorOffset)
|
||||
|
||||
expect(result.newInput).toBe('/sdd-global-read existing text here')
|
||||
expect(result.newCursorOffset).toBe('/sdd-global-read '.length)
|
||||
})
|
||||
|
||||
test('works normally when cursor is at end of input', () => {
|
||||
// Standard case: cursor at end, no text after cursor
|
||||
const input = '/com'
|
||||
const cursorOffset = 4
|
||||
|
||||
const result = simulateTabCompletion('commit', input, cursorOffset)
|
||||
|
||||
expect(result.newInput).toBe('/commit ')
|
||||
expect(result.newCursorOffset).toBe('/commit '.length)
|
||||
})
|
||||
|
||||
test('preserves single word after cursor', () => {
|
||||
const input = '/comworld'
|
||||
const cursorOffset = 4
|
||||
|
||||
const result = simulateTabCompletion('commit', input, cursorOffset)
|
||||
|
||||
expect(result.newInput).toBe('/commit world')
|
||||
expect(result.newCursorOffset).toBe('/commit '.length)
|
||||
})
|
||||
|
||||
test('preserves multiline text after cursor', () => {
|
||||
const input = '/comline1\nline2'
|
||||
const cursorOffset = 4
|
||||
|
||||
const result = simulateTabCompletion('commit', input, cursorOffset)
|
||||
|
||||
expect(result.newInput).toBe('/commit line1\nline2')
|
||||
expect(result.newCursorOffset).toBe('/commit '.length)
|
||||
})
|
||||
|
||||
test('handles empty text after cursor identically to end-of-input', () => {
|
||||
const input = '/commit'
|
||||
const endResult = simulateTabCompletion('commit', input, 7)
|
||||
|
||||
expect(endResult.newInput).toBe('/commit ')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── hasCommandWithArguments with cursor-sliced input ─────────────────
|
||||
// Tests the helper function used in updateSuggestions to determine if
|
||||
// command has arguments. After the fix, only the text before cursor is
|
||||
// passed, so post-cursor text doesn't affect the check.
|
||||
|
||||
describe('hasCommandWithArguments (cursor-aware usage)', () => {
|
||||
function hasCommandWithArguments(
|
||||
isAtEndWithWhitespace: boolean,
|
||||
value: string,
|
||||
): boolean {
|
||||
return !isAtEndWithWhitespace && value.includes(' ') && !value.endsWith(' ')
|
||||
}
|
||||
|
||||
test('returns false when cursor-sliced input has no space', () => {
|
||||
// input="/sdd-existing text", cursorOffset=5 → commandInput="/sdd-"
|
||||
const commandInput = '/sdd-'
|
||||
expect(hasCommandWithArguments(false, commandInput)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true when cursor-sliced input has real arguments', () => {
|
||||
// input="/commit msg rest", cursorOffset=11 → commandInput="/commit msg"
|
||||
const commandInput = '/commit msg'
|
||||
expect(hasCommandWithArguments(false, commandInput)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for trailing space (ready for arguments)', () => {
|
||||
const commandInput = '/commit '
|
||||
expect(hasCommandWithArguments(false, commandInput)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when cursor is at end with trailing space', () => {
|
||||
// isAtEndWithWhitespace=true → always false
|
||||
expect(hasCommandWithArguments(true, '/commit ')).toBe(false)
|
||||
})
|
||||
|
||||
test('does not match space from post-cursor text', () => {
|
||||
// Before fix: full input "/sdd-existing text" → hasCommandWithArguments = true
|
||||
// After fix: sliced input "/sdd-" → hasCommandWithArguments = false
|
||||
const fullInput = '/sdd-existing text'
|
||||
const cursorOffset = 5
|
||||
const commandInput = fullInput.substring(0, cursorOffset)
|
||||
|
||||
expect(commandInput).toBe('/sdd-')
|
||||
expect(hasCommandWithArguments(false, commandInput)).toBe(false)
|
||||
// Verify the full input WOULD have been true (proving the bug existed)
|
||||
expect(hasCommandWithArguments(false, fullInput)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -68,8 +68,15 @@ let nextId = 1
|
||||
/**
|
||||
* Default socket path based on PID, placed in a tmpdir subdirectory so it
|
||||
* survives across config-home changes and avoids polluting ~/.claude.
|
||||
*
|
||||
* On Windows, Node.js requires named pipe paths in the `\\.\pipe\` namespace;
|
||||
* file-system paths like `C:\...\Temp\x.sock` cause EACCES. Bun handles both
|
||||
* transparently, but we use the pipe format on Windows for Node.js compat.
|
||||
*/
|
||||
export function getDefaultUdsSocketPath(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return `\\\\.\\pipe\\claude-code-${process.pid}`
|
||||
}
|
||||
return join(tmpdir(), 'claude-code-socks', `${process.pid}.sock`)
|
||||
}
|
||||
|
||||
@@ -123,14 +130,18 @@ export async function startUdsMessaging(
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
// Ensure parent directory exists (skip on Windows — pipe paths aren't files)
|
||||
if (process.platform !== 'win32') {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
}
|
||||
|
||||
// Clean up stale socket file
|
||||
try {
|
||||
await unlink(path)
|
||||
} catch {
|
||||
// ENOENT is fine
|
||||
// Clean up stale socket file (skip on Windows — pipe paths aren't files)
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await unlink(path)
|
||||
} catch {
|
||||
// ENOENT is fine
|
||||
}
|
||||
}
|
||||
|
||||
socketPath = path
|
||||
@@ -220,12 +231,14 @@ export async function stopUdsMessaging(): Promise<void> {
|
||||
})
|
||||
server = null
|
||||
|
||||
// Remove socket file
|
||||
// Remove socket file (skip on Windows — pipe paths aren't files)
|
||||
if (socketPath) {
|
||||
try {
|
||||
await unlink(socketPath)
|
||||
} catch {
|
||||
// Already gone
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await unlink(socketPath)
|
||||
} catch {
|
||||
// Already gone
|
||||
}
|
||||
}
|
||||
delete process.env.CLAUDE_CODE_MESSAGING_SOCKET
|
||||
logForDebugging(
|
||||
|
||||
@@ -44,11 +44,18 @@ export function hasVoiceAuth(): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Full runtime check: auth + GrowthBook kill-switch. Callers: `/voice`
|
||||
* (voice.ts, voice/index.ts), ConfigTool, VoiceModeNotice — command-time
|
||||
* paths where a fresh keychain read is acceptable. For React render
|
||||
* paths use useVoiceEnabled() instead (memoizes the auth half).
|
||||
* Full runtime check for Anthropic voice_stream backend.
|
||||
* Returns true when both auth + GrowthBook kill-switch pass.
|
||||
*/
|
||||
export function isVoiceModeEnabled(): boolean {
|
||||
return hasVoiceAuth() && isVoiceGrowthBookEnabled()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if voice mode can be activated with any STT backend.
|
||||
* Always returns true when VOICE_MODE feature flag is on and GrowthBook
|
||||
* kill-switch is off — the Doubao backend does not require Anthropic auth.
|
||||
*/
|
||||
export function isVoiceAvailable(): boolean {
|
||||
return isVoiceGrowthBookEnabled()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Updated: 2026-04-24
|
||||
|
||||
## Style
|
||||
- Learns best with: Analogies (Memory page <-> Capsule hotel), Concrete trade-offs (Latency vs Throughput)
|
||||
- Learns best with: Analogies (Memory page <-> Capsule hotel), Concrete trade-offs (Latency vs Throughput)
|
||||
- Strength: Strong logical intuition regarding memory constraints.
|
||||
- Pace: Fast. Grasped PagedAttention/TP concepts quickly from first principles.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user