commit 30b27fe8fb9257026ca35d05438c46d7c52adc1b Author: wehub-resource-sync Date: Mon Jul 13 12:33:50 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..921df28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.log +.env +.wechat-claude-code/ +.claude/ +CLAUDE.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e22f34 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Wechat-ggGitHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..235fd46 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# WeChat Claude Code Bridge + +

+ Chat with Claude Code in WeChat, just like texting a friend +

+ +

+ License: MIT + skills.sh + English +

+ +扫码绑定微信后,你的微信里会多出一个好友。给它发消息,消息会自动转发给你电脑上运行的 Claude Code,回复也会实时推送到微信。支持文字、图片、语音、文件的收发。 + +ScreenShot_2026-06-10_211251_410 + +## 核心亮点 +| | | +|---|---| +| **扫码即用** | 不用注册账号,不用部署服务器。微信扫码绑定,一分钟搞定。数据全在本地,隐私有保障。 | +| **消息不刷屏** | 只推送核心信息——进度、结果、关键决策。工具调用、中间过程等噪音自动过滤,阅读体验清爽。 | +| **"对方正在输入中..."** | Claude 在处理任务时,微信顶部会显示输入状态,随时感知它在干活。 | +| **电脑手机体验一致** | 手机端和电脑端 Claude Code 行为完全相同——同样的编排逻辑、同样的输出效果。不是两个割裂的 AI。 | +| **文件双向收发** | 发图片、Word、PDF 给 Claude 分析;Claude 生成的文件也会直接推送到微信,不用回到电脑前查看。 | +| **超时安抚** | 任务超过 5 分钟没响应?它会自动发一条消息告诉你还在干,不会让你对着空白聊天框干等。 | + +## 快速安装 + +**方式一:skills CLI(推荐)** + +```bash +npx skills add Wechat-ggGitHub/wechat-claude-code +``` + +首次在对话中触发时,会自动克隆项目源码并安装依赖。 + +**方式二:手动克隆** + +```bash +git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git ~/.claude/skills/wechat-claude-code +cd ~/.claude/skills/wechat-claude-code && npm install +``` + +## 快速开始 + +### 1. 扫码绑定 + +```bash +cd ~/.claude/skills/wechat-claude-code +npm run setup +``` + +弹出二维码,用微信扫码。 + +### 2. 启动服务 + +```bash +npm run daemon -- start +``` + +macOS 下自动注册 launchd,开机自启、崩溃自动重启。 + +### 3. 开始聊天 + +打开微信,给你新出现的那个"好友"发条消息试试。 + +### 管理服务 + +```bash +npm run daemon -- status # 查看运行状态 +npm run daemon -- stop # 停止服务 +npm run daemon -- restart # 重启服务(更新代码后使用) +npm run daemon -- logs # 查看日志 +``` + +## 微信端命令 + +直接在微信聊天中发送即可: + +| 命令 | 说明 | +|------|------| +| `/help` | 显示帮助 | +| `/clear` | 清除当前会话,开始新对话 | +| `/stop` | 停止当前任务 | +| `/model <名称>` | 切换 Claude 模型 | +| `/prompt <内容>` | 设置系统提示词(如"用中文回答") | +| `/cwd <路径>` | 切换工作目录 | +| `/skills` | 查看已安装的 Skill | +| `/status` | 查看当前会话状态 | +| `/history [数量]` | 查看最近对话记录 | +| `/compact` | 压缩上下文,开始新 CLI 会话 | +| `/reset` | 完全重置(包括工作目录等设置) | +| `/undo [数量]` | 撤销最近几条对话 | +| `/ [参数]` | 触发任意已安装的 Skill | + +## 工作原理 + +``` +微信(手机) ←→ ilink Bot API ←→ Node.js 守护进程 ←→ Claude Code CLI(本地) +``` + +守护进程通过长轮询监听微信消息,转发给本地 `claude` CLI 处理,回复实时流式推送回微信。全程跑在你自己电脑上。 + +## 后续计划 + +- **消息队列优化** — 连续发多条指令时,回复容易串。正在研究更好的队列策略,也欢迎讨论。 +- **电脑休眠不中断** — 利用 macOS 的 `caffeinate` 命令阻止系统睡眠,合上盖子也能响应微信消息。 +- **接续电脑会话** — 在电脑上聊了很久,出门想接着聊。计划支持从当前电脑端的 Claude Code 会话直接续聊,工作空间和上下文保持一致。 + +## 前置条件 + +- Node.js >= 18 +- macOS 或 Linux +- 个人微信账号 +- 已安装 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 并完成认证 + +> **提示:** Claude Code 支持第三方 API 提供商(OpenRouter、AWS Bedrock 等),设置 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_API_KEY` 即可。 + +## 数据目录 + +所有数据存储在 `~/.wechat-claude-code/`: + +``` +~/.wechat-claude-code/ +├── accounts/ # 微信账号凭证 +├── config.json # 全局配置 +├── sessions/ # 会话数据 +└── logs/ # 运行日志(每日轮转,保留 30 天) +``` + +## License + +[MIT](LICENSE) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..6299eca --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`Wechat-ggGitHub/wechat-claude-code` +- 原始仓库:https://github.com/Wechat-ggGitHub/wechat-claude-code +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README_en.md b/README_en.md new file mode 100644 index 0000000..7032917 --- /dev/null +++ b/README_en.md @@ -0,0 +1,144 @@ +# WeChat Claude Code Bridge + +

+ Chat with Claude Code in WeChat, just like texting a friend +

+ +

+ License: MIT + skills.sh + 中文 +

+ +Scan a QR code to bind your WeChat, and a new "friend" appears in your contacts. Send it a message — it gets forwarded to Claude Code running on your computer, and the reply streams back to WeChat in real time. Supports text, images, voice, and files. + +--- + +## Highlights + +| | | +|---|---| +| **Scan and go** | No account signup, no server deployment. Scan a QR code and you're done in a minute. All data stays on your machine. | +| **Clean messages** | Only key info gets pushed — progress, results, key decisions. Tool calls and intermediate noise are filtered out automatically. | +| **"Typing..." indicator** | WeChat shows a typing indicator while Claude is working, so you always know it's on it. | +| **Consistent experience** | Mobile and desktop Claude Code behave identically — same orchestration, same output. Not two disconnected AIs. | +| **Two-way files** | Send images, Word docs, PDFs for Claude to analyze. Files Claude generates get pushed directly to WeChat — no need to go back to your computer. | +| **Timeout reassurance** | Task taking longer than 5 minutes? You'll get an automatic message letting you know it's still working. | + +--- + +## Install + +**Option 1: skills CLI (recommended)** + +```bash +npx skills add Wechat-ggGitHub/wechat-claude-code +``` + +The first time you trigger the skill, it will automatically clone the source and install dependencies. + +**Option 2: Manual clone** + +```bash +git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git ~/.claude/skills/wechat-claude-code +cd ~/.claude/skills/wechat-claude-code && npm install +``` + +## Quick Start + +### 1. Bind WeChat + +```bash +cd ~/.claude/skills/wechat-claude-code +npm run setup +``` + +A QR code will pop up — scan it with WeChat. + +### 2. Start the service + +```bash +npm run daemon -- start +``` + +On macOS, this registers a launchd agent for auto-start on boot and auto-restart on crash. + +### 3. Start chatting + +Open WeChat and send a message to your new "friend". + +### Manage the service + +```bash +npm run daemon -- status # Check if running +npm run daemon -- stop # Stop the service +npm run daemon -- restart # Restart (after code updates) +npm run daemon -- logs # View recent logs +``` + +--- + +## WeChat Commands + +Send these directly in the WeChat chat: + +| Command | Description | +|---------|-------------| +| `/help` | Show available commands | +| `/clear` | Clear current session, start fresh | +| `/stop` | Stop current task | +| `/model ` | Switch Claude model | +| `/prompt ` | Set a system prompt (e.g. "reply in Chinese") | +| `/cwd ` | Switch working directory | +| `/skills` | List installed Skills | +| `/status` | View current session state | +| `/history [n]` | View recent chat history | +| `/compact` | Compact context, start a new CLI session | +| `/reset` | Full reset including working directory | +| `/undo [n]` | Remove last N messages from history | +| `/ [args]` | Trigger any installed Skill | + +--- + +## How It Works + +``` +WeChat (phone) ←→ ilink Bot API ←→ Node.js daemon ←→ Claude Code CLI (local) +``` + +The daemon long-polls WeChat for new messages, forwards them to the local `claude` CLI, and streams replies back to WeChat. Everything runs on your own machine. + +--- + +## Roadmap + +- **Message queue optimization** — Consecutive messages can produce mixed-up replies. Working on a better queuing strategy. Ideas welcome. +- **Prevent sleep** — Use macOS `caffeinate` to keep the system awake, so closing the lid doesn't interrupt the service. +- **Resume desktop session** — Chat on your computer for a while, then continue the same session from WeChat on the go. Same workspace, same context. + +--- + +## Prerequisites + +- Node.js >= 18 +- macOS or Linux +- A personal WeChat account +- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed and authenticated + +> **Note:** Claude Code supports third-party API providers (OpenRouter, AWS Bedrock, etc.) — set `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` accordingly. + +## Data Directory + +All data is stored in `~/.wechat-claude-code/`: + +``` +~/.wechat-claude-code/ +├── accounts/ # WeChat account credentials +├── config.json # Global config +├── sessions/ # Session data +└── logs/ # Rotating logs (daily, 30-day retention) +``` + +## License + +[MIT](LICENSE) diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..0e8cac5 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,139 @@ +--- +name: wechat-claude-code +description: 微信消息桥接 - 在微信中与 Claude Code 聊天。支持文字对话、图片识别、实时进度推送、斜杠命令。 +--- + +# WeChat Claude Code Bridge + +通过个人微信与本地 Claude Code 进行对话。 + +## 前置条件 + +- Node.js >= 18 +- macOS(daemon 使用 launchd 管理) +- 个人微信账号(需扫码绑定) +- 已安装 Claude Code(`@anthropic-ai/claude-agent-sdk`) + +## 安装 + +**方式一:通过 skills CLI(推荐)** + +```bash +npx skills add Wechat-ggGitHub/wechat-claude-code +``` + +首次触发时 skill 会自动克隆完整项目源码并安装依赖。 + +**方式二:手动克隆** + +```bash +git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git ~/.claude/skills/wechat-claude-code +cd ~/.claude/skills/wechat-claude-code && npm install +``` + +## 触发场景 + +用户提到"微信桥接"、"微信聊天"、"wechat bridge"、"连接微信"、"微信状态"、"停止微信"等与微信桥接相关的话题时触发。 + +## 触发后的执行流程 + +**被触发时,不要直接执行任何操作,先探查当前状态再给出可用操作。** + +按顺序检查以下状态: + +### 第 1 步:检查项目是否完整安装 + +```bash +test -f ~/.claude/skills/wechat-claude-code/package.json && echo "source_ok" || echo "source_missing" +``` + +- 如果 `source_missing`:需要从 GitHub 克隆完整项目。执行: + ```bash + git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git /tmp/wechat-claude-code-install && cp -r /tmp/wechat-claude-code-install/{src,scripts,*.ts,*.json,*.md,LICENSE} ~/.claude/skills/wechat-claude-code/ && rm -rf /tmp/wechat-claude-code-install + ``` + 然后继续检查依赖。 + +- 如果 `source_ok`:继续检查依赖。 + +```bash +cd ~/.claude/skills/wechat-claude-code && test -d node_modules && echo "deps_ok" || echo "deps_missing" +``` + +- 如果 `deps_missing`:执行 `cd ~/.claude/skills/wechat-claude-code && npm install` 安装依赖,然后继续。 +- 如果 `deps_ok`:继续下一步。 + +### 第 2 步:检查是否已绑定微信账号 + +```bash +ls ~/.wechat-claude-code/accounts/*.json 2>/dev/null | head -1 +``` + +- 如果没有账号文件:提示用户需要先执行 setup 扫码绑定,询问是否现在执行。 +- 如果有账号文件:继续下一步。 + +### 第 3 步:检查 daemon 运行状态 + +```bash +cd ~/.claude/skills/wechat-claude-code && npm run daemon -- status +``` + +### 第 4 步:根据状态展示信息 + +**如果 daemon 未运行:** + +``` +微信桥接已绑定但未运行。 + +可用操作: + setup 重新扫码绑定(换号或过期时使用) + start 启动服务 + logs 查看上次运行的日志 +``` + +**如果 daemon 正在运行:** + +``` +微信桥接正在运行(PID: xxx)。 + +可用操作: + stop 停止服务 + restart 重启服务(代码更新后使用) + logs 查看运行日志 + +微信端命令(直接在微信中发送): + /help 显示帮助 + /clear 清除当前会话,开始新对话 + /status 查看当前会话状态 + /model 切换 Claude 模型 + /prompt 设置系统提示词 + /cwd 切换工作目录 + /skills 查看已安装的 skill +``` + +如果用户明确指定了操作(如"启动微信"、"停止微信服务"、"看看日志"等),跳过状态展示直接执行对应命令。 + +## 子命令参考 + +所有命令的工作目录为 `~/.claude/skills/wechat-claude-code`。 + +| 命令 | 执行 | 说明 | +|------|------|------| +| setup | `npm run setup` | 首次安装向导:生成 QR 码 → 微信扫码 → 配置工作目录 | +| start | `npm run daemon -- start` | 启动 launchd 守护进程(开机自启、自动重启) | +| stop | `npm run daemon -- stop` | 停止守护进程 | +| restart | `npm run daemon -- restart` | 重启守护进程 | +| status | `npm run daemon -- status` | 查看运行状态 | +| logs | `npm run daemon -- logs` | 查看最近日志(tail -100) | + +## 数据目录 + +所有数据存储在 `~/.wechat-claude-code/`: + +``` +~/.wechat-claude-code/ +├── accounts/ # 绑定的微信账号数据(每个账号一个 JSON) +├── config.env # 全局配置(工作目录、模型、系统提示词) +├── sessions/ # 会话数据(每个账号一个 JSON) +├── get_updates_buf # 消息轮询同步缓冲 +└── logs/ # 运行日志(每日轮转,保留 30 天) +``` diff --git a/docs/superpowers/plans/2026-06-20-message-batching.md b/docs/superpowers/plans/2026-06-20-message-batching.md new file mode 100644 index 0000000..8d82ddb --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-message-batching.md @@ -0,0 +1,1233 @@ +# 微信消息分块优化 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 Claude CLI 最终答案从"按段落碎片推送"改为"按回合整段推送",仅在超 4000 字时按段落硬切;agent loop 期间的 interstitial 保持实时段落推送。 + +**Architecture:** 在 `provider.ts` 暴露 `onTurnEnd(stopReason)` 回调(基于 `message_delta` 事件的 `stop_reason` 字段);新增 `src/claude/turn-router.ts` 的 `TurnRouter` 类把 `text_delta` 按回合累积,根据 `stop_reason` 分流为 interstitial(立即发)或 final(流结束发);`main.ts` 替换原 `textBuffer` 逻辑,接入 TurnRouter,删除段落边界 flush 相关死代码。 + +**Tech Stack:** TypeScript(strict)、Node.js ESM(`"type": "module"`、Node16 module resolution)、Node 内置 test runner(`node --test`)。 + +## Global Constraints + +- 不改 `src/wechat/api.ts` 的限流逻辑(2.5s 间隔、60s 冷却、指数退避保持原样)。 +- 不改 `MAX_MESSAGE_LENGTH = 4000`、`splitMessage`、`parseBlocks`、`findSafeSplitPoint`、`splitByNewline`。 +- 不改 typing 指示器、silence warning 5min 兜底(`flushTimer`)、文件自动推送。 +- TypeScript strict 模式,编译命令 `npm run build`(`tsc`)。 +- 测试入口 `npm test` = `node --test dist/tests/*.test.js`,必须先 `npm run build`。 +- ESM 导入必须带 `.js` 后缀(即便源是 `.ts`)。 +- 提交信息遵循现有风格:`type: 中文描述`(参考 `git log`)。 + +**Spec 参考**:`docs/superpowers/specs/2026-06-20-message-batching-design.md` + +--- + +## Task 1: 提取 `handleStreamLine` 到 provider.ts(纯重构,为可测性铺路) + +**Files:** +- Modify: `src/claude/provider.ts`(提取 `rl.on('line', ...)` 内的 switch 到导出函数) +- Create: `src/tests/provider.test.ts` + +**Interfaces:** +- Consumes: 无(首个任务) +- Produces: + - `export interface StreamParserState { sessionId: string; textParts: string[]; errorMessage?: string; trackingSkill: boolean; skillInputAccum: string; }` + - `export interface StreamParserCallbacks { onText?: (text: string) => void; onBlockEnd?: () => void; }` + - `export function handleStreamLine(line: string, state: StreamParserState, callbacks: StreamParserCallbacks): void` + +- [ ] **Step 1: 写覆盖现有行为的失败测试** + +Create `src/tests/provider.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { handleStreamLine, type StreamParserState } from '../claude/provider.js'; + +function freshState(): StreamParserState { + return { sessionId: '', textParts: [], trackingSkill: false, skillInputAccum: '' }; +} + +test('handleStreamLine: system init 设置 sessionId', () => { + const state = freshState(); + handleStreamLine( + JSON.stringify({ type: 'system', subtype: 'init', session_id: 'sess-123' }), + state, + {}, + ); + assert.equal(state.sessionId, 'sess-123'); +}); + +test('handleStreamLine: text_delta 触发 onText', () => { + const calls: string[] = []; + handleStreamLine( + JSON.stringify({ + type: 'stream_event', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hello' } }, + }), + freshState(), + { onText: (t) => calls.push(t) }, + ); + assert.deepEqual(calls, ['hello']); +}); + +test('handleStreamLine: content_block_stop 触发 onBlockEnd', () => { + let called = 0; + handleStreamLine( + JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }), + freshState(), + { onBlockEnd: () => called++ }, + ); + assert.equal(called, 1); +}); + +test('handleStreamLine: assistant 消息文本累积到 textParts', () => { + const state = freshState(); + handleStreamLine( + JSON.stringify({ + type: 'assistant', + message: { content: [{ type: 'text', text: '回复内容' }] }, + }), + state, + {}, + ); + assert.deepEqual(state.textParts, ['回复内容']); +}); + +test('handleStreamLine: 空行和非法 JSON 静默跳过', () => { + const state = freshState(); + handleStreamLine('', state, {}); + handleStreamLine('not json', state, {}); + handleStreamLine(' ', state, {}); + assert.deepEqual(state.textParts, []); +}); +``` + +- [ ] **Step 2: 跑测试确认失败(函数不存在)** + +Run: +```bash +npm run build && node --test dist/tests/provider.test.js +``` +Expected: 编译错误 `handleStreamLine is not exported` 或测试运行报错 `cannot find module`。 + +- [ ] **Step 3: 实现提取** + +In `src/claude/provider.ts`: + +**3a. 在文件顶部 imports 之后、`claudeQuery` 之前,加入类型定义和提取的函数:** + +```ts +// --------------------------------------------------------------------------- +// Stream parser (extracted for testability) +// --------------------------------------------------------------------------- + +export interface StreamParserState { + sessionId: string; + textParts: string[]; + errorMessage?: string; + trackingSkill: boolean; + skillInputAccum: string; +} + +export interface StreamParserCallbacks { + onText?: (text: string) => void; + onBlockEnd?: () => void; +} + +export function handleStreamLine( + line: string, + state: StreamParserState, + callbacks: StreamParserCallbacks, +): void { + if (!line.trim()) return; + let obj: any; + try { + obj = JSON.parse(line); + } catch { + return; + } + + switch (obj.type) { + case 'system': { + if (obj.subtype === 'init' && obj.session_id) { + state.sessionId = obj.session_id; + } + break; + } + case 'assistant': { + const content = obj.message?.content; + if (Array.isArray(content)) { + const text = content + .filter((b: any) => b.type === 'text') + .map((b: any) => b.text ?? '') + .join(''); + if (text) state.textParts.push(text); + } + break; + } + case 'stream_event': { + const evt = obj.event; + if (evt?.type === 'content_block_start' && evt.content_block?.type === 'tool_use') { + if (evt.content_block.name === 'Skill') { + state.trackingSkill = true; + state.skillInputAccum = ''; + } + } else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'text_delta') { + const delta: string = evt.delta.text; + if (delta && callbacks.onText) { + callbacks.onText(delta); + } + } else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'input_json_delta' && state.trackingSkill) { + state.skillInputAccum += evt.delta.partial_json ?? ''; + try { + const parsed = JSON.parse(state.skillInputAccum); + if (parsed.skill) { + const msg = `\n正在调用 ${parsed.skill} 技能\n\n`; + if (callbacks.onText) callbacks.onText(msg); + state.trackingSkill = false; + } + } catch { + // JSON not complete yet + } + } else if (evt?.type === 'content_block_stop') { + state.trackingSkill = false; + if (callbacks.onBlockEnd) callbacks.onBlockEnd(); + } + break; + } + case 'result': { + if (obj.result && typeof obj.result === 'string') { + const combined = state.textParts.join(''); + if (!combined.includes(obj.result)) { + state.textParts.push(obj.result); + } + } + if (obj.subtype === 'error' || (obj.errors && obj.errors.length > 0)) { + const errors = obj.errors ?? [obj.error_message ?? 'Unknown error']; + state.errorMessage = Array.isArray(errors) ? errors.join('; ') : String(errors); + logger.error('CLI returned error result', { errors }); + } + break; + } + default: + break; + } +} +``` + +**3b. 在 `claudeQuery` 内替换原 `rl.on('line', ...)` 块。** 找到现有的: + +```ts + // Parse NDJSON from stdout + let skillInputAccum = ''; + let trackingSkill = false; + + const rl = createInterface({ input: child.stdout! }); + rl.on('line', (line: string) => { + if (!line.trim()) return; + let obj: any; + try { + obj = JSON.parse(line); + } catch { + // Skip unparseable lines + return; + } + + switch (obj.type) { + case 'system': { + if (obj.subtype === 'init' && obj.session_id) { + sessionId = obj.session_id; + } + break; + } + case 'assistant': { + const content = obj.message?.content; + if (Array.isArray(content)) { + const text = content + .filter((b: any) => b.type === 'text') + .map((b: any) => b.text ?? '') + .join(''); + if (text) textParts.push(text); + } + break; + } + case 'stream_event': { + const evt = obj.event; + if (evt?.type === 'content_block_start' && evt.content_block?.type === 'tool_use') { + if (evt.content_block.name === 'Skill') { + trackingSkill = true; + skillInputAccum = ''; + } + } else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'text_delta') { + const delta: string = evt.delta.text; + if (delta && onText) { + Promise.resolve(onText(delta)).catch(() => {}); + } + } else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'input_json_delta' && trackingSkill) { + skillInputAccum += evt.delta.partial_json ?? ''; + try { + const parsed = JSON.parse(skillInputAccum); + if (parsed.skill) { + const msg = `\n正在调用 ${parsed.skill} 技能\n\n`; + if (onText) Promise.resolve(onText(msg)).catch(() => {}); + trackingSkill = false; + } + } catch { + // JSON not complete yet, keep accumulating + } + } else if (evt?.type === 'content_block_stop') { + trackingSkill = false; + if (onBlockEnd) Promise.resolve(onBlockEnd()).catch(() => {}); + } + break; + } + case 'result': { + if (obj.result && typeof obj.result === 'string') { + const combined = textParts.join(''); + if (!combined.includes(obj.result)) { + textParts.push(obj.result); + } + } + if (obj.subtype === 'error' || (obj.errors && obj.errors.length > 0)) { + const errors = obj.errors ?? [obj.error_message ?? 'Unknown error']; + errorMessage = Array.isArray(errors) ? errors.join('; ') : String(errors); + logger.error('CLI returned error result', { errors }); + } + break; + } + default: + break; + } + }); +``` + +替换为: + +```ts + // Parse NDJSON from stdout (logic in handleStreamLine for testability) + const parserState: StreamParserState = { + sessionId: '', + textParts: [], + trackingSkill: false, + skillInputAccum: '', + }; + const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd }; + + const rl = createInterface({ input: child.stdout! }); + rl.on('line', (line: string) => { + handleStreamLine(line, parserState, parserCallbacks); + }); +``` + +**3c. 把所有读写 `sessionId` / `textParts` / `errorMessage` 的地方改成操作 `parserState.*`。** 在 `claudeQuery` 内: + +- 函数开头删除三个局部声明:`let sessionId = '';`、`const textParts: string[] = [];`、`let errorMessage: string | undefined;`(状态现在在 `parserState` 里)。 +- timeout handler 内:`const partialText = textParts.join('\n').trim();` → `parserState.textParts.join('\n').trim();`;`finish({ ..., sessionId, ... })` → `finish({ ..., sessionId: parserState.sessionId, ... })`。 +- onAbort 内:同上两处替换。 +- `child.on('close', ...)` 内(5 处替换,含读和写): + - `!textParts.length && !errorMessage` → `!parserState.textParts.length && !parserState.errorMessage` + - `errorMessage = stderr || \`claude exited with code ${code}\`;` → `parserState.errorMessage = stderr || \`claude exited with code ${code}\`;` + - `const fullText = textParts.join('\n').trim();` → `parserState.textParts.join('\n').trim();` + - `if (!fullText && !errorMessage)` → `if (!fullText && !parserState.errorMessage)` + - `errorMessage = 'Claude returned an empty response.';` → `parserState.errorMessage = 'Claude returned an empty response.';` + - `finish({ text: fullText, sessionId, error: errorMessage })` → `finish({ text: fullText, sessionId: parserState.sessionId, error: parserState.errorMessage })` + - 日志里 `textLength: fullText.length` 等读取 `fullText` 的不动(它是局部变量)。 +- `child.on('error', ...)` 内:`finish({ text: '', sessionId, error: ... })` → `finish({ text: '', sessionId: parserState.sessionId, error: ... })`。 + +- [ ] **Step 4: 编译并跑测试确认通过** + +Run: +```bash +npm run build && node --test dist/tests/provider.test.js +``` +Expected: 5 个测试全 PASS,无 TypeScript 编译错误。 + +- [ ] **Step 5: 端到端冒烟(确认重构没破坏 claudeQuery)** + +Run: +```bash +echo "你好" | node dist/main.js 2>&1 | head -5 || true +``` +Expected: 进程能启动(即使因为没有配置账号/凭证而退出,也不应在 `provider.ts` 上报 TypeError)。如果输出 `未找到账号` 之类的运行期错误,说明导入和类型正常。 + +- [ ] **Step 6: 提交** + +```bash +git add src/claude/provider.ts src/tests/provider.test.ts +git commit -m "$(cat <<'EOF' +refactor: 提取 handleStreamLine 为可测的纯函数 + +把 claudeQuery 里 rl.on('line') 的 NDJSON 解析 switch 体抽成独立导出函数 +handleStreamLine(line, state, callbacks),状态外置到 StreamParserState。 +行为完全不变,仅是为后续 onTurnEnd 接入和单元测试铺路。 + +附首批单元测试覆盖 system init / text_delta / content_block_stop / +assistant 文本累积 / 非法行跳过 5 条路径。 + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 2: 在 provider.ts 加 `onTurnEnd` 回调 + +**Files:** +- Modify: `src/claude/provider.ts`(`QueryOptions` 加字段、`handleStreamLine` 加分支、`StreamParserCallbacks` 加字段) +- Modify: `src/tests/provider.test.ts`(新增测试) + +**Interfaces:** +- Consumes: Task 1 的 `handleStreamLine` / `StreamParserState` / `StreamParserCallbacks` +- Produces: + - `QueryOptions.onTurnEnd?: (stopReason: string) => void` + - `StreamParserCallbacks.onTurnEnd?: (stopReason: string) => void` + - `handleStreamLine` 在收到 `message_delta` 事件且 `delta.stop_reason` 存在时触发 `onTurnEnd` + +- [ ] **Step 1: 写失败测试** + +Append to `src/tests/provider.test.ts`: + +```ts +test('handleStreamLine: message_delta 带 stop_reason 触发 onTurnEnd', () => { + const calls: string[] = []; + handleStreamLine( + JSON.stringify({ + type: 'stream_event', + event: { type: 'message_delta', delta: { stop_reason: 'end_turn' } }, + }), + freshState(), + { onTurnEnd: (r) => calls.push(r) }, + ); + assert.deepEqual(calls, ['end_turn']); +}); + +test('handleStreamLine: message_delta 无 stop_reason 不触发 onTurnEnd', () => { + const calls: string[] = []; + handleStreamLine( + JSON.stringify({ + type: 'stream_event', + event: { type: 'message_delta', delta: {} }, + }), + freshState(), + { onTurnEnd: (r) => calls.push(r) }, + ); + assert.deepEqual(calls, []); +}); + +test('handleStreamLine: tool_use stop_reason 也正常透传', () => { + const calls: string[] = []; + handleStreamLine( + JSON.stringify({ + type: 'stream_event', + event: { type: 'message_delta', delta: { stop_reason: 'tool_use' } }, + }), + freshState(), + { onTurnEnd: (r) => calls.push(r) }, + ); + assert.deepEqual(calls, ['tool_use']); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: +```bash +npm run build && node --test dist/tests/provider.test.js +``` +Expected: 3 个新测试 FAIL(`onTurnEnd` 类型不存在或回调不触发),原 5 个 PASS。 + +- [ ] **Step 3: 实现** + +**3a. 在 `QueryOptions` 接口加字段**(`src/claude/provider.ts`): + +找到现有的: +```ts + /** Called when a content block ends — use to flush buffered text. */ + onBlockEnd?: () => Promise | void; +``` + +在其**之后**加: +```ts + /** Called when an assistant turn ends, with its stop_reason + * ('tool_use' | 'end_turn' | 'max_tokens' | 'stop_sequence' | 'pause_turn' | ...). + * Use to decide whether the turn's text is interstitial or final answer. */ + onTurnEnd?: (stopReason: string) => Promise | void; +``` + +**3b. 在 `StreamParserCallbacks` 接口加字段**(同文件,Task 1 新增的部分): + +找到: +```ts +export interface StreamParserCallbacks { + onText?: (text: string) => void; + onBlockEnd?: () => void; +} +``` + +改为: +```ts +export interface StreamParserCallbacks { + onText?: (text: string) => void; + onBlockEnd?: () => void; + onTurnEnd?: (stopReason: string) => void; +} +``` + +**3c. 在 `handleStreamLine` 的 `stream_event` case 加分支。** 找到 `content_block_stop` 分支: + +```ts + } else if (evt?.type === 'content_block_stop') { + state.trackingSkill = false; + if (callbacks.onBlockEnd) callbacks.onBlockEnd(); + } + break; +``` + +在其**之后**(仍在 `stream_event` case 内、`break;` 之前)插入: + +```ts + } else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) { + if (callbacks.onTurnEnd) callbacks.onTurnEnd(evt.delta.stop_reason); + } +``` + +注意:因为这是 `else if` 链,要把上面那个 `}` 闭合改一下。完整片段应该是: + +```ts + } else if (evt?.type === 'content_block_stop') { + state.trackingSkill = false; + if (callbacks.onBlockEnd) callbacks.onBlockEnd(); + } else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) { + if (callbacks.onTurnEnd) callbacks.onTurnEnd(evt.delta.stop_reason); + } + break; +``` + +**3d. 把 `onTurnEnd` 透传到 parserCallbacks。** 在 `claudeQuery` 内找到: + +```ts + const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd }; +``` + +改为: +```ts + const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd, onTurnEnd }; +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: +```bash +npm run build && node --test dist/tests/provider.test.js +``` +Expected: 8 个测试全 PASS。 + +- [ ] **Step 5: 提交** + +```bash +git add src/claude/provider.ts src/tests/provider.test.ts +git commit -m "$(cat <<'EOF' +feat: provider 暴露 onTurnEnd 回调,按 stop_reason 标记回合类型 + +handleStreamLine 在收到 message_delta 事件且带 stop_reason 时触发 +onTurnEnd(stopReason)。下游可据此区分 'tool_use' 回合(interstitial) +和 'end_turn' / 'max_tokens' 等终态回合(final answer)。 + +本任务仅暴露信号,不改变任何现有 flush 行为——main.ts 下一任务接入。 + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 3: 创建 `TurnRouter` 状态机 + +**Files:** +- Create: `src/claude/turn-router.ts` +- Create: `src/tests/turn-router.test.ts` + +**Interfaces:** +- Consumes: 无(独立模块) +- Produces: + - `export type MessageRole = 'interstitial' | 'final';` + - `export interface RoutedMessage { text: string; role: MessageRole; }` + - `export class TurnRouter { constructor(emit: (msg: RoutedMessage) => void); onText(delta: string): void; onTurnEnd(stopReason: string): void; drain(): void; }` + +行为契约: +- `onText` 累积到内部 `turnBuffer`,不立即 emit。 +- `onTurnEnd('tool_use')`:把 `turnBuffer` 作为 `interstitial` emit(trim 后非空才发),清空 `turnBuffer`。 +- `onTurnEnd(其他)`:把 `turnBuffer` 追加到 `pendingFinal`(用 `\n\n` 连接非空两端),清空 `turnBuffer`,**不立即 emit**。 +- `drain()`:先 emit `pendingFinal` 作为 `final`(非空才发),再 emit 残留 `turnBuffer` 作为 `interstitial`(非空才发),清空两者。 + +- [ ] **Step 1: 写失败测试** + +Create `src/tests/turn-router.test.ts`: + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { TurnRouter, type RoutedMessage } from '../claude/turn-router.js'; + +function newRouter() { + const emitted: RoutedMessage[] = []; + const router = new TurnRouter((m) => emitted.push(m)); + return { router, emitted }; +} + +test('onText 累积不立即 emit', () => { + const { router, emitted } = newRouter(); + router.onText('hello '); + router.onText('world'); + assert.deepEqual(emitted, []); +}); + +test('onTurnEnd(tool_use) 把 turnBuffer 作为 interstitial emit', () => { + const { router, emitted } = newRouter(); + router.onText('让我看一下'); + router.onTurnEnd('tool_use'); + assert.deepEqual(emitted, [{ text: '让我看一下', role: 'interstitial' }]); +}); + +test('onTurnEnd(end_turn) 不立即 emit,攒到 drain', () => { + const { router, emitted } = newRouter(); + router.onText('最终答案第一段'); + router.onTurnEnd('end_turn'); + assert.deepEqual(emitted, []); + router.drain(); + assert.deepEqual(emitted, [{ text: '最终答案第一段', role: 'final' }]); +}); + +test('多个 end_turn 回合用 \\n\\n 连接成一个 final', () => { + const { router, emitted } = newRouter(); + router.onText('段一'); + router.onTurnEnd('pause_turn'); + router.onText('段二'); + router.onTurnEnd('end_turn'); + router.drain(); + assert.deepEqual(emitted, [{ text: '段一\n\n段二', role: 'final' }]); +}); + +test('tool_use 和 end_turn 混合:interstitial 立即发,final 攒到 drain', () => { + const { router, emitted } = newRouter(); + router.onText('让我查一下'); + router.onTurnEnd('tool_use'); // → interstitial 立即 + router.onText('找到了。'); + router.onText('详细说明...'); + router.onTurnEnd('end_turn'); // → final 攒着 + router.drain(); // → final 发出 + assert.deepEqual(emitted, [ + { text: '让我查一下', role: 'interstitial' }, + { text: '找到了。详细说明...', role: 'final' }, + ]); +}); + +test('空文本回合不产生空消息', () => { + const { router, emitted } = newRouter(); + router.onTurnEnd('tool_use'); // turnBuffer 空 + router.onTurnEnd('end_turn'); // turnBuffer 空 + router.drain(); + assert.deepEqual(emitted, []); +}); + +test('onTurnEnd 未触发时 drain 也能把残留 turnBuffer 当 interstitial 发出', () => { + const { router, emitted } = newRouter(); + router.onText('未结束的残留'); + router.drain(); + assert.deepEqual(emitted, [ + { text: '未结束的残留', role: 'interstitial' }, + ]); +}); + +test('纯文本 Q&A(无 tool_use,单 end_turn)整段作为 final', () => { + const { router, emitted } = newRouter(); + const chunks = ['闭包是...', '举个例子...', '总结...']; + for (const c of chunks) router.onText(c); + router.onTurnEnd('end_turn'); + router.drain(); + assert.deepEqual(emitted, [ + { text: chunks.join(''), role: 'final' }, + ]); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: +```bash +npm run build && node --test dist/tests/turn-router.test.js +``` +Expected: 导入失败 `Cannot find module ../claude/turn-router.js`。 + +- [ ] **Step 3: 实现 TurnRouter** + +Create `src/claude/turn-router.ts`: + +```ts +/** + * TurnRouter 把 Claude CLI 的流式输出按"回合"分流: + * + * - tool_use 回合的文本 → 立即作为 interstitial emit(agent loop 进度) + * - 其他 stop_reason(end_turn / max_tokens / stop_sequence / pause_turn / ...) + * 的文本 → 攒到 pendingFinal,drain 时一次性作为 final emit + * + * 设计参考 docs/superpowers/specs/2026-06-20-message-batching-design.md。 + * + * 本类不做任何 I/O,只决定"何时把哪段文本以什么 role emit"。 + * 调用方(main.ts)负责把 RoutedMessage 切分(splitMessage)并发到微信。 + */ + +export type MessageRole = 'interstitial' | 'final'; + +export interface RoutedMessage { + text: string; + role: MessageRole; +} + +export class TurnRouter { + private turnBuffer = ''; + private pendingFinal = ''; + + constructor(private readonly emit: (msg: RoutedMessage) => void) {} + + onText(delta: string): void { + this.turnBuffer += delta; + } + + onTurnEnd(stopReason: string): void { + const text = this.turnBuffer; + this.turnBuffer = ''; + if (!text.trim()) return; + if (stopReason === 'tool_use') { + this.emit({ text, role: 'interstitial' }); + } else { + // end_turn / max_tokens / stop_sequence / pause_turn / 未知值 + // 一律当最终答案处理(宁可合并也不丢) + this.pendingFinal += this.pendingFinal ? '\n\n' + text : text; + } + } + + /** 流结束时调用。先发 final,再 drain 残留 interstitial。 */ + drain(): void { + if (this.pendingFinal.trim()) { + this.emit({ text: this.pendingFinal, role: 'final' }); + this.pendingFinal = ''; + } + if (this.turnBuffer.trim()) { + this.emit({ text: this.turnBuffer, role: 'interstitial' }); + this.turnBuffer = ''; + } + } +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: +```bash +npm run build && node --test dist/tests/turn-router.test.js +``` +Expected: 8 个测试全 PASS。 + +- [ ] **Step 5: 提交** + +```bash +git add src/claude/turn-router.ts src/tests/turn-router.test.ts +git commit -m "$(cat <<'EOF' +feat: 新增 TurnRouter 状态机,按 stop_reason 分流 interstitial/final + +纯逻辑模块,不持 I/O。onText 累积到 turnBuffer;onTurnEnd(tool_use) +立即 emit 为 interstitial,其他 stop_reason 攒到 pendingFinal; +drain 时一次性 emit 为 final。 + +覆盖 8 条路径:累积不 emit / tool_use 立即发 / end_turn 攒到 drain / +多 end_turn 用 \\n\\n 连接 / 混合 / 空回合不产生空消息 / 残留 drain / +纯文本 Q&A 整段 final。 + +下一个任务把 main.ts 接进来。 + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 4: 把 TurnRouter 接入 `main.ts`,删除死代码 + +**Files:** +- Modify: `src/main.ts`(`sendToClaude` 函数:替换 textBuffer 逻辑、接入 TurnRouter、改 flush 顺序、删除死代码) +- Modify: `src/claude/provider.ts`(删除 `QueryOptions.onBlockEnd` 字段、`StreamParserCallbacks.onBlockEnd` 字段、`handleStreamLine` 的 `content_block_stop` 分支调用) +- Modify: `src/tests/provider.test.ts`(删除 onBlockEnd 相关测试) + +**Interfaces:** +- Consumes: + - Task 1/2 的 `handleStreamLine` / `StreamParserCallbacks`(无 onBlockEnd) + - Task 2 的 `QueryOptions.onTurnEnd` + - Task 3 的 `TurnRouter` / `RoutedMessage` +- Produces: 无新对外接口(`sendToClaude` 仍是内部函数) + +- [ ] **Step 1: 删除 provider.ts 里的 onBlockEnd(先消提供方,让消费方编译报错暴露出来)** + +**1a. 修改 `src/claude/provider.ts` 的 `QueryOptions` 接口**,删除: +```ts + /** Called when a content block ends — use to flush buffered text. */ + onBlockEnd?: () => Promise | void; +``` + +**1b. 修改 `StreamParserCallbacks`**,删除 `onBlockEnd` 字段: +```ts +export interface StreamParserCallbacks { + onText?: (text: string) => void; + onBlockEnd?: () => void; // ← 删这一行 + onTurnEnd?: (stopReason: string) => void; +} +``` +变成: +```ts +export interface StreamParserCallbacks { + onText?: (text: string) => void; + onTurnEnd?: (stopReason: string) => void; +} +``` + +**1c. 修改 `handleStreamLine` 的 `content_block_stop` 分支**,不再调用 callbacks: + +找到: +```ts + } else if (evt?.type === 'content_block_stop') { + state.trackingSkill = false; + if (callbacks.onBlockEnd) callbacks.onBlockEnd(); + } else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) { +``` +改为: +```ts + } else if (evt?.type === 'content_block_stop') { + state.trackingSkill = false; + } else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) { +``` + +**1d. 修改 `claudeQuery` 内的 parserCallbacks 构造**,删除 onBlockEnd: + +找到: +```ts + const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd, onTurnEnd }; +``` +改为: +```ts + const parserCallbacks: StreamParserCallbacks = { onText, onTurnEnd }; +``` + +**1e. 在 `claudeQuery` 函数签名解构里删除 `onBlockEnd`。** 找到: +```ts + const { + prompt, + cwd, + resume, + model, + systemPrompt, + images, + onText, + onBlockEnd, + abortController, + } = options; +``` +改为(同时加 `onTurnEnd`): +```ts + const { + prompt, + cwd, + resume, + model, + systemPrompt, + images, + onText, + onTurnEnd, + abortController, + } = options; +``` + +- [ ] **Step 2: 改 provider.test.ts,删 onBlockEnd 测试** + +在 `src/tests/provider.test.ts` 删除: +```ts +test('handleStreamLine: content_block_stop 触发 onBlockEnd', () => { + let called = 0; + handleStreamLine( + JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }), + freshState(), + { onBlockEnd: () => called++ }, + ); + assert.equal(called, 1); +}); +``` + +替换为(验证 content_block_stop 仍重置 trackingSkill,但不发回调): +```ts +test('handleStreamLine: content_block_stop 重置 trackingSkill,无回调', () => { + const state = freshState(); + state.trackingSkill = true; + let textCalls = 0; + handleStreamLine( + JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }), + state, + { onText: () => textCalls++ }, + ); + assert.equal(state.trackingSkill, false); + assert.equal(textCalls, 0); +}); +``` + +- [ ] **Step 3: 编译确认 provider 侧改动不报错(此时 main.ts 还在传 onBlockEnd,预期会报错)** + +Run: +```bash +npm run build 2>&1 | head -20 +``` +Expected: 在 `src/main.ts` 报 TypeScript 错误,类似: +``` +error TS2322: Type '{ onText: ...; onBlockEnd: ...; onTurnEnd: ...; }' is not assignable to type 'QueryOptions' ... + Object literal may only specify known properties, and 'onBlockEnd' does not exist in type 'QueryOptions'. +``` +这是预期的——下一个 step 修 main.ts。 + +- [ ] **Step 4: 重写 main.ts 的 sendToClaude,接入 TurnRouter** + +**4a. 加 import。** 在 `src/main.ts` 顶部 imports 区,找到: +```ts +import { claudeQuery, type QueryOptions } from './claude/provider.js'; +``` +在其**之后**加: +```ts +import { TurnRouter } from './claude/turn-router.js'; +``` + +**4b. 删除死代码 `endsWithStructuralBoundary`。** 在 `src/main.ts` 的 `sendToClaude` 函数内(约 501-503 行)找到: + +```ts + /** Check if buffer ends at a structural boundary (double newline or horizontal rule). */ + function endsWithStructuralBoundary(text: string): boolean { + return /\n\n\s*$/.test(text) || /\n[-*_]{3,}\s*$/.test(text); + } +``` + +整个函数**删除**。 + +> 注意:`MIN_BATCH_FLUSH_LEN` 和 `SOFT_FLUSH_LIMIT` 两个常量在 sendToClaude 内部声明,紧跟在 `endsWithStructuralBoundary` 上方。它们会在 step 4c 的整段替换里一并消失(OLD 块包含它们,NEW 块不包含),无需单独处理。 + +**4c. 替换 sendToClaude 内的流式处理段。** 找到现有的(约 493-591 行): + +```ts + let textBuffer = ''; + let anySent = false; + let lastSentTime = Date.now(); + + const MIN_BATCH_FLUSH_LEN = 30; + const SOFT_FLUSH_LIMIT = 3800; + + /** Check if buffer ends at a structural boundary (double newline or horizontal rule). */ + function endsWithStructuralBoundary(text: string): boolean { + return /\n\n\s*$/.test(text) || /\n[-*_]{3,}\s*$/.test(text); + } + + // Serial promise chain — each flushText() appends to the chain, no flags needed + let flushChain: Promise = Promise.resolve(); + + function flushText(): Promise { + // Capture and clear synchronously to prevent race condition: + // new deltas can arrive while the chain awaits sendText, + // causing the async callback to clear content it never captured. + const captured = textBuffer.trim(); + textBuffer = ''; + if (!captured) return flushChain; + + flushChain = flushChain.then(async () => { + const chunks = splitMessage(captured); + for (let i = 0; i < chunks.length; i++) { + try { + await sender.sendText(fromUserId, contextToken, chunks[i]); + } catch (err) { + // Rate-limit exhaustion etc.: put the unsent chunks back at the + // front of the buffer so the next flush retries them. Content is + // never silently dropped (previously the for-loop aborted here and + // the already-cleared buffer lost everything from this chunk on). + const remaining = chunks.slice(i).join('\n\n'); + textBuffer = remaining + (textBuffer ? '\n\n' + textBuffer : ''); + logger.warn('flushText send failed, content retained for retry', { + error: err instanceof Error ? err.message : String(err), + retainedChunks: chunks.length - i, + }); + return; + } + } + anySent = true; + lastSentTime = Date.now(); + }); + return flushChain; + } + + // Safety net: send keepalive if nothing was sent for 5 minutes + const SILENCE_WARNING_MS = 5 * 60 * 1000; + const SILENCE_MESSAGES = [ + '我还在处理中,这个问题有点复杂,请再稍等一下', + '正在努力干活中,马上就有结果了,请稍等片刻', + '有点复杂正在处理,再给我一点时间,很快就好', + '快好了别着急,正在收尾阶段,马上给你回复', + '还在跑呢,任务量比较大,不过马上就能出结果了', + '任务比想象的复杂一些,再等等我,正在全力处理', + '正在处理中,进展顺利,再等一会儿就好', + '还没完不过已经快了,再给我一分钟就能搞定', + '我在认真思考这个问题,请再稍等一会儿', + '稍微有点棘手,不过已经快解决了,再等我一下', + ]; + flushTimer = setInterval(() => { + if (Date.now() - lastSentTime > SILENCE_WARNING_MS) { + const msg = SILENCE_MESSAGES[Math.floor(Math.random() * SILENCE_MESSAGES.length)]; + sender.sendText(fromUserId, contextToken, msg).catch(() => {}); + lastSentTime = Date.now(); + } + }, 2000); + + const queryOptions: QueryOptions = { + prompt, + cwd: (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()), + resume: session.sdkSessionId, + model: session.model, + systemPrompt: [ + '你正在通过微信与用户对话,不是在终端里。不要让用户去终端操作。如果用户需要文件,直接输出文件地址就行,会自动识别解析推送文件到用户的微信中。', + config.systemPrompt, + ].filter(Boolean).join('\n'), + abortController, + images, + onText: async (delta: string) => { + textBuffer += delta; + + // Flush at structural boundaries (only if buffer is substantial) or when approaching size limit + const shouldFlush = + (endsWithStructuralBoundary(textBuffer) && textBuffer.trim().length >= MIN_BATCH_FLUSH_LEN) + || textBuffer.length > SOFT_FLUSH_LIMIT; + + if (shouldFlush) { + await flushText(); + } + }, + onBlockEnd: () => { + if (textBuffer.trim().length >= MIN_BATCH_FLUSH_LEN || textBuffer.length > SOFT_FLUSH_LIMIT) { + flushText(); + } + }, + }; +``` + +整段替换为: + +```ts + let anySent = false; + let lastSentTime = Date.now(); + let pendingRetry = ''; // sendText 失败时未发出的 chunks,下一次 flush 优先重试 + + // Serial promise chain — each emit appends to the chain, no flags needed + let flushChain: Promise = Promise.resolve(); + + /** 把一段文本切分后串行发到微信。失败时把未发的 chunks 攒到 pendingRetry,下次重试。 */ + function emitText(text: string, role: 'interstitial' | 'final'): void { + if (!text.trim()) return; + flushChain = flushChain.then(async () => { + const combined = pendingRetry ? pendingRetry + '\n\n' + text : text; + pendingRetry = ''; + if (!combined.trim()) return; + const chunks = splitMessage(combined); + for (let i = 0; i < chunks.length; i++) { + try { + await sender.sendText(fromUserId, contextToken, chunks[i]); + } catch (err) { + // Rate-limit exhaustion etc.: put the unsent chunks back so the + // next emit retries them. Content is never silently dropped. + pendingRetry = chunks.slice(i).join('\n\n'); + logger.warn('emitText send failed, content retained for retry', { + role, + error: err instanceof Error ? err.message : String(err), + retainedChunks: chunks.length - i, + }); + return; + } + } + anySent = true; + lastSentTime = Date.now(); + }); + } + + const router = new TurnRouter((msg) => emitText(msg.text, msg.role)); + + // Safety net: send keepalive if nothing was sent for 5 minutes + const SILENCE_WARNING_MS = 5 * 60 * 1000; + const SILENCE_MESSAGES = [ + '我还在处理中,这个问题有点复杂,请再稍等一下', + '正在努力干活中,马上就有结果了,请稍等片刻', + '有点复杂正在处理,再给我一点时间,很快就好', + '快好了别着急,正在收尾阶段,马上给你回复', + '还在跑呢,任务量比较大,不过马上就能出结果了', + '任务比想象的复杂一些,再等等我,正在全力处理', + '正在处理中,进展顺利,再等一会儿就好', + '还没完不过已经快了,再给我一分钟就能搞定', + '我在认真思考这个问题,请再稍等一会儿', + '稍微有点棘手,不过已经快解决了,再等我一下', + ]; + flushTimer = setInterval(() => { + if (Date.now() - lastSentTime > SILENCE_WARNING_MS) { + const msg = SILENCE_MESSAGES[Math.floor(Math.random() * SILENCE_MESSAGES.length)]; + sender.sendText(fromUserId, contextToken, msg).catch(() => {}); + lastSentTime = Date.now(); + } + }, 2000); + + const queryOptions: QueryOptions = { + prompt, + cwd: (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()), + resume: session.sdkSessionId, + model: session.model, + systemPrompt: [ + '你正在通过微信与用户对话,不是在终端里。不要让用户去终端操作。如果用户需要文件,直接输出文件地址就行,会自动识别解析推送文件到用户的微信中。', + config.systemPrompt, + ].filter(Boolean).join('\n'), + abortController, + images, + onText: (delta: string) => { + router.onText(delta); + }, + onTurnEnd: (stopReason: string) => { + router.onTurnEnd(stopReason); + }, + }; +``` + +**4d. 修改流结束后的 flush 顺序。** 找到(约 605-627 行): + +```ts + // Stop periodic flush and send any remaining buffered content + clearInterval(flushTimer); + await flushText(); + + // Send result back to WeChat + if (result.text) { + if (result.error) { + logger.warn('Claude query had error but returned text, using text', { error: result.error }); + } + sessionStore.addChatMessage(session, 'assistant', result.text); + // If nothing was streamed at all (e.g. streaming not supported), send full text now + if (!anySent) { + const chunks = splitMessage(result.text); + for (const chunk of chunks) { + await sender.sendText(fromUserId, contextToken, chunk); + } + } + } else if (result.error) { +``` + +替换 `clearInterval(flushTimer);` 和 `await flushText();` 这两行(保留后面所有内容不变): + +```ts + // Stop periodic flush, drain router (final 先于 interstitial), wait for queued sends + clearInterval(flushTimer); + router.drain(); + await flushChain; + + // Send result back to WeChat + if (result.text) { + if (result.error) { + logger.warn('Claude query had error but returned text, using text', { error: result.error }); + } + sessionStore.addChatMessage(session, 'assistant', result.text); + // If nothing was streamed at all (e.g. streaming not supported), send full text now + if (!anySent) { + const chunks = splitMessage(result.text); + for (const chunk of chunks) { + await sender.sendText(fromUserId, contextToken, chunk); + } + } + } else if (result.error) { +``` + +- [ ] **Step 5: 编译并跑所有测试** + +Run: +```bash +npm run build && npm test +``` +Expected: +- TypeScript 编译零错误(确认 onBlockEnd 已彻底从 main.ts 移除)。 +- 所有测试 PASS:`provider.test.ts`(8 个)+ `turn-router.test.ts`(8 个)。 + +- [ ] **Step 6: 端到端冒烟** + +Run: +```bash +echo "" | node dist/main.js 2>&1 | head -5 || true +``` +Expected: 进程启动、读到 `未找到账号` 或类似的运行期错误(因为没配置),但**不应**报 TypeScript / 模块解析错误。这验证编译产物导入正常。 + +- [ ] **Step 7: 真实 trace 验证新逻辑依赖的信号确实存在** + +重新跑一份 trace,确认 Claude CLI 真实输出里包含新逻辑依赖的 `message_delta` 事件: + +```bash +mkdir -p /tmp/verify-batching && cd /tmp/verify-batching && \ +echo "用三段话解释 JavaScript 闭包" | claude -p - --output-format stream-json --verbose --include-partial-messages --dangerously-skip-permissions 2>/dev/null > trace.jsonl && \ +echo "trace 行数: $(wc -l < trace.jsonl)" && \ +echo "--- message_delta 事件数(应 >= 1)---" && \ +grep -c '"type":"message_delta"' trace.jsonl && \ +echo "--- 各 stop_reason 分布 ---" && \ +grep -o '"stop_reason":"[^"]*"' trace.jsonl | sort | uniq -c +``` + +Expected: +- trace 行数 > 100。 +- 至少 1 个 `message_delta` 事件。 +- stop_reason 分布里至少有 1 个 `end_turn`(纯 Q&A 场景)。 + +> 这一步只验证「我们依赖的信号真实存在」。完整端到端效果验证(消息条数从 N 降到 1)需要装上 daemon 在微信里实测,见下方「验收清单」。 + +- [ ] **Step 8: 提交** + +```bash +git add src/main.ts src/claude/provider.ts src/tests/provider.test.ts +git commit -m "$(cat <<'EOF' +feat: main.ts 接入 TurnRouter,最终答案改为按回合整段推送 + +把 sendToClaude 原来的单 textBuffer + 段落边界 flush 逻辑替换为 +TurnRouter 状态机:onText 只累积不 flush,onTurnEnd(tool_use) 立即 +emit 为 interstitial,其他 stop_reason 攒到 pendingFinal,流结束时 +router.drain() 一次性 emit 为 final(splitMessage 按 4000 字硬切)。 + +真实 trace 验证效果: +- 纯文本 Q&A(1485 字):8 条 → 1 条 +- tool_use + 9121 字长答案:59 条 → 4 条 + +顺带清理死代码:删除 MIN_BATCH_FLUSH_LEN / SOFT_FLUSH_LIMIT / +endsWithStructuralBoundary / onBlockEnd(provider.ts 同步移除字段 +和 content_block_stop 回调,测试相应更新)。 + +限流逻辑、splitMessage、typing、silence warning、文件自动推送 +全部不动。emitText 保留了 commit d6d7d62 引入的失败重试语义 +(pendingRetry)。 + +Spec: docs/superpowers/specs/2026-06-20-message-batching-design.md + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## 验收清单(实现完成后人工跑一遍) + +参考 spec § 7.3: + +1. **纯 Q&A**:微信发"用三段话解释闭包"→ 应收到 **1 条**完整答案(而非现在的 8 条)。 +2. **多 tool_use + 长答案**:微信发"分析 src/main.ts 的结构"→ 应收到 1 条 interstitial + N 条最终答案块(按 4000 字切),总条数远少于现在。 +3. **Abort**:发任务后立即发 `/stop` → 已发的 interstitial 不丢,部分生成的最终答案按已生成内容推送,无内容丢失。 +4. **限流恢复**:观察日志,若偶发 `emitText send failed, content retained for retry`,下一次 emit 应自动重试成功(pendingRetry 机制)。 diff --git a/docs/superpowers/specs/2026-06-20-message-batching-design.md b/docs/superpowers/specs/2026-06-20-message-batching-design.md new file mode 100644 index 0000000..b2904a1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-message-batching-design.md @@ -0,0 +1,186 @@ +# 微信消息分块优化:按回合分流最终答案 + +**日期**:2026-06-20 +**作者**:brainstorming session +**状态**:待评审 + +## 1. 问题陈述 + +当前流式推送逻辑把 Claude CLI 的所有 `text_delta` 一视同仁地按"段落边界"切割推送,导致**最终答案也被切成大量碎片**。 + +真实 trace 验证(`/tmp/claude-stream-test/traces/`): + +| Trace | 场景 | 现状推送条数 | +|---|---|:---:| +| A | 纯文本 Q&A(1485 字) | **8 条** | +| D | tool_use + 9121 字长答案 | **59 条** | + +Trace D 的 59 条会反复撞服务端 ~10 条/分钟的突发限制(`ret:-2`),触发多次 60s 冷却,**累计卡顿 5-6 分钟**。这是用户报告的"体验特别差"的根因。 + +## 2. 目标与非目标 + +### 目标 + +- **最终答案**(Claude 给用户的交付内容)尽可能合并成最少条消息,仅在超过微信单条 4000 字硬上限时按段落边界切分。 +- **Agent loop 期间的 interstitial**(工具调用之间的简短评注)保持现状的实时段落推送,不引入额外延迟。 + +### 非目标 + +- **不改限流逻辑**。`api.ts` 的 2.5s 间隔、60s 冷却、指数退避全部保持现状。理由:Problem 1 修复后发送量大幅下降(D 场景 59 → 4),足以让绝大多数任务在突发限制以内完成;用户已确认接受重度 agent loop(10+ 工具调用 + 密集 interstitial)仍可能撞墙的边界情况。 +- 不改 `splitMessage` 的 4000 字上限。 +- 不改 typing 指示器、silence warning(5min 兜底)、文件自动推送等机制。 + +## 3. 根因分析 + +`src/main.ts:574-590` 的 `onText` 回调对每个 `text_delta` 执行同一段 flush 判断: + +```ts +const shouldFlush = + (endsWithStructuralBoundary(textBuffer) && textBuffer.trim().length >= MIN_BATCH_FLUSH_LEN) + || textBuffer.length > SOFT_FLUSH_LIMIT; +``` + +这段逻辑同时作用于两类语义不同的文本: + +1. **Interstitial**:agent 在工具调用之间吐出的简短进度("让我看一下代码"、"找到问题了")。 +2. **Final answer**:回合自然结束时交付给用户的完整回答。 + +两者都被段落边界切分。`src/main.ts:610-621` 虽然有"整段重发"分支,但条件是 `!anySent`——只要流式期间推过任何内容就不再触发,所以最终答案只能依靠流式 flush,被切成 N 段。 + +## 4. 关键洞察:`stop_reason` 是明确的回合类型信号 + +抓取真实 NDJSON trace(`claude -p - --output-format stream-json --verbose --include-partial-messages`)后发现:**每个 assistant 回合结束时会发 `message_delta` 事件,带明确的 `stop_reason` 字段**。 + +| `stop_reason` | 含义 | 文本应如何处理 | +|---|---|---| +| `"tool_use"` | 回合因要调工具结束,agent loop 继续 | 按段落 flush(interstitial) | +| `"end_turn"` | 自然结束,这就是最终答案 | **整回合 buffer,流结束才一次性发** | +| `"max_tokens"` / `"stop_sequence"` / `"pause_turn"` | 非自然结束但属终态 | 同 `end_turn` | + +这比早期讨论的"是否有 tool_use"启发式更可靠——不需要猜测、不需要特例处理纯 Q&A 场景,API 直接告诉我们这回合是什么。 + +## 5. 设计 + +### 5.1 状态机 + +`sendToClaude` 内部维护三个局部状态(替换现在的单个 `textBuffer`): + +```ts +let turnBuffer = ''; // 当前回合累积的 text_delta +let pendingFinal = ''; // 标记为最终答案的回合文本(可能跨多个 end_turn 回合) +let anySent = false; // 是否曾成功推送(保留现有安全网语义) +``` + +### 5.2 事件路由 + +**`onText(delta)`**:每个 `text_delta` 追加到 `turnBuffer`。**不再做段落边界 flush 判断**——段落 flush 推迟到回合结束时根据 `stop_reason` 决定。 + +**`onTurnEnd(stopReason)`**(新回调,由 `provider.ts` 在 `message_delta` 事件时触发): + +```ts +const turnText = turnBuffer; +turnBuffer = ''; + +if (stopReason === 'tool_use') { + // interstitial:按段落边界 flush(保留 agent loop 进度的实时性) + await flushInterstitial(turnText); +} else { + // end_turn / max_tokens / 等:标记为最终答案,不立即发 + pendingFinal += (pendingFinal && turnText) ? '\n\n' : ''; + pendingFinal += turnText; +} +``` + +**流结束**(`claudeQuery` 返回后): + +```ts +// 1. 先发最终答案(splitMessage 按 4000 字硬切,自然按段落打包) +await flushFinal(pendingFinal); +// 2. drain 残留 interstitial(保险) +await flushInterstitial(turnBuffer); +// 3. 安全网:完全没流式过任何内容时,用 result.text 整段发 +if (!anySent && result.text) { + for (const chunk of splitMessage(result.text)) await sender.sendText(...); +} +``` + +### 5.3 `flushInterstitial` 与 `flushFinal` + +两者都复用现有 `flushChain`(串行 promise 链)保证发送顺序,都调用 `splitMessage`: + +- `flushInterstitial(text)`:把 text 通过 `splitMessage` 切(对长 interstitial 也能处理),逐块 `sender.sendText`。 +- `flushFinal(text)`:同上。差别只在**调用时机**——interstitial 在回合结束立即调用,final 只在流结束调用。 + +实现上可以参数化合并成一个 `flush(text, role)` 函数,role 仅用于日志区分。 + +### 5.4 边界情况 + +| 场景 | 行为 | +|---|---| +| 纯 Q&A(无 tool_use,单回合 end_turn) | 所有文本进 `pendingFinal`,流结束一次性发。**这正是 Trace A 的 8→1**。 | +| Agent 多轮 tool_use 后给最终答案 | 每个 tool_use 回合的文本立即当 interstitial 推;最后的 end_turn 回合 buffer 到流结束一次性推。 | +| 最终答案超 4000 字 | `splitMessage` 按段落边界硬切成 N 块(N = ceil(字数/4000))。Trace D 的 9121 字 → 3 块。 | +| 多个 end_turn 回合(罕见,如 `pause_turn` 后续接 end_turn) | 用 `\n\n` 连接累积到 `pendingFinal`,结束时一起发。 | +| Abort(被新消息打断) | `provider.ts` 现有 `onAbort` 捕获 `partialText` 返回。`sendToClaude` 的 `finally` 会 drain 两个 buffer(interstitial + final);被打断时部分最终答案也能推送给用户,不丢内容。 | +| 流式完全失败(`onText` 从未触发) | `anySent` 保持 false,安全网分支用 `result.text` 整段发(沿用现有逻辑)。 | + +### 5.5 不变的部分 + +- `splitMessage` / `parseBlocks` / `findSafeSplitPoint` / `splitByNewline`:完全不动,仍是最终切分手段。 +- `MAX_MESSAGE_LENGTH = 4000`:不动。 +- silence warning 5min 兜底(`flushTimer`):保留。长最终答案生成期间 typing 指示器 + 5min 兜底仍在。 +- `result.text` 写入 chat history:不变。 +- `api.ts` 限流逻辑:不变。 + +### 5.6 删除的死代码 + +新方案不再需要按段落边界判断 flush 时机,以下符号变成死代码,一并删除(遵循项目"不留无用符号"规范): + +- `MIN_BATCH_FLUSH_LEN`(常量) +- `SOFT_FLUSH_LIMIT`(常量) +- `endsWithStructuralBoundary`(函数) + +`onBlockEnd` 回调也不再需要——回合边界由 `onTurnEnd` 接管。`QueryOptions.onBlockEnd` 字段从 `provider.ts` 删除,`main.ts` 不再传它。 + +## 6. 改动文件 + +| 文件 | 改动 | +|---|---| +| `src/claude/provider.ts` | `stream_event` 分支里,`message_delta` 事件触发新回调 `onTurnEnd(stopReason)`。`QueryOptions` 增加可选字段 `onTurnEnd?: (stopReason: string) => void`,**删除** `onBlockEnd` 字段及对应的 `content_block_stop` 分支调用(不再需要)。 | +| `src/main.ts` | `sendToClaude` 内:把单个 `textBuffer` 拆成 `turnBuffer` + `pendingFinal`;`onText` 只累积不 flush;新增 `onTurnEnd` 路由;流结束顺序改为先 `flushFinal` 再 `flushInterstitial`;**删除** `MIN_BATCH_FLUSH_LEN`、`SOFT_FLUSH_LIMIT`、`endsWithStructuralBoundary`、`onBlockEnd` 入参。`splitMessage` 等辅助函数不动。 | + +**改动量预估**:`provider.ts` 加约 5 行(一个 case 分支 + 类型定义)- 3 行(删 onBlockEnd);`main.ts` 改约 30-40 行(路由重写)+ 删约 15 行(死代码)。净增量小。 + +## 7. 验证计划 + +### 7.1 单元测试 + +- `splitMessage` 行为不变(现有测试继续通过)。 +- 新增:构造模拟的 `turn` 序列,验证 `simulateProposed` 对各种 `stop_reason` 组合的分流正确。 + +### 7.2 集成验证(用真实 trace 回放) + +复用本次 brainstorming 期间的模拟器 `/tmp/claude-stream-test/simulate.mjs`,把 `provider.ts` 改完后的真实输出再抓一次 trace,确认推送条数符合预期: +- 纯 Q&A → 1 条 +- 多 tool_use + 最终答案 → interstitial 数 + 1(或按 4000 字切的 N 块) + +### 7.3 手测(微信端) + +1. 在微信里发"用三段话解释闭包"→ 应该收到 1 条完整答案(而非现在的 8 条)。 +2. 发"分析 src/main.ts 的结构"→ 应该收到 1 条 interstitial + 3 条最终答案块(而非现在的 59 条)。 +3. 发一个会触发 abort 的任务(`/stop`)→ 已推的 interstitial 不丢,未发的最终答案部分按已生成的部分推送。 + +## 8. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| Agent 单回合内既有长文本又有 tool_use(如先写 500 字解释再调工具) | 文本会延迟到回合结束(看到 `stop_reason=tool_use`)才作为 interstitial 推送。延迟量级 = 回合内文本生成时间,通常几秒。silence warning 5min 兜底覆盖极端情况。 | +| `message_delta` 事件因 CLI 异常未触发 | 流结束时的 `flushFinal(pendingFinal)` + `flushInterstitial(turnBuffer)` 兜底 drain,任何已累积的文本都不会丢。 | +| `stop_reason` 取值未来扩展(新枚举) | 默认走 `else` 分支当最终答案处理——对新值也是安全选择(宁可少切也不丢内容)。 | +| Interstitial 过长(agent 在工具间吐大段评注) | `flushInterstitial` 走 `splitMessage`,会按 4000 字硬切。行为与现状一致。 | + +## 9. 未来可考虑的改进(不在本次 scope) + +- **限流改造**:如果 Problem 1 修复后重度 agent loop 仍频繁撞墙,再考虑把 `api.ts` 的固定间隔改成 60s 滑动窗口(~8 条上限)。 +- **长最终答案期间的进度反馈**:用户当前选择"完全等完再发"。若未来希望长答案期间有部分漏出,可在 `pendingFinal` 超过某阈值(如 5000 字)或等待超过 60s 时,主动 flush 一次部分内容。 +- **更激进的 interstitial 合并**:把连续多个 tool_use 回合的 interstitial 攒到一起发,进一步减少条数。代价是 agent loop 实时性下降。 diff --git a/docs/superpowers/specs/2026-06-27-tool-noise-filter-design.md b/docs/superpowers/specs/2026-06-27-tool-noise-filter-design.md new file mode 100644 index 0000000..1309f89 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-tool-noise-filter-design.md @@ -0,0 +1,92 @@ +# 工具噪音过滤 — 设计文档 + +**日期**:2026-06-27 +**动机**:上游 LLM 供应商(Z.ai 等聚合代理)把"服务端内置工具"的输入输出(JSON +参数、URL、识别结果等)以文本形式下放到 `text_delta`,bridge 不加区分地透传到微信, +导致用户在后半程看到"全是代码"的消息,体验急剧恶化,并加速消耗 iLink 11 条配额。 + +## 目标 + +- 在 bridge 侧识别并剥离这类"工具噪音",只保留 Claude 的真正旁白 +- **不依赖任何 provider 特定签名**(emoji、关键词都会变),用结构特征判定 +- 不误伤 Claude 正常写给用户的内容(多行总结、代码示例、路径说明) + +## 非目标 + +- 不修改 provider 侧行为(Z.ai 是第三方,改不动) +- 不解析"工具名"——保持中性占位 `🔧 [工具调用]`,避免再依赖任何命名约定 +- 不做 `/verbose` 调试开关(v1 不需要,简化迭代) + +## 规则 + +### 分类器(三个条件 AND) + +一段文字**同时**满足以下三个特征才被判定为工具噪音: + +1. **长度 > 400 字符** — Claude 单段旁白极少超过此规模 +2. **包含 ``` ```json ``` ``` 围栏代码块** — 工具输入序列化的强信号 +3. **包含 URL 或绝对路径**(`https?://...` / `/Users/...` / `/home/...` / `/tmp/...` + 等)— 工具调用几乎都引用资源定位符 + +任一条件不满足即原样返回,零成本短路。 + +### 剥离器(行级结构判定) + +按 `\n` 把文本切成行,逐行判定是否为"结构性行"。结构性行的定义: + +- 空行(段落分隔) +- ``` ``` ``` 开头(代码围栏) +- `*` 开头(markdown 加粗 / 斜体,覆盖 `**Input:**` / `*Executing...*` 等) +- `[` / `]` / `{` / `}` 开头(JSON 括号) +- 含 `\\n` 字面量(JSON 字符串里的换行转义) +- `"key":` 开头(JSON 键值对) +- `* ` 开头(markdown 斜体列表项) + +扫描所有行,记录**最后一个结构性行**的位置 `lastStructuralIdx`。`lastStructuralIdx + 1` +之后的所有行就是 Claude 的旁白。整段替换为: + +``` +🔧 [工具调用] — <旁白> +``` + +若没有旁白(结尾仍全是结构性内容),就只输出 `🔧 [工具调用]`。 + +## 接入点 + +`src/main.ts:587`,`TurnRouter` 的 emit 回调: + +```ts +const router = new TurnRouter((msg) => + emitText(filterToolNoise(msg.text), msg.role), +); +``` + +单一咽喉,所有送微信的文字都过这一道。不动 provider、router、sender。 + +## 为什么用结构检测而不是签名匹配 + +| 维度 | 签名匹配 | 结构检测(本设计) | +|---|---|---| +| Z.ai 改 emoji | ❌ 失效 | ✅ 仍命中 | +| 换 provider | ❌ 重写规则 | ✅ 仍命中 | +| 命中精度 | 高 | 高(三条件 AND 收紧) | +| 误伤风险 | 低 | 低(必须 ``` ```json ``` ``` + URL) | +| 维护成本 | 每次格式变都改 | 零维护 | + +## 测试 + +`src/tests/tool-noise-filter.test.ts`,fixture 覆盖: + +- **7 条命中样本**(来自今日真实日志,逐一断言"被压缩 + 旁白正确提取") +- **8 条正常样本**(含路径、代码块、表格的长总结——逐一断言"原样返回") +- **边界**:空串、纯短消息、有 JSON 但无 URL、有 URL 但无 JSON、长度恰好 400 + +Fixture 文件落在 `src/tests/fixtures/tool-noise/real-cases.json`,从今日 log +直接提取,未来回归测试可对照真实数据。 + +## 已验证效果(基于今日真实日志) + +- 总发送:361 条 +- 命中:7 条(全部为 Z.ai analyze_image dump) +- 误伤:0 +- 命中消息字符消耗:5472 → 504(压缩 90.8%) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c697f98 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,703 @@ +{ + "name": "wechat-claude-code", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wechat-claude-code", + "version": "1.0.0", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "qrcode": "^1.5.4", + "qrcode-terminal": "^0.12.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", + "@types/qrcode-terminal": "^0.12.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.1.77", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.77.tgz", + "integrity": "sha512-ZEjWQtkoB2MEY6K16DWMmF+8OhywAynH0m08V265cerbZ8xPD/2Ng2jPzbbO40mPeFSsMDJboShL+a3aObP0Jg==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-linuxmusl-arm64": "^0.33.5", + "@img/sharp-linuxmusl-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qrcode-terminal": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", + "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..eace5c4 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "wechat-claude-code", + "version": "1.0.0", + "description": "Chat with Claude Code from WeChat - a Claude Code Skill that bridges personal WeChat to local Claude Code", + "type": "module", + "scripts": { + "build": "tsc", + "postinstall": "npm run build", + "start": "node dist/main.js", + "run": "node dist/main.js start", + "dev": "tsc --watch", + "test": "node --test dist/tests/*.test.js", + "setup": "node dist/main.js setup", + "daemon": "bash scripts/daemon.sh", + "visualize": "npx tsx src/tools/visualize-logs.ts" + }, + "dependencies": { +"qrcode": "^1.5.4", + "qrcode-terminal": "^0.12.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", + "@types/qrcode-terminal": "^0.12.0", + "typescript": "^5.7.0" + }, + "keywords": ["wechat", "claude-code", "claude", "bridge", "chat", "skill"], + "author": "Wechat-ggGitHub", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Wechat-ggGitHub/wechat-claude-code.git" + } +} diff --git a/scripts/daemon.sh b/scripts/daemon.sh new file mode 100755 index 0000000..acb05fe --- /dev/null +++ b/scripts/daemon.sh @@ -0,0 +1,422 @@ +#!/bin/bash +set -euo pipefail + +# ============================================================================= +# wechat-claude-code cross-platform daemon manager +# Supports: macOS (launchd) / Linux (systemd + nohup fallback) +# ============================================================================= + +DATA_DIR="${HOME}/.wechat-claude-code" +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SERVICE_NAME="wechat-claude-code" + +# Platform detection +OS_TYPE="$(uname -s)" + +# ============================================================================= +# macOS (launchd) functions +# ============================================================================= + +macos_plist_label() { + echo "com.wechat-claude-code.bridge" +} + +macos_plist_path() { + echo "${HOME}/Library/LaunchAgents/$(macos_plist_label).plist" +} + +macos_is_loaded() { + launchctl print "gui/$(id -u)/$(macos_plist_label)" &>/dev/null +} + +macos_start() { + local plist_label="$(macos_plist_label)" + local plist_path="$(macos_plist_path)" + local node_bin="$(command -v node || echo '/usr/local/bin/node')" + + if macos_is_loaded; then + echo "Already running (or plist loaded)" + exit 0 + fi + + mkdir -p "$DATA_DIR/logs" + + # Collect Anthropic/Claude env vars for plist + local plist_extra_env="" + for var in ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_BASE_URL CLAUDE_API_KEY; do + if [ -n "${!var:-}" ]; then + plist_extra_env="${plist_extra_env} ${var} + ${!var} +" + fi + done + + cat > "$plist_path" < + + + + Label + ${plist_label} + ProgramArguments + + ${node_bin} + ${PROJECT_DIR}/dist/main.js + start + + WorkingDirectory + ${PROJECT_DIR} + RunAtLoad + + KeepAlive + + StandardOutPath + ${DATA_DIR}/logs/stdout.log + StandardErrorPath + ${DATA_DIR}/logs/stderr.log + EnvironmentVariables + + PATH + ${HOME}/.local/bin:${node_bin%/*}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin +${plist_extra_env} + + +PLIST + + launchctl load "$plist_path" + echo "Started wechat-claude-code daemon (macOS launchd)" +} + +macos_stop() { + local plist_label="$(macos_plist_label)" + local plist_path="$(macos_plist_path)" + + launchctl bootout "gui/$(id -u)/${plist_label}" 2>/dev/null || true + rm -f "$plist_path" + echo "Stopped wechat-claude-code daemon (macOS launchd)" +} + +macos_status() { + if macos_is_loaded; then + local pid=$(pgrep -f "dist/main.js start" 2>/dev/null | head -1) + if [ -n "$pid" ]; then + echo "Running (PID: $pid)" + else + echo "Loaded but not running" + fi + else + echo "Not running" + fi +} + +macos_logs() { + local log_dir="${DATA_DIR}/logs" + if [ -d "$log_dir" ]; then + local latest=$(ls -t "${log_dir}"/bridge-*.log 2>/dev/null | head -1) + if [ -n "$latest" ]; then + tail -100 "$latest" + else + echo "No bridge logs found. Checking stdout/stderr:" + for f in "${log_dir}"/stdout.log "${log_dir}"/stderr.log; do + if [ -f "$f" ]; then + echo "=== $(basename "$f") ===" + tail -30 "$f" + fi + done + fi + else + echo "No logs found" + fi +} + +# ============================================================================= +# Linux (systemd) functions +# ============================================================================= + +linux_ensure_user_session() { + if [ -z "${XDG_RUNTIME_DIR:-}" ]; then + export XDG_RUNTIME_DIR="/run/user/$(id -u)" + mkdir -p "$XDG_RUNTIME_DIR" 2>/dev/null || true + fi + if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then + export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus" + fi +} + +linux_service_file() { + echo "${HOME}/.config/systemd/user/${SERVICE_NAME}.service" +} + +linux_pid_file() { + echo "${DATA_DIR}/${SERVICE_NAME}.pid" +} + +linux_node_bin() { + local node_bin="$(command -v node 2>/dev/null || echo '')" + if [ -z "$node_bin" ]; then + local nvm_default="${NVM_DIR:-${HOME}/.nvm}/versions/node" + if [ -d "$nvm_default" ]; then + node_bin="$(find "$nvm_default" -name "node" -type f 2>/dev/null | head -1)" + fi + fi + echo "${node_bin:-/usr/bin/node}" +} + +linux_systemd_available() { + linux_ensure_user_session + systemctl --user list-units &>/dev/null +} + +linux_create_service_file() { + local service_file="$(linux_service_file)" + local node_bin="$(linux_node_bin)" + + mkdir -p "$(dirname "$service_file")" + + # Collect Anthropic/Claude env vars to pass through to the service + local extra_env="" + for var in ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_BASE_URL CLAUDE_API_KEY; do + if [ -n "${!var:-}" ]; then + extra_env="${extra_env}Environment=${var}=${!var} +" + fi + done + + cat > "$service_file" </dev/null || true +} + +linux_direct_start() { + local pid_file="$(linux_pid_file)" + local node_bin="$(linux_node_bin)" + + if [ -f "$pid_file" ]; then + local old_pid=$(cat "$pid_file" 2>/dev/null) + if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then + echo "Already running (PID: $old_pid)" + exit 0 + fi + rm -f "$pid_file" + fi + + mkdir -p "$DATA_DIR/logs" + + echo "Starting wechat-claude-code daemon (direct mode)..." + nohup "$node_bin" "${PROJECT_DIR}/dist/main.js" start \ + >> "$DATA_DIR/logs/stdout.log" \ + 2>> "$DATA_DIR/logs/stderr.log" & + local pid=$! + echo "$pid" > "$pid_file" + echo "Started (PID: $pid)" + echo "Logs: $DATA_DIR/logs/stdout.log" +} + +linux_direct_stop() { + local pid_file="$(linux_pid_file)" + + if [ ! -f "$pid_file" ]; then + echo "Not running (no PID file)" + exit 0 + fi + + local pid=$(cat "$pid_file" 2>/dev/null) + if [ -z "$pid" ]; then + rm -f "$pid_file" + echo "Stopped" + exit 0 + fi + + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + local count=0 + while kill -0 "$pid" 2>/dev/null && [ $count -lt 10 ]; do + sleep 1 + count=$((count + 1)) + done + kill -9 "$pid" 2>/dev/null || true + echo "Stopped (PID: $pid)" + else + echo "Process not running (cleaning up PID file)" + fi + + rm -f "$pid_file" +} + +linux_direct_status() { + local pid_file="$(linux_pid_file)" + + if [ ! -f "$pid_file" ]; then + echo "Not running" + exit 0 + fi + + local pid=$(cat "$pid_file" 2>/dev/null) + if [ -z "$pid" ]; then + echo "Not running (invalid PID file)" + exit 0 + fi + + if kill -0 "$pid" 2>/dev/null; then + echo "Running (PID: $pid)" + else + echo "Not running (stale PID file)" + fi +} + +linux_start() { + if linux_systemd_available; then + local service_file="$(linux_service_file)" + + if systemctl --user is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then + echo "Already running" + exit 0 + fi + + mkdir -p "$DATA_DIR/logs" + linux_create_service_file + linux_reload_daemon + + systemctl --user start "${SERVICE_NAME}" + systemctl --user enable "${SERVICE_NAME}" 2>/dev/null || true + echo "Started wechat-claude-code daemon (Linux systemd)" + else + echo "Note: systemd user session not available, using direct mode" + echo "To enable systemd mode, run: 'loginctl enable-linger $(whoami)'" + echo "" + linux_direct_start + fi +} + +linux_stop() { + if linux_systemd_available && systemctl --user cat "${SERVICE_NAME}" &>/dev/null; then + systemctl --user stop "${SERVICE_NAME}" 2>/dev/null || true + systemctl --user disable "${SERVICE_NAME}" 2>/dev/null || true + echo "Stopped wechat-claude-code daemon (Linux systemd)" + else + linux_direct_stop + fi +} + +linux_restart() { + linux_stop + sleep 1 + linux_start +} + +linux_status() { + if linux_systemd_available && systemctl --user cat "${SERVICE_NAME}" &>/dev/null; then + if systemctl --user is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then + local pid=$(systemctl --user show-property --value=MainPID "${SERVICE_NAME}" 2>/dev/null) + if [ -n "$pid" ] && [ "$pid" != "0" ]; then + echo "Running (PID: $pid)" + else + echo "Active" + fi + else + echo "Not running" + fi + + if systemctl --user cat "${SERVICE_NAME}" &>/dev/null; then + echo "" + systemctl --user status "${SERVICE_NAME}" --no-pager 2>/dev/null || true + fi + else + linux_direct_status + fi +} + +linux_logs() { + if command -v journalctl >/dev/null 2>&1; then + if journalctl --user --unit="${SERVICE_NAME}" --quiet &>/dev/null; then + echo "=== systemd journal logs (last 100 lines) ===" + journalctl --user --unit="${SERVICE_NAME}" --no-pager -n 100 2>/dev/null || true + echo "" + echo "=== File logs ===" + fi + fi + + local log_dir="${DATA_DIR}/logs" + if [ -d "$log_dir" ]; then + for f in "${log_dir}"/stdout.log "${log_dir}"/stderr.log; do + if [ -f "$f" ]; then + echo "=== $(basename "$f") ===" + tail -50 "$f" + echo "" + fi + done + else + echo "No logs found" + fi +} + +# ============================================================================= +# Main dispatcher +# ============================================================================= + +main() { + local command="${1:-}" + + case "$OS_TYPE" in + Darwin) + case "$command" in + start) macos_start ;; + stop) macos_stop ;; + restart) macos_stop; sleep 1; macos_start ;; + status) macos_status ;; + logs) macos_logs ;; + *) + echo "Usage: daemon.sh {start|stop|restart|status|logs}" + echo "Platform: macOS (launchd)" + exit 1 + ;; + esac + ;; + Linux) + case "$command" in + start) linux_start ;; + stop) linux_stop ;; + restart) linux_restart ;; + status) linux_status ;; + logs) linux_logs ;; + *) + echo "Usage: daemon.sh {start|stop|restart|status|logs}" + echo "Platform: Linux (systemd)" + exit 1 + ;; + esac + ;; + *) + echo "Error: Unsupported platform '$OS_TYPE'" + echo "Supported platforms: macOS (Darwin), Linux" + exit 1 + ;; + esac +} + +main "$@" diff --git a/src/claude/provider.ts b/src/claude/provider.ts new file mode 100644 index 0000000..252f756 --- /dev/null +++ b/src/claude/provider.ts @@ -0,0 +1,314 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createInterface } from 'node:readline'; +import { logger } from '../logger.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface QueryOptions { + prompt: string; + cwd: string; + resume?: string; + model?: string; + systemPrompt?: string; + images?: Array<{ + type: "image"; + source: { type: "base64"; media_type: string; data: string }; + }>; + /** Called each time an assistant text chunk is produced (e.g. before/after tool calls). */ + onText?: (text: string) => Promise | void; + /** Called when an assistant turn ends, with its stop_reason + * ('tool_use' | 'end_turn' | 'max_tokens' | 'stop_sequence' | 'pause_turn' | ...). + * Use to decide whether the turn's text is interstitial or final answer. */ + onTurnEnd?: (stopReason: string) => Promise | void; + /** Optional abort controller to cancel the query (e.g. when user sends a new message). */ + abortController?: AbortController; +} + +export interface QueryResult { + text: string; + sessionId: string; + error?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TEMP_DIR = join(tmpdir(), 'wechat-claude-code'); + +function saveImageTemp(images: NonNullable): string[] { + mkdirSync(TEMP_DIR, { recursive: true }); + const paths: string[] = []; + for (const img of images) { + const ext = img.source.media_type.split('/')[1] || 'png'; + const fileName = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; + const filePath = join(TEMP_DIR, fileName); + writeFileSync(filePath, Buffer.from(img.source.data, 'base64')); + paths.push(filePath); + } + return paths; +} + +function cleanupTempFiles(paths: string[]): void { + for (const p of paths) { + try { unlinkSync(p); } catch { /* ignore */ } + } +} + +// --------------------------------------------------------------------------- +// Stream parser (extracted for testability) +// --------------------------------------------------------------------------- + +export interface StreamParserState { + sessionId: string; + textParts: string[]; + errorMessage?: string; + trackingSkill: boolean; + skillInputAccum: string; +} + +export interface StreamParserCallbacks { + onText?: (text: string) => void; + onTurnEnd?: (stopReason: string) => void; +} + +export function handleStreamLine( + line: string, + state: StreamParserState, + callbacks: StreamParserCallbacks, +): void { + if (!line.trim()) return; + let obj: any; + try { + obj = JSON.parse(line); + } catch { + return; + } + + switch (obj.type) { + case 'system': { + if (obj.subtype === 'init' && obj.session_id) { + state.sessionId = obj.session_id; + } + break; + } + case 'assistant': { + const content = obj.message?.content; + if (Array.isArray(content)) { + const text = content + .filter((b: any) => b.type === 'text') + .map((b: any) => b.text ?? '') + .join(''); + if (text) state.textParts.push(text); + } + break; + } + case 'stream_event': { + const evt = obj.event; + if (evt?.type === 'content_block_start' && evt.content_block?.type === 'tool_use') { + if (evt.content_block.name === 'Skill') { + state.trackingSkill = true; + state.skillInputAccum = ''; + } + } else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'text_delta') { + const delta: string = evt.delta.text; + if (delta && callbacks.onText) { + Promise.resolve(callbacks.onText(delta)).catch(() => {}); + } + } else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'input_json_delta' && state.trackingSkill) { + state.skillInputAccum += evt.delta.partial_json ?? ''; + try { + const parsed = JSON.parse(state.skillInputAccum); + if (parsed.skill) { + const msg = `\n正在调用 ${parsed.skill} 技能\n\n`; + if (callbacks.onText) Promise.resolve(callbacks.onText(msg)).catch(() => {}); + state.trackingSkill = false; + } + } catch { + // JSON not complete yet + } + } else if (evt?.type === 'content_block_stop') { + state.trackingSkill = false; + } else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) { + if (callbacks.onTurnEnd) Promise.resolve(callbacks.onTurnEnd(evt.delta.stop_reason)).catch(() => {}); + } + break; + } + case 'result': { + if (obj.result && typeof obj.result === 'string') { + const combined = state.textParts.join(''); + if (!combined.includes(obj.result)) { + state.textParts.push(obj.result); + } + } + if (obj.subtype === 'error' || (obj.errors && obj.errors.length > 0)) { + const errors = obj.errors ?? [obj.error_message ?? 'Unknown error']; + state.errorMessage = Array.isArray(errors) ? errors.join('; ') : String(errors); + logger.error('CLI returned error result', { errors }); + } + break; + } + default: + break; + } +} + +// --------------------------------------------------------------------------- +// Core function +// --------------------------------------------------------------------------- + +export async function claudeQuery(options: QueryOptions): Promise { + const { + prompt, + cwd, + resume, + model, + systemPrompt, + images, + onText, + onTurnEnd, + abortController, + } = options; + + logger.info("Starting Claude CLI query", { + cwd, + model, + resume: !!resume, + hasImages: !!images?.length, + }); + + // Build CLI arguments + const args: string[] = [ + '-p', '-', + '--output-format', 'stream-json', + '--verbose', + '--include-partial-messages', + '--dangerously-skip-permissions', + ]; + + if (resume) args.push('--resume', resume); + if (model) args.push('--model', model); + if (systemPrompt) args.push('--append-system-prompt', systemPrompt); + + // Handle images: save to temp files and append paths to prompt + const tempImagePaths = images?.length ? saveImageTemp(images) : []; + let fullPrompt = prompt; + if (tempImagePaths.length > 0) { + const imageLines = tempImagePaths.map(p => `\n![image](file://${p})`).join(''); + fullPrompt += imageLines; + } + + // Accumulators + let child: ChildProcess | undefined; + let settled = false; + + const QUERY_TIMEOUT_MS = 60 * 60 * 1000; + + return new Promise((resolve) => { + const finish = (result: QueryResult) => { + if (settled) return; + settled = true; + cleanupTempFiles(tempImagePaths); + resolve(result); + }; + + try { + child = spawn('claude', args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + finish({ text: '', sessionId: '', error: `Failed to spawn claude: ${msg}` }); + return; + } + + // Write prompt to stdin and close + child.stdin!.write(fullPrompt); + child.stdin!.end(); + + // Timeout + const timeoutId = setTimeout(() => { + logger.warn('Claude CLI query timed out, killing process'); + child!.kill('SIGTERM'); + const partialText = parserState.textParts.join('\n').trim(); + finish({ + text: partialText, + sessionId: parserState.sessionId, + error: partialText ? undefined : 'Claude query timed out after 60 minutes', + }); + }, QUERY_TIMEOUT_MS); + + // Abort handling + const onAbort = () => { + logger.info('Claude CLI query aborted'); + child!.kill('SIGTERM'); + const partialText = parserState.textParts.join('\n').trim(); + finish({ text: partialText, sessionId: parserState.sessionId }); + }; + abortController?.signal.addEventListener('abort', onAbort, { once: true }); + + // Collect stderr + const stderrParts: string[] = []; + child.stderr!.setEncoding('utf8'); + child.stderr!.on('data', (chunk: string) => { + stderrParts.push(chunk); + }); + + // Parse NDJSON from stdout (logic in handleStreamLine for testability) + const parserState: StreamParserState = { + sessionId: '', + textParts: [], + trackingSkill: false, + skillInputAccum: '', + }; + const parserCallbacks: StreamParserCallbacks = { onText, onTurnEnd }; + + const rl = createInterface({ input: child.stdout! }); + rl.on('line', (line: string) => { + handleStreamLine(line, parserState, parserCallbacks); + }); + + // Handle process exit + child.on('close', (code: number | null) => { + clearTimeout(timeoutId); + abortController?.signal.removeEventListener('abort', onAbort); + + if (code !== 0 && code !== null && !parserState.textParts.length && !parserState.errorMessage) { + const stderr = stderrParts.join('').trim(); + parserState.errorMessage = stderr || `claude exited with code ${code}`; + logger.error('Claude CLI exited with error', { code, stderr: stderr.slice(0, 500) }); + } + + const fullText = parserState.textParts.join('\n').trim(); + + if (!fullText && !parserState.errorMessage) { + parserState.errorMessage = 'Claude returned an empty response.'; + } + + logger.info("Claude CLI query completed", { + sessionId: parserState.sessionId, + textLength: fullText.length, + hasError: !!parserState.errorMessage, + }); + + finish({ + text: fullText, + sessionId: parserState.sessionId, + error: parserState.errorMessage, + }); + }); + + child.on('error', (err: Error) => { + clearTimeout(timeoutId); + abortController?.signal.removeEventListener('abort', onAbort); + finish({ text: '', sessionId: parserState.sessionId, error: `Failed to spawn claude: ${err.message}` }); + }); + }); +} diff --git a/src/claude/skill-scanner.ts b/src/claude/skill-scanner.ts new file mode 100644 index 0000000..9dad71a --- /dev/null +++ b/src/claude/skill-scanner.ts @@ -0,0 +1,159 @@ +import { readdirSync, readFileSync, existsSync, type Dirent } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { logger } from '../logger.js'; + +export interface SkillInfo { + name: string; + description: string; + path: string; +} + +/** + * Parse YAML-like frontmatter from a SKILL.md file. + * Only extracts `name` and `description` fields. + */ +function parseSkillMd(filePath: string): { name: string; description: string } | null { + try { + const content = readFileSync(filePath, 'utf-8'); + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + + const frontmatter = match[1]; + const nameMatch = frontmatter.match(/^name:\s*(.+)$/m); + const descMatch = frontmatter.match(/^description:\s*(.+)$/m); + + if (!nameMatch) return null; + + return { + name: nameMatch[1].trim().replace(/^["']|["']$/g, ''), + description: descMatch ? descMatch[1].trim().replace(/^["']|["']$/g, '') : '', + }; + } catch { + logger.warn(`Failed to read SKILL.md: ${filePath}`); + return null; + } +} + +/** + * Scan a directory for SKILL.md files, reading skill info from each. + */ +function scanDirectory(baseDir: string, depth: number = 2): SkillInfo[] { + const skills: SkillInfo[] = []; + + if (!existsSync(baseDir)) return skills; + + let entries: Dirent[]; + try { + entries = readdirSync(baseDir, { withFileTypes: true }); + } catch { + return skills; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const fullPath = join(baseDir, entry.name); + + if (depth > 1) { + // Recurse one level deeper + skills.push(...scanDirectory(fullPath, depth - 1)); + } + + const skillFile = join(fullPath, 'SKILL.md'); + if (existsSync(skillFile)) { + const info = parseSkillMd(skillFile); + if (info) { + skills.push({ ...info, path: fullPath }); + } + } + } + + return skills; +} + +/** + * Scan all known skill directories for installed Claude Code skills. + * + * Locations scanned: + * 1. ~/.claude/skills/ (each subdirectory) + * 2. ~/.claude/plugins/cache/{plugin}/skills/ (each subdirectory) + * 3. ~/.claude/plugins/cache/{plugin}/superpowers/skills/ (each subdirectory) + */ +export function scanAllSkills(): SkillInfo[] { + const home = homedir(); + const claudeDir = join(home, '.claude'); + const skills: SkillInfo[] = []; + const seen = new Set(); + + // 1. ~/.claude/skills/*/ + const userSkillsDir = join(claudeDir, 'skills'); + for (const skill of scanDirectory(userSkillsDir, 1)) { + if (!seen.has(skill.name)) { + seen.add(skill.name); + skills.push(skill); + } + } + + // 2. ~/.claude/plugins/cache/*/skills/*/ + const pluginsCacheDir = join(claudeDir, 'plugins', 'cache'); + if (existsSync(pluginsCacheDir)) { + let cacheEntries: Dirent[]; + try { + cacheEntries = readdirSync(pluginsCacheDir, { withFileTypes: true }); + } catch { + cacheEntries = []; + } + + for (const cacheEntry of cacheEntries) { + if (!cacheEntry.isDirectory()) continue; + const cacheDir = join(pluginsCacheDir, cacheEntry.name); + + // Regular skills + const pluginSkillsDir = join(cacheDir, 'skills'); + for (const skill of scanDirectory(pluginSkillsDir, 1)) { + if (!seen.has(skill.name)) { + seen.add(skill.name); + skills.push(skill); + } + } + + // Superpowers skills + const superpowersSkillsDir = join(cacheDir, 'superpowers', 'skills'); + for (const skill of scanDirectory(superpowersSkillsDir, 1)) { + if (!seen.has(skill.name)) { + seen.add(skill.name); + skills.push(skill); + } + } + } + } + + logger.info(`Scanned ${skills.length} skills`); + return skills; +} + +/** + * Format a list of skills into a readable string for display. + */ +export function formatSkillList(skills: SkillInfo[]): string { + if (skills.length === 0) { + return 'No skills found.'; + } + + const lines = skills.map((s, i) => { + const desc = s.description ? ` - ${s.description}` : ''; + return ` ${i + 1}. ${s.name}${desc}`; + }); + + return `Available skills (${skills.length}):\n${lines.join('\n')}`; +} + +/** + * Find a skill by name (case-insensitive match). + */ +export function findSkill(skills: SkillInfo[], name: string): SkillInfo | undefined { + const lower = name.toLowerCase(); + return skills.find( + (s) => s.name.toLowerCase() === lower || s.name.toLowerCase().replace(/\s+/g, '-') === lower, + ); +} diff --git a/src/claude/tool-noise-filter.ts b/src/claude/tool-noise-filter.ts new file mode 100644 index 0000000..18d7db8 --- /dev/null +++ b/src/claude/tool-noise-filter.ts @@ -0,0 +1,52 @@ +/** + * 工具噪音过滤器 + * + * 某些 LLM 供应商(如 Z.ai 等聚合代理)会把服务端内置工具的输入/输出(JSON 参数、 + * URL、识别结果等)以文本形式塞进 `text_delta` 流。bridge 无法在协议层区分这些 + * 内容与 Claude 的正常旁白,于是会被原样推到微信,造成"全是代码"的灾难体验。 + * + * 本模块用结构特征判定(不依赖任何 provider 签名),把命中段剥到只剩 Claude 的 + * 旁白,前面加上中性占位 `🔧 [工具调用]`。 + * + * 设计文档:docs/superpowers/specs/2026-06-27-tool-noise-filter-design.md + */ + +const FENCED_JSON = /```json\b[\s\S]*?```/i; +const URL_OR_PATH = /(https?:\/\/\S+)|(\/(?:Users|home|tmp|var|opt|etc)\/\S+)|(~\/\S+)/; + +const LENGTH_THRESHOLD = 400; + +function isStructuralLine(line: string): boolean { + if (!line.trim()) return true; // 段落分隔 + if (/^\s*```/.test(line)) return true; // 代码围栏 + if (/^\s*\*+/.test(line)) return true; // **粗体** / *斜体* 头 + if (/^\s*[\[\]\{\}]/.test(line)) return true; // JSON 括号 + if (/\\n/.test(line)) return true; // JSON 字符串里的换行转义 + if (/^\s*"\w+"\s*:/.test(line)) return true; // "key": value 行 + if (/^\s*\*\s/.test(line)) return true; // markdown 斜体列表项 + return false; +} + +/** 找出文本结尾处那段"Claude 的旁白",没有则返回空串。 */ +function extractTail(text: string): string { + const lines = text.split('\n'); + let lastStructuralIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (isStructuralLine(lines[i])) lastStructuralIdx = i; + } + if (lastStructuralIdx < 0 || lastStructuralIdx === lines.length - 1) return ''; + return lines.slice(lastStructuralIdx + 1).join('\n').trim(); +} + +/** + * 三条件 AND 判定:长度 > 阈值 + 含 ```json``` 围栏 + 含 URL 或绝对路径。 + * 任一不满足即原样返回。 + */ +export function filterToolNoise(text: string): string { + if (text.length <= LENGTH_THRESHOLD) return text; + if (!FENCED_JSON.test(text)) return text; + if (!URL_OR_PATH.test(text)) return text; + + const tail = extractTail(text); + return tail ? `🔧 [工具调用] — ${tail}` : '🔧 [工具调用]'; +} diff --git a/src/claude/turn-router.ts b/src/claude/turn-router.ts new file mode 100644 index 0000000..1a9eb14 --- /dev/null +++ b/src/claude/turn-router.ts @@ -0,0 +1,55 @@ +/** + * TurnRouter 把 Claude CLI 的流式输出按"回合"分流: + * + * - tool_use 回合的文本 → 立即作为 interstitial emit(agent loop 进度) + * - 其他 stop_reason(end_turn / max_tokens / stop_sequence / pause_turn / ...) + * 的文本 → 攒到 pendingFinal,drain 时一次性作为 final emit + * + * 设计参考 docs/superpowers/specs/2026-06-20-message-batching-design.md。 + * + * 本类不做任何 I/O,只决定"何时把哪段文本以什么 role emit"。 + * 调用方(main.ts)负责把 RoutedMessage 切分(splitMessage)并发到微信。 + */ + +export type MessageRole = 'interstitial' | 'final'; + +export interface RoutedMessage { + text: string; + role: MessageRole; +} + +export class TurnRouter { + private turnBuffer = ''; + private pendingFinal = ''; + + constructor(private readonly emit: (msg: RoutedMessage) => void) {} + + onText(delta: string): void { + this.turnBuffer += delta; + } + + onTurnEnd(stopReason: string): void { + const text = this.turnBuffer; + this.turnBuffer = ''; + if (!text.trim()) return; + if (stopReason === 'tool_use') { + this.emit({ text, role: 'interstitial' }); + } else { + // end_turn / max_tokens / stop_sequence / pause_turn / 未知值 + // 一律当最终答案处理(宁可合并也不丢) + this.pendingFinal += this.pendingFinal ? '\n\n' + text : text; + } + } + + /** 流结束时调用。先发 final,再 drain 残留 interstitial。 */ + drain(): void { + if (this.pendingFinal.trim()) { + this.emit({ text: this.pendingFinal, role: 'final' }); + this.pendingFinal = ''; + } + if (this.turnBuffer.trim()) { + this.emit({ text: this.turnBuffer, role: 'interstitial' }); + this.turnBuffer = ''; + } + } +} diff --git a/src/commands/handlers.ts b/src/commands/handlers.ts new file mode 100644 index 0000000..25586df --- /dev/null +++ b/src/commands/handlers.ts @@ -0,0 +1,233 @@ +import type { CommandContext, CommandResult } from './router.js'; +import { scanAllSkills, formatSkillList, findSkill, type SkillInfo } from '../claude/skill-scanner.js'; +import { loadConfig, saveConfig } from '../config.js'; +import { DEFAULT_WORKING_DIR } from '../constants.js'; +import { readFileSync, existsSync, statSync } from 'node:fs'; +import { resolve, basename, join } from 'node:path'; +import { homedir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const HELP_TEXT = `可用命令: + +会话管理: + /help 显示帮助 + /stop 停止当前对话并清空排队消息 + /clear 清除当前会话 + /reset 完全重置(包括工作目录等设置) + /status 查看当前会话状态 + /compact 压缩上下文(开始新 SDK 会话,保留历史) + /history [数量] 查看对话记录(默认最近20条) + /undo [数量] 撤销最近对话(默认1条) + +文件: + /send <路径> 发送本地文件(图片直接显示,其他文件作为附件) + +配置: + /cwd [路径] 查看或切换工作目录 + /model [名称] 查看或切换 Claude 模型 + /prompt [内容] 查看或设置系统提示词(全局生效) + +其他: + /skills [full] 列出已安装的 skill(full 显示描述) + /version 查看版本信息 + / [参数] 触发已安装的 skill + +直接输入文字即可与 Claude Code 对话`; + +// 缓存 skill 列表,避免每次命令都扫描文件系统 +let cachedSkills: SkillInfo[] | null = null; +let lastScanTime = 0; +const CACHE_TTL = 60_000; // 60秒 + +function getSkills(): SkillInfo[] { + const now = Date.now(); + if (!cachedSkills || now - lastScanTime > CACHE_TTL) { + cachedSkills = scanAllSkills(); + lastScanTime = now; + } + return cachedSkills; +} + +/** 清除缓存,用于 /skills 命令强制刷新 */ +export function invalidateSkillCache(): void { + cachedSkills = null; +} + +export function handleHelp(_args: string): CommandResult { + return { reply: HELP_TEXT, handled: true }; +} + +export function handleClear(ctx: CommandContext): CommandResult { + const newSession = ctx.clearSession(); + Object.assign(ctx.session, newSession); + return { reply: '✅ 会话已清除,下次消息将开始新会话。', handled: true }; +} + +export function handleCwd(ctx: CommandContext, args: string): CommandResult { + if (!args) { + return { reply: `当前工作目录: ${ctx.session.workingDirectory}\n用法: /cwd <路径>`, handled: true }; + } + ctx.updateSession({ workingDirectory: args }); + return { reply: `✅ 工作目录已切换为: ${args}`, handled: true }; +} + +export function handleModel(ctx: CommandContext, args: string): CommandResult { + if (!args) { + return { reply: '用法: /model <模型名称>\n例: /model claude-sonnet-4-6', handled: true }; + } + ctx.updateSession({ model: args }); + return { reply: `✅ 模型已切换为: ${args}`, handled: true }; +} + +export function handleStatus(ctx: CommandContext): CommandResult { + const s = ctx.session; + const lines = [ + '📊 会话状态', + '', + `工作目录: ${s.workingDirectory}`, + `模型: ${s.model ?? '默认'}`, + `会话ID: ${s.sdkSessionId ?? '无'}`, + `状态: ${s.state}`, + ]; + return { reply: lines.join('\n'), handled: true }; +} + +export function handleSkills(args: string): CommandResult { + invalidateSkillCache(); + const skills = getSkills(); + if (skills.length === 0) { + return { reply: '未找到已安装的 skill。', handled: true }; + } + + const showFull = args.trim().toLowerCase() === 'full'; + if (showFull) { + const lines = skills.map(s => `/${s.name}\n ${s.description}`); + return { reply: `📋 已安装的 Skill (${skills.length}):\n\n${lines.join('\n\n')}`, handled: true }; + } + const lines = skills.map(s => `/${s.name}`); + return { reply: `📋 已安装的 Skill (${skills.length}):\n\n${lines.join('\n')}\n\n使用 /skills full 查看完整描述`, handled: true }; +} + +const MAX_HISTORY_LIMIT = 100; + +export function handleHistory(ctx: CommandContext, args: string): CommandResult { + const limit = args ? parseInt(args, 10) : 20; + if (isNaN(limit) || limit <= 0) { + return { reply: '用法: /history [数量]\n例: /history 50(显示最近50条对话)', handled: true }; + } + const effectiveLimit = Math.min(limit, MAX_HISTORY_LIMIT); + + const historyText = ctx.getChatHistoryText?.(effectiveLimit) || '暂无对话记录'; + + return { reply: `📝 对话记录(最近${effectiveLimit}条):\n\n${historyText}`, handled: true }; +} + +/** 完全重置会话(包括工作目录等设置) */ +export function handleReset(ctx: CommandContext): CommandResult { + const newSession = ctx.clearSession(); + newSession.workingDirectory = DEFAULT_WORKING_DIR; + Object.assign(ctx.session, newSession); + return { reply: '✅ 会话已完全重置,所有设置恢复默认。', handled: true }; +} + +/** 压缩上下文 — 清除 SDK 会话 ID,开始新上下文但保留聊天历史 */ +export function handleCompact(ctx: CommandContext): CommandResult { + const currentSessionId = ctx.session.sdkSessionId; + if (!currentSessionId) { + return { reply: 'ℹ️ 当前没有活动的 SDK 会话,无需压缩。', handled: true }; + } + ctx.updateSession({ + previousSdkSessionId: currentSessionId, + sdkSessionId: undefined, + }); + return { + reply: '✅ 上下文已压缩\n\n下次消息将开始新的 SDK 会话(token 清零)\n聊天历史已保留,可用 /history 查看', + handled: true, + }; +} + +/** 撤销最近 N 条对话 */ +export function handleUndo(ctx: CommandContext, args: string): CommandResult { + const count = args ? parseInt(args, 10) : 1; + if (isNaN(count) || count <= 0) { + return { reply: '用法: /undo [数量]\n例: /undo 2(撤销最近2条对话)', handled: true }; + } + const history = ctx.session.chatHistory || []; + if (history.length === 0) { + return { reply: '⚠️ 没有对话记录可撤销', handled: true }; + } + const actualCount = Math.min(count, history.length); + ctx.session.chatHistory = history.slice(0, -actualCount); + ctx.updateSession({ chatHistory: ctx.session.chatHistory }); + return { reply: `✅ 已撤销最近 ${actualCount} 条对话`, handled: true }; +} + +/** 查看版本信息 */ +export function handleVersion(): CommandResult { + try { + const __dirname = fileURLToPath(new URL('.', import.meta.url)); + const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')); + const version = pkg.version || 'unknown'; + return { reply: `wechat-claude-code v${version}`, handled: true }; + } catch { + return { reply: 'wechat-claude-code (version unknown)', handled: true }; + } +} + +export function handlePrompt(_ctx: CommandContext, args: string): CommandResult { + const config = loadConfig(); + if (!args) { + const current = config.systemPrompt; + if (current) { + return { reply: `📝 当前系统提示词:\n${current}\n\n用法:\n/prompt <提示词> — 设置\n/prompt clear — 清除`, handled: true }; + } + return { reply: '📝 暂无系统提示词\n\n用法: /prompt <提示词>\n例: /prompt 用中文回答我', handled: true }; + } + if (args.trim().toLowerCase() === 'clear') { + config.systemPrompt = undefined; + saveConfig(config); + return { reply: '✅ 系统提示词已清除', handled: true }; + } + config.systemPrompt = args.trim(); + saveConfig(config); + return { reply: `✅ 系统提示词已设置:\n${config.systemPrompt}`, handled: true }; +} + +export function handleSend(ctx: CommandContext, args: string): CommandResult { + if (!args) { + return { reply: '用法: /send <文件路径>\n例: /send ~/Documents/report.pdf\n /send ./chart.png', handled: true }; + } + + const resolved = args.startsWith('/') + ? args + : resolve(ctx.session.workingDirectory, args.replace(/^~/, homedir())); + if (!existsSync(resolved)) { + return { reply: `文件不存在: ${resolved}`, handled: true }; + } + + const stat = statSync(resolved); + if (stat.isDirectory()) { + return { reply: `这是一个目录,请指定文件: ${resolved}`, handled: true }; + } + + if (stat.size > 25 * 1024 * 1024) { + return { reply: `文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),最大支持 25MB`, handled: true }; + } + + return { handled: true, sendFile: resolved }; +} + +export function handleUnknown(cmd: string, args: string): CommandResult { + const skills = getSkills(); + const skill = findSkill(skills, cmd); + + if (skill) { + const prompt = args ? `Use the ${skill.name} skill: ${args}` : `Use the ${skill.name} skill`; + return { handled: true, claudePrompt: prompt }; + } + + return { + handled: true, + reply: `未找到 skill: ${cmd}\n输入 /skills 查看可用列表`, + }; +} diff --git a/src/commands/router.ts b/src/commands/router.ts new file mode 100644 index 0000000..b1446b9 --- /dev/null +++ b/src/commands/router.ts @@ -0,0 +1,77 @@ +import type { Session } from '../session.js'; +import { findSkill } from '../claude/skill-scanner.js'; +import { logger } from '../logger.js'; +import { handleHelp, handleClear, handleCwd, handleModel, handleStatus, handleSkills, handleHistory, handleReset, handleCompact, handleUndo, handleVersion, handlePrompt, handleSend, handleUnknown } from './handlers.js'; + +export interface CommandContext { + accountId: string; + session: Session; + updateSession: (partial: Partial) => void; + clearSession: () => Session; + getChatHistoryText?: (limit?: number) => string; + text: string; +} + +export interface CommandResult { + reply?: string; + handled: boolean; + claudePrompt?: string; + sendFile?: string; // Absolute path to a file to send to the user +} + +/** + * Parse and dispatch a slash command. + * + * Supported commands: + * /help - Show help text with all available commands + * /clear - Clear the current session + * /model - Update the session model + * /status - Show current session info + * /skills - List all installed skills + * / - Invoke a skill by name (args are forwarded to Claude) + */ +export function routeCommand(ctx: CommandContext): CommandResult { + const text = ctx.text.trim(); + + if (!text.startsWith('/')) { + return { handled: false }; + } + + const spaceIdx = text.indexOf(' '); + const cmd = (spaceIdx === -1 ? text.slice(1) : text.slice(1, spaceIdx)).toLowerCase(); + const args = spaceIdx === -1 ? '' : text.slice(spaceIdx + 1).trim(); + + logger.info(`Slash command: /${cmd} ${args}`.trimEnd()); + + switch (cmd) { + case 'help': + return handleHelp(args); + case 'clear': + return handleClear(ctx); + case 'reset': + return handleReset(ctx); + case 'cwd': + return handleCwd(ctx, args); + case 'model': + return handleModel(ctx, args); + case 'prompt': + return handlePrompt(ctx, args); + case 'status': + return handleStatus(ctx); + case 'skills': + return handleSkills(args); + case 'history': + return handleHistory(ctx, args); + case 'undo': + return handleUndo(ctx, args); + case 'compact': + return handleCompact(ctx); + case 'send': + return handleSend(ctx, args); + case 'version': + case 'v': + return handleVersion(); + default: + return handleUnknown(cmd, args); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..085d33c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,48 @@ +import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { DEFAULT_WORKING_DIR } from "./constants.js"; + +export interface Config { + workingDirectory: string; + model?: string; + systemPrompt?: string; +} + +const CONFIG_DIR = join(homedir(), ".wechat-claude-code"); +const CONFIG_PATH = join(CONFIG_DIR, "config.json"); + +const DEFAULT_CONFIG: Config = { + workingDirectory: DEFAULT_WORKING_DIR, +}; + +export function loadConfig(): Config { + try { + const content = readFileSync(CONFIG_PATH, "utf-8"); + const parsed = JSON.parse(content); + const config: Config = { + workingDirectory: parsed.workingDirectory || DEFAULT_CONFIG.workingDirectory, + model: parsed.model, + systemPrompt: parsed.systemPrompt, + }; + mkdirSync(config.workingDirectory, { recursive: true }); + return config; + } catch { + const config = { ...DEFAULT_CONFIG }; + mkdirSync(config.workingDirectory, { recursive: true }); + return config; + } +} + +export function saveConfig(config: Config): void { + mkdirSync(CONFIG_DIR, { recursive: true }); + const data: Record = { + workingDirectory: config.workingDirectory, + }; + if (config.model) data.model = config.model; + if (config.systemPrompt) data.systemPrompt = config.systemPrompt; + writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2) + "\n", "utf-8"); + if (process.platform !== "win32") { + chmodSync(CONFIG_PATH, 0o600); + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..17b52e1 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,8 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export const DATA_DIR = process.env.WCC_DATA_DIR || join(homedir(), '.wechat-claude-code'); + +export const DEFAULT_WORKING_DIR = join(homedir(), 'Documents', 'ClaudeCode'); + +export const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'; diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..90ba24b --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,83 @@ +import { mkdirSync, appendFileSync, readdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const LOG_DIR = join(homedir(), ".wechat-claude-code", "logs"); +const MAX_LOG_FILES = 30; // Keep at most 30 days of logs + +/** Clean up old log files beyond MAX_LOG_FILES retention. */ +function cleanupOldLogs(): void { + try { + const files = readdirSync(LOG_DIR) + .filter((f) => f.startsWith("bridge-") && f.endsWith(".log")) + .sort(); + while (files.length > MAX_LOG_FILES) { + unlinkSync(join(LOG_DIR, files.shift()!)); + } + } catch { + // Ignore errors during cleanup + } +} + +/** + * Redact sensitive values from a string: + * - Bearer tokens (Authorization headers) + * - aes_key values + * - generic token/secret values in JSON payloads + */ +export function redact(obj: unknown): string { + const raw = typeof obj === "string" ? obj : JSON.stringify(obj); + if (!raw) return raw; + + let safe = raw; + // Mask Bearer tokens: "Bearer " + safe = safe.replace(/Bearer\s+[^\s"\\]+/gi, "Bearer ***"); + // Mask generic token/secret/password/api_key values in JSON + // Matches both snake_case (bot_token) and camelCase (botToken) + safe = safe.replace( + /"(?:(?:[\w]+_)?[Tt]oken|(?:[\w]+_)?[Ss]ecret|(?:[\w]+_)?[Pp]assword|(?:[\w]+_)?api_key|[Aa]es_[Kk]ey)"\s*:\s*"[^"]*"/gi, + (match) => { + const key = match.match(/"[^"]*"/)?.[0] ?? '""'; + return `${key}: "***"`; + }, + ); + return safe; +} + +function ensureLogDir(): void { + mkdirSync(LOG_DIR, { recursive: true }); + cleanupOldLogs(); +} + +function getLogFilePath(): string { + const now = new Date(Date.now() + 8 * 60 * 60 * 1000); + const date = now.toISOString().slice(0, 10); // YYYY-MM-DD + return join(LOG_DIR, `bridge-${date}.log`); +} + +function writeLogLine(level: string, message: string, data?: unknown): void { + ensureLogDir(); + const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(); + const timestamp = ts.replace('Z', '+08:00'); + const parts = [timestamp, level, message]; + if (data !== undefined) { + parts.push(redact(data)); + } + const line = parts.join(" ") + "\n"; + appendFileSync(getLogFilePath(), line, "utf-8"); +} + +export const logger = { + info(message: string, data?: unknown): void { + writeLogLine("INFO", message, data); + }, + warn(message: string, data?: unknown): void { + writeLogLine("WARN", message, data); + }, + error(message: string, data?: unknown): void { + writeLogLine("ERROR", message, data); + }, + debug(message: string, data?: unknown): void { + writeLogLine("DEBUG", message, data); + }, +} as const; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..ac03bb8 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,816 @@ +import { createInterface } from 'node:readline'; +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { join, basename } from 'node:path'; +import { unlinkSync, writeFileSync, mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; + +import { WeChatApi } from './wechat/api.js'; +import { saveAccount, loadLatestAccount, type AccountData } from './wechat/accounts.js'; +import { startQrLogin, waitForQrScan } from './wechat/login.js'; +import { createMonitor, type MonitorCallbacks } from './wechat/monitor.js'; +import { createSender } from './wechat/send.js'; +import { downloadImage, extractText, extractFirstImageUrl, extractFirstFileItem, downloadFile } from './wechat/media.js'; +import { createSessionStore, type Session } from './session.js'; +import { routeCommand, type CommandContext, type CommandResult } from './commands/router.js'; +import { claudeQuery, type QueryOptions } from './claude/provider.js'; +import { TurnRouter } from './claude/turn-router.js'; +import { filterToolNoise } from './claude/tool-noise-filter.js'; +import { loadConfig, saveConfig } from './config.js'; +import { logger } from './logger.js'; +import { DATA_DIR } from './constants.js'; +import { MessageType, type WeixinMessage } from './wechat/types.js'; +import { loadPendingQueue, savePendingQueue, type PendingItem } from './pending-queue.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const MAX_MESSAGE_LENGTH = 4000; + +// Extensions eligible for auto-push when detected in Claude's response +const AUTO_PUSH_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico', + '.pdf', '.doc', '.docx', '.ppt', '.pptx', '.rtf', + '.txt', '.md', + '.csv', '.xlsx', '.xls', + '.mp3', '.wav', '.m4a', '.mp4', '.mov', +]); + +/** Extract local file paths from Claude's response text. */ +function extractFilePathsFromText(text: string, cwd: string): string[] { + const paths: string[] = []; + // Match absolute paths (macOS/Linux), tilde paths, and Windows paths with a file extension + const regex = /(?:\/(?:Users|home|tmp|var|etc)\/[^\s`'"()\[\]{}|<>]+\.\w+|~\/[^\s`'"()\[\]{}|<>]+\.\w+|[A-Za-z]:[\\\/][^\s`'"()\[\]{}|<>]+\.\w+)/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(text)) !== null) { + const raw = match[0]; + const resolved = raw.startsWith('~') + ? raw.replace(/^~/, homedir()) + : raw; + paths.push(resolved); + } + return paths; +} + +/** Split text into blocks at paragraph boundaries (double newlines). */ +function parseBlocks(text: string): string[] { + return text.split(/\n\n+/).filter(block => block.length > 0); +} + +/** Find a safe split point that won't break markdown formatting. */ +function findSafeSplitPoint(text: string, maxLen: number): number { + // Try newline first (preserves list items, paragraphs) + let idx = text.lastIndexOf('\n', maxLen); + if (idx >= maxLen * 0.3) return idx; + + // Try sentence-ending punctuation + const sentenceEnd = /[。!?.!?]$/; + for (let i = maxLen; i >= maxLen * 0.5; i--) { + if (sentenceEnd.test(text.slice(i - 1, i))) return i; + } + + // Try space (won't split mid-word or mid-markdown) + idx = text.lastIndexOf(' ', maxLen); + if (idx >= maxLen * 0.3) return idx; + + // Last resort: hard cut + return maxLen; +} + +/** Fallback: split a single oversized block at safe boundaries. */ +function splitByNewline(text: string, maxLen: number): string[] { + const chunks: string[] = []; + let remaining = text; + while (remaining.length > 0) { + if (remaining.length <= maxLen) { + chunks.push(remaining); + break; + } + const splitIdx = findSafeSplitPoint(remaining, maxLen); + chunks.push(remaining.slice(0, splitIdx)); + remaining = remaining.slice(splitIdx).replace(/^\n+/, ''); + } + return chunks; +} + +/** + * Card-aware message splitter. + * Splits at paragraph boundaries (double newlines) to keep cards intact, + * falls back to newline-based splitting for oversized single blocks. + */ +function splitMessage(text: string, maxLen: number = MAX_MESSAGE_LENGTH): string[] { + if (text.length <= maxLen) return [text]; + const blocks = parseBlocks(text); + const chunks: string[] = []; + let current = ''; + + for (const block of blocks) { + // Can this block fit into the current chunk? + if (current.length === 0) { + if (block.length <= maxLen) { + current = block; + } else { + chunks.push(...splitByNewline(block, maxLen)); + } + } else if (current.length + 2 + block.length <= maxLen) { + current += '\n\n' + block; + } else { + // Current chunk is complete, start a new one + chunks.push(current); + if (block.length <= maxLen) { + current = block; + } else { + chunks.push(...splitByNewline(block, maxLen)); + current = ''; + } + } + } + if (current) chunks.push(current); + return chunks; +} + +function promptUser(question: string, defaultValue?: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const display = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `; + rl.question(display, (answer) => { + rl.close(); + resolve(answer.trim() || defaultValue || ''); + }); + }); +} + +/** Open a file using the platform's default application (secure: uses spawnSync) */ +function openFile(filePath: string): void { + const platform = process.platform; + let cmd: string; + let args: string[]; + + if (platform === 'darwin') { + cmd = 'open'; + args = [filePath]; + } else if (platform === 'win32') { + cmd = 'cmd'; + args = ['/c', 'start', '', filePath]; + } else { + // Linux: try xdg-open + cmd = 'xdg-open'; + args = [filePath]; + } + + const result = spawnSync(cmd, args, { stdio: 'ignore' }); + if (result.error) { + logger.warn('Failed to open file', { cmd, filePath, error: result.error.message }); + } +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +async function runSetup(): Promise { + mkdirSync(DATA_DIR, { recursive: true }); + const QR_PATH = join(DATA_DIR, 'qrcode.png'); + + console.log('正在设置...\n'); + + // Loop: generate QR → display → poll for scan → handle expiry → repeat + while (true) { + const { qrcodeUrl, qrcodeId } = await startQrLogin(); + + const isHeadlessLinux = process.platform === 'linux' && + !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY; + + if (isHeadlessLinux) { + // Headless Linux: display QR in terminal using qrcode-terminal + try { + const qrcodeTerminal = await import('qrcode-terminal'); + console.log('请用微信扫描下方二维码:\n'); + qrcodeTerminal.default.generate(qrcodeUrl, { small: true }); + console.log(); + console.log('二维码链接:', qrcodeUrl); + console.log(); + } catch { + logger.warn('qrcode-terminal not available, falling back to URL'); + console.log('无法在终端显示二维码,请访问链接:'); + console.log(qrcodeUrl); + console.log(); + } + } else { + // macOS / Windows / GUI Linux: generate QR PNG and open with system viewer + const QRCode = await import('qrcode'); + const pngData = await QRCode.toBuffer(qrcodeUrl, { type: 'png', width: 400, margin: 2 }); + writeFileSync(QR_PATH, pngData); + + openFile(QR_PATH); + console.log('已打开二维码图片,请用微信扫描:'); + console.log(`图片路径: ${QR_PATH}\n`); + } + + console.log('等待扫码绑定...'); + + try { + await waitForQrScan(qrcodeId); + console.log('✅ 绑定成功!'); + break; + } catch (err: any) { + if (err.message?.includes('expired')) { + console.log('⚠️ 二维码已过期,正在刷新...\n'); + continue; + } + throw err; + } + } + + // Clean up QR image + try { unlinkSync(QR_PATH); } catch { + logger.warn('Failed to clean up QR image', { path: QR_PATH }); + } + + const workingDir = await promptUser('请输入工作目录', join(homedir(), 'Documents', 'ClaudeCode')); + const config = loadConfig(); + config.workingDirectory = workingDir; + saveConfig(config); + + console.log('运行 npm run daemon -- start 启动服务'); +} + +// --------------------------------------------------------------------------- +// Daemon +// --------------------------------------------------------------------------- + +async function runDaemon(): Promise { + const config = loadConfig(); + const account = loadLatestAccount(); + + if (!account) { + console.error('未找到账号,请先运行 node dist/main.js setup'); + process.exit(1); + } + + const api = new WeChatApi(account.botToken, account.baseUrl); + const sessionStore = createSessionStore(); + const session: Session = sessionStore.load(account.accountId); + + // Fix: backfill session workingDirectory from config if it's still the default process.cwd() + if (config.workingDirectory && session.workingDirectory === process.cwd()) { + session.workingDirectory = config.workingDirectory; + sessionStore.save(account.accountId, session); + } + + // Fix: reset stale non-idle state on startup (e.g. after crash) + if (session.state !== 'idle') { + logger.warn('Resetting stale session state on startup', { state: session.state }); + session.state = 'idle'; + sessionStore.save(account.accountId, session); + } + + const sender = createSender(api, account.accountId); + const sharedCtx = { lastContextToken: '' }; + const activeControllers = new Map(); + + // -- Message queue for serial processing -- + const messageQueue: WeixinMessage[] = []; + let processingQueue = false; + + async function drainQueue(): Promise { + if (processingQueue) return; + processingQueue = true; + while (messageQueue.length > 0) { + const msg = messageQueue.shift()!; + await handleMessage(msg, account!, session, sessionStore, sender, config, sharedCtx, activeControllers, messageQueue); + } + processingQueue = false; + } + + // -- Wire the monitor callbacks -- + + /** Handle priority commands (/stop, /clear) immediately, bypassing the serial queue. */ + function handlePriorityCommand(msg: WeixinMessage): boolean { + if (msg.message_type !== MessageType.USER || !msg.item_list) return false; + const text = extractTextFromItems(msg.item_list); + if (!text.startsWith('/stop') && !text.startsWith('/clear')) return false; + if (session.state !== 'processing') return false; + + const ctrl = activeControllers.get(account!.accountId); + if (ctrl) { ctrl.abort(); activeControllers.delete(account!.accountId); } + session.state = 'idle'; + sessionStore.save(account!.accountId, session); + + if (text.startsWith('/stop')) { + messageQueue.length = 0; + sender.sendText(msg.from_user_id!, msg.context_token ?? '', '⏹ 已停止当前对话,排队中的消息已清空。').catch(() => {}); + } + return true; + } + + const callbacks: MonitorCallbacks = { + onMessage: async (msg: WeixinMessage) => { + if (handlePriorityCommand(msg)) return; + messageQueue.push(msg); + drainQueue(); + }, + onSessionExpired: () => { + logger.warn('Session expired, will keep retrying...'); + console.error('⚠️ 微信会话已过期,请重新运行 setup 扫码绑定'); + }, + }; + + const monitor = createMonitor(api, callbacks); + + // -- Graceful shutdown -- + + function shutdown(): void { + logger.info('Shutting down...'); + monitor.stop(); + process.exit(0); + } + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + logger.info('Daemon started', { accountId: account.accountId }); + console.log(`已启动 (账号: ${account.accountId})`); + + await monitor.run(); +} + +// --------------------------------------------------------------------------- +// Message handling +// --------------------------------------------------------------------------- + +async function handleMessage( + msg: WeixinMessage, + account: AccountData, + session: Session, + sessionStore: ReturnType, + sender: ReturnType, + config: ReturnType, + sharedCtx: { lastContextToken: string }, + activeControllers: Map, + messageQueue: WeixinMessage[], +): Promise { + // Filter: only user messages with required fields + if (msg.message_type !== MessageType.USER) return; + if (!msg.from_user_id || !msg.item_list) return; + if (account.userId && msg.from_user_id !== account.userId) return; + + const contextToken = msg.context_token ?? ''; + const fromUserId = msg.from_user_id; + sharedCtx.lastContextToken = contextToken; + + // Flush any pending messages from prior rate-limit windows. User's new + // message brings a fresh context_token, which resets the iLink 11-msg quota. + await flushPending(account.accountId, fromUserId, contextToken, sender); + + // Extract text from items + const userText = extractTextFromItems(msg.item_list); + const imageItem = extractFirstImageUrl(msg.item_list); + const fileItem = extractFirstFileItem(msg.item_list); + + // Drop non-command messages while processing (priority commands already handled upstream) + if (session.state === 'processing' && !userText.startsWith('/')) { + return; + } + + // -- Command routing -- + + if (userText.startsWith('/')) { + const updateSession = (partial: Partial) => { + Object.assign(session, partial); + sessionStore.save(account.accountId, session); + }; + + const ctx: CommandContext = { + accountId: account.accountId, + session, + updateSession, + clearSession: () => sessionStore.clear(account.accountId), + getChatHistoryText: (limit?: number) => sessionStore.getChatHistoryText(session, limit), + text: userText, + }; + + const result: CommandResult = routeCommand(ctx); + + if (result.handled && result.reply) { + await sender.sendText(fromUserId, contextToken, result.reply); + return; + } + + if (result.handled && result.claudePrompt) { + await sendToClaude( + result.claudePrompt, imageItem, fileItem, fromUserId, contextToken, + account, session, sessionStore, sender, config, activeControllers, + ); + return; + } + + if (result.handled && result.sendFile) { + await sender.sendFile(fromUserId, contextToken, result.sendFile); + return; + } + + if (result.handled) return; + + // Not handled, treat as normal message (fall through) + } + + // -- Normal message -> Claude -- + + if (!userText && !imageItem && !fileItem) { + await sender.sendText(fromUserId, contextToken, '暂不支持此类型消息,请发送文字、语音、图片或文件'); + return; + } + + await sendToClaude( + userText, imageItem, fileItem, fromUserId, contextToken, + account, session, sessionStore, sender, config, activeControllers, + ); +} + +function extractTextFromItems(items: NonNullable): string { + return items.map((item) => extractText(item)).filter(Boolean).join('\n'); +} + +/** + * Drain the pending message queue (messages that couldn't be delivered in a + * prior rate-limit window). Called whenever a fresh user message arrives with + * a new context_token. Each flush attempt stops at the first failure — + * remaining items stay queued for the next user message. + */ +async function flushPending( + accountId: string, + toUserId: string, + contextToken: string, + sender: ReturnType, +): Promise { + const queue = loadPendingQueue(accountId); + if (queue.length === 0) return; + + logger.info('Flushing pending queue', { accountId, pending: queue.length }); + const stillPending: PendingItem[] = []; + + for (const item of queue) { + try { + const chunks = splitMessage(item.text); + for (const chunk of chunks) { + await sender.sendText(toUserId, contextToken, chunk); + } + } catch (err) { + logger.warn('Flush stopped at rate-limit, keeping remaining items queued', { + accountId, + flushed: queue.length - stillPending.length - 1, + remaining: stillPending.length + 1, + error: err instanceof Error ? err.message : String(err), + }); + stillPending.push(item); + } + } + + savePendingQueue(accountId, stillPending); + + if (stillPending.length > 0 && stillPending.length === queue.length) { + // Nothing got flushed this round — nudge the user. + await sender + .sendText(toUserId, contextToken, `⏳ 还有 ${stillPending.length} 条暂存消息未能推送,再发任意消息我会继续补发。`) + .catch(() => {}); + } +} + +async function sendToClaude( + userText: string, + imageItem: ReturnType, + fileItem: ReturnType, + fromUserId: string, + contextToken: string, + account: AccountData, + session: Session, + sessionStore: ReturnType, + sender: ReturnType, + config: ReturnType, + activeControllers: Map, +): Promise { + // Set state to processing + session.state = 'processing'; + sessionStore.save(account.accountId, session); + + // Create abort controller for this query so it can be cancelled by new messages + const abortController = new AbortController(); + activeControllers.set(account.accountId, abortController); + + // Flush timer for streaming text to WeChat during query (declared here for finally cleanup) + let flushTimer: ReturnType | undefined; + + // Record user message in chat history + sessionStore.addChatMessage(session, 'user', userText || '(图片)'); + + // Start typing indicator (keepalive until stopTyping is called) + const stopTyping = sender.startTyping(fromUserId, contextToken); + + try { + // Download image if present + let images: QueryOptions['images']; + if (imageItem) { + const base64DataUri = await downloadImage(imageItem); + if (base64DataUri) { + const matches = base64DataUri.match(/^data:([^;]+);base64,(.+)$/); + if (matches) { + images = [ + { + type: 'image', + source: { + type: 'base64', + media_type: matches[1], + data: matches[2], + }, + }, + ]; + } + } + } + + // Download file if present + let prompt = userText || '请分析这张图片'; + if (fileItem) { + const filePath = await downloadFile(fileItem); + if (filePath) { + const fileName = fileItem.file_item?.file_name || basename(filePath); + prompt = userText + ? `${userText}\n\n用户发送了文件: ${fileName}\n文件已保存到: ${filePath}\n请先读取这个文件再回答。` + : `用户发送了文件: ${fileName}\n文件已保存到: ${filePath}\n请读取这个文件并总结其内容。`; + } + } + + let anySent = false; + let lastSentTime = Date.now(); + let pendingRetry: { text: string; role: 'interstitial' | 'final' } | null = null; + + // Serial promise chain — each emit appends to the chain, no flags needed + let flushChain: Promise = Promise.resolve(); + + function emitText(text: string, role: 'interstitial' | 'final'): void { + if (!text.trim()) return; + + // 若上一次发送失败留下了 pendingRetry,先用它原本的 role 单独补发, + // 不要和当前 role 的文本合并(避免 interstitial 内容混进 final 答案)。 + if (pendingRetry) { + const stuck = pendingRetry; + pendingRetry = null; + scheduleSend(stuck.text, stuck.role); + } + + scheduleSend(text, role); + } + + function scheduleSend(text: string, role: 'interstitial' | 'final'): void { + if (!text.trim()) return; + flushChain = flushChain.then(async () => { + const chunks = splitMessage(text); + for (let i = 0; i < chunks.length; i++) { + try { + await sender.sendText(fromUserId, contextToken, chunks[i]); + } catch (err) { + pendingRetry = { text: chunks.slice(i).join('\n\n'), role }; + logger.warn('emitText send failed, content retained for retry', { + role, + error: err instanceof Error ? err.message : String(err), + retainedChunks: chunks.length - i, + }); + return; + } + } + anySent = true; + lastSentTime = Date.now(); + }); + } + + const router = new TurnRouter((msg) => emitText(filterToolNoise(msg.text), msg.role)); + + // Safety net: send keepalive if nothing was sent for 5 minutes + const SILENCE_WARNING_MS = 5 * 60 * 1000; + const SILENCE_MESSAGES = [ + '我还在处理中,这个问题有点复杂,请再稍等一下', + '正在努力干活中,马上就有结果了,请稍等片刻', + '有点复杂正在处理,再给我一点时间,很快就好', + '快好了别着急,正在收尾阶段,马上给你回复', + '还在跑呢,任务量比较大,不过马上就能出结果了', + '任务比想象的复杂一些,再等等我,正在全力处理', + '正在处理中,进展顺利,再等一会儿就好', + '还没完不过已经快了,再给我一分钟就能搞定', + '我在认真思考这个问题,请再稍等一会儿', + '稍微有点棘手,不过已经快解决了,再等我一下', + ]; + flushTimer = setInterval(() => { + if (Date.now() - lastSentTime > SILENCE_WARNING_MS) { + const msg = SILENCE_MESSAGES[Math.floor(Math.random() * SILENCE_MESSAGES.length)]; + sender.sendText(fromUserId, contextToken, msg).catch(() => {}); + lastSentTime = Date.now(); + } + }, 2000); + + const queryOptions: QueryOptions = { + prompt, + cwd: (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()), + resume: session.sdkSessionId, + model: session.model, + systemPrompt: [ + '你正在通过微信与用户对话,不是在终端里。不要让用户去终端操作。如果用户需要文件,直接输出文件地址就行,会自动识别解析推送文件到用户的微信中。', + config.systemPrompt, + ].filter(Boolean).join('\n'), + abortController, + images, + onText: (delta: string) => { + router.onText(delta); + }, + onTurnEnd: (stopReason: string) => { + router.onTurnEnd(stopReason); + }, + }; + + let result = await claudeQuery(queryOptions); + + // If resume failed (e.g. corrupted session), retry without resume + if (result.error && queryOptions.resume) { + logger.warn('Resume failed, retrying without resume', { error: result.error, sessionId: queryOptions.resume }); + queryOptions.resume = undefined; + session.sdkSessionId = undefined; + sessionStore.save(account.accountId, session); + const retryResult = await claudeQuery(queryOptions); + Object.assign(result, retryResult); + } + + // Stop periodic flush, drain router (final 先于 interstitial), wait for queued sends + clearInterval(flushTimer); + router.drain(); + await flushChain; + + // 兜底重试:drain() 的最后一次发送若失败,pendingRetry 会卡住没有下一个 emit 接力。 + // 这里做有上限的终态重试,避免静默丢内容(commit d6d7d62 的 "never silently drop" 保证)。 + const MAX_TERMINAL_ATTEMPTS = 3; + let terminalAttempt = 0; + while (pendingRetry && terminalAttempt < MAX_TERMINAL_ATTEMPTS) { + const stuck: { text: string; role: 'interstitial' | 'final' } = pendingRetry; + pendingRetry = null; + terminalAttempt++; + const delayMs = terminalAttempt * 5_000; // 5s, 10s, 15s + logger.warn(`terminal retry ${terminalAttempt}/${MAX_TERMINAL_ATTEMPTS} for stranded content`, { + role: stuck.role, + delayMs, + textLength: stuck.text.length, + }); + await new Promise(r => setTimeout(r, delayMs)); + + const chunks = splitMessage(stuck.text); + let failed = false; + for (let i = 0; i < chunks.length; i++) { + try { + await sender.sendText(fromUserId, contextToken, chunks[i]); + anySent = true; + lastSentTime = Date.now(); + } catch (err) { + pendingRetry = { text: chunks.slice(i).join('\n\n'), role: stuck.role }; + logger.warn('terminal retry failed', { + attempt: terminalAttempt, + error: err instanceof Error ? err.message : String(err), + }); + failed = true; + break; + } + } + if (!failed) break; + } + + if (pendingRetry) { + // Park the stranded content to the pending queue. It will be flushed + // automatically when the user's next message brings a fresh context_token + // (which resets the iLink 11-msg quota). + const queue = loadPendingQueue(account.accountId); + queue.push({ + text: pendingRetry.text, + role: pendingRetry.role, + queuedAt: Date.now(), + }); + savePendingQueue(account.accountId, queue); + logger.warn('content parked to pending queue', { + role: pendingRetry.role, + textLength: pendingRetry.text.length, + queueSize: queue.length, + }); + await sender + .sendText(fromUserId, contextToken, '⏳ 部分内容因微信单次推送上限暂存,下次你回复任意消息时自动补发。') + .catch(() => {}); + pendingRetry = null; + } + + // Send result back to WeChat + if (result.text) { + if (result.error) { + logger.warn('Claude query had error but returned text, using text', { error: result.error }); + } + sessionStore.addChatMessage(session, 'assistant', result.text); + // If nothing was streamed at all (e.g. streaming not supported), send full text now + if (!anySent) { + const chunks = splitMessage(result.text); + for (const chunk of chunks) { + await sender.sendText(fromUserId, contextToken, chunk); + } + } + } else if (result.error) { + logger.error('Claude query error', { error: result.error }); + await sender.sendText(fromUserId, contextToken, 'Claude 处理请求时出错,请稍后重试。'); + } else if (!anySent) { + await sender.sendText(fromUserId, contextToken, 'Claude 无返回内容(可能因权限被拒而终止)'); + } + + // Update session with new SDK session ID + session.sdkSessionId = result.sessionId || undefined; + session.state = 'idle'; + sessionStore.save(account.accountId, session); + + // Auto-push deliverable files mentioned in Claude's response + if (result.text) { + const cwd = (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()); + const detectedPaths = extractFilePathsFromText(result.text, cwd); + const { existsSync } = await import('node:fs'); + const { extname } = await import('node:path'); + const pushable = detectedPaths.filter(f => { + const ext = extname(f).toLowerCase(); + return AUTO_PUSH_EXTENSIONS.has(ext) && existsSync(f); + }); + if (pushable.length > 0) { + const failedFiles: string[] = []; + for (const filePath of pushable) { + try { + await sender.sendFile(fromUserId, contextToken, filePath); + } catch { + failedFiles.push(filePath); + } + } + if (failedFiles.length > 0) { + // Server-side rate limit requires longer cooldown (observed ret:-2 even after 9s backoff) + for (let attempt = 0; attempt < 3; attempt++) { + const delay = (attempt + 1) * 15_000; + logger.warn(`Rate-limited, retrying ${failedFiles.length} file(s) in ${delay / 1000}s (attempt ${attempt + 1}/3)`); + await new Promise(r => setTimeout(r, delay)); + const stillFailed: string[] = []; + for (const filePath of failedFiles) { + try { + await sender.sendFile(fromUserId, contextToken, filePath); + } catch { + stillFailed.push(filePath); + } + } + if (stillFailed.length === 0) break; + failedFiles.length = 0; + failedFiles.push(...stillFailed); + } + if (failedFiles.length > 0) { + logger.error('File delivery failed after all retries', { files: failedFiles }); + await sender.sendText(fromUserId, contextToken, `文件推送失败(服务端限频),请稍后重试。`).catch(() => {}); + } + } + } + } + } catch (err) { + const isAbort = err instanceof Error && (err.name === 'AbortError' || err.message.includes('abort')); + if (isAbort) { + // Query was cancelled by a new incoming message — exit silently + logger.info('Claude query aborted by new message'); + } else { + const errorMsg = err instanceof Error ? err.message : String(err); + logger.error('Error in sendToClaude', { error: errorMsg }); + await sender.sendText(fromUserId, contextToken, '处理消息时出错,请稍后重试。'); + } + session.state = 'idle'; + sessionStore.save(account.accountId, session); + } finally { + clearInterval(flushTimer); + stopTyping(); + // Clean up the abort controller if it's still ours + if (activeControllers.get(account.accountId) === abortController) { + activeControllers.delete(account.accountId); + } + } +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +const command = process.argv[2]; + +if (command === 'setup') { + runSetup().catch((err) => { + logger.error('Setup failed', { error: err instanceof Error ? err.message : String(err) }); + console.error('设置失败:', err); + process.exit(1); + }); +} else { + // 'start' or no argument + runDaemon().catch((err) => { + logger.error('Daemon start failed', { error: err instanceof Error ? err.message : String(err) }); + console.error('启动失败:', err); + process.exit(1); + }); +} diff --git a/src/pending-queue.ts b/src/pending-queue.ts new file mode 100644 index 0000000..8c8a5bf --- /dev/null +++ b/src/pending-queue.ts @@ -0,0 +1,65 @@ +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { DATA_DIR } from './constants.js'; +import { logger } from './logger.js'; + +export interface PendingItem { + text: string; + role: 'interstitial' | 'final'; + queuedAt: number; +} + +const QUEUE_DIR = join(DATA_DIR, 'pending-queue'); + +function queuePath(accountId: string): string { + return join(QUEUE_DIR, `${accountId}.json`); +} + +function ensureDir(): void { + if (!existsSync(QUEUE_DIR)) { + mkdirSync(QUEUE_DIR, { recursive: true }); + } +} + +export function loadPendingQueue(accountId: string): PendingItem[] { + try { + const path = queuePath(accountId); + if (!existsSync(path)) return []; + const raw = readFileSync(path, 'utf-8'); + const data = JSON.parse(raw); + return Array.isArray(data) ? data : []; + } catch (err) { + logger.warn('Failed to load pending queue', { + accountId, + error: err instanceof Error ? err.message : String(err), + }); + return []; + } +} + +export function savePendingQueue(accountId: string, items: PendingItem[]): void { + try { + ensureDir(); + writeFileSync(queuePath(accountId), JSON.stringify(items, null, 2), 'utf-8'); + } catch (err) { + logger.warn('Failed to save pending queue', { + accountId, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +export function appendPending(accountId: string, item: PendingItem): PendingItem[] { + const items = loadPendingQueue(accountId); + items.push(item); + savePendingQueue(accountId, items); + return items; +} + +export function clearPending(accountId: string): void { + savePendingQueue(accountId, []); +} + +export function hasPending(accountId: string): boolean { + return loadPendingQueue(accountId).length > 0; +} diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..77776b2 --- /dev/null +++ b/src/session.ts @@ -0,0 +1,119 @@ +import { loadJson, saveJson, validateAccountId } from './store.js'; +import { mkdirSync } from 'node:fs'; +import { DATA_DIR, DEFAULT_WORKING_DIR } from './constants.js'; +import { join } from 'node:path'; +import { logger } from './logger.js'; + +const SESSIONS_DIR = join(DATA_DIR, 'sessions'); + +export type SessionState = 'idle' | 'processing'; + +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; + timestamp: number; +} + +export interface Session { + sdkSessionId?: string; + previousSdkSessionId?: string; + workingDirectory: string; + model?: string; + state: SessionState; + chatHistory: ChatMessage[]; + maxHistoryLength?: number; +} + +const DEFAULT_MAX_HISTORY = 100; + +export function createSessionStore() { + function getSessionPath(accountId: string): string { + validateAccountId(accountId); + return join(SESSIONS_DIR, `${accountId}.json`); + } + + function load(accountId: string): Session { + validateAccountId(accountId); + const session = loadJson(getSessionPath(accountId), { + workingDirectory: DEFAULT_WORKING_DIR, + state: 'idle', + chatHistory: [], + maxHistoryLength: DEFAULT_MAX_HISTORY, + }); + + // Backward compatibility: ensure chatHistory exists + if (!session.chatHistory) { + session.chatHistory = []; + } + if (!session.maxHistoryLength) { + session.maxHistoryLength = DEFAULT_MAX_HISTORY; + } + + return session; + } + + function save(accountId: string, session: Session): void { + mkdirSync(SESSIONS_DIR, { recursive: true }); + + // Trim chat history if it exceeds max length before saving + const maxLen = session.maxHistoryLength || DEFAULT_MAX_HISTORY; + if (session.chatHistory.length > maxLen) { + session.chatHistory = session.chatHistory.slice(-maxLen); + } + + saveJson(getSessionPath(accountId), session); + } + + function clear(accountId: string, currentSession?: Session): Session { + const session: Session = { + sdkSessionId: undefined, // explicitly clear so Object.assign removes it + previousSdkSessionId: undefined, + workingDirectory: currentSession?.workingDirectory ?? DEFAULT_WORKING_DIR, + model: currentSession?.model, + state: 'idle', + chatHistory: [], + maxHistoryLength: currentSession?.maxHistoryLength || DEFAULT_MAX_HISTORY, + }; + save(accountId, session); + return session; + } + + function addChatMessage(session: Session, role: 'user' | 'assistant', content: string): void { + if (!session.chatHistory) { + session.chatHistory = []; + } + session.chatHistory.push({ + role, + content, + timestamp: Date.now(), + }); + + // Trim if exceeds max length + const maxLen = session.maxHistoryLength || DEFAULT_MAX_HISTORY; + if (session.chatHistory.length > maxLen) { + session.chatHistory = session.chatHistory.slice(-maxLen); + } + } + + function getChatHistoryText(session: Session, limit?: number): string { + const history = session.chatHistory || []; + const messages = limit ? history.slice(-limit) : history; + + if (messages.length === 0) { + return '暂无对话记录'; + } + + const lines: string[] = []; + for (const msg of messages) { + const time = new Date(msg.timestamp).toLocaleString('zh-CN'); + const role = msg.role === 'user' ? '用户' : 'Claude'; + lines.push(`[${time}] ${role}:`); + lines.push(msg.content); + lines.push(''); + } + + return lines.join('\n'); + } + + return { load, save, clear, addChatMessage, getChatHistoryText }; +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..d85e095 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,39 @@ +import { readFileSync, writeFileSync, chmodSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { logger } from "./logger.js"; + +export function validateAccountId(accountId: string): void { + if (!/^[a-zA-Z0-9_.@=-]+$/.test(accountId)) { + throw new Error(`Invalid accountId: "${accountId}"`); + } +} + +/** + * Load a JSON file, returning a typed object or the fallback if the file + * does not exist or cannot be parsed. + */ +export function loadJson(filePath: string, fallback: T): T { + try { + const raw = readFileSync(filePath, "utf-8"); + return JSON.parse(raw) as T; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + logger.warn('loadJson failed, using fallback', { filePath, error: err instanceof Error ? err.message : String(err) }); + } + return fallback; + } +} + +/** + * Persist an object as pretty-printed JSON. + * File is written with mode 0o600 (owner read/write only). + */ +export function saveJson(filePath: string, data: unknown): void { + mkdirSync(dirname(filePath), { recursive: true }); + const raw = JSON.stringify(data, null, 2) + "\n"; + writeFileSync(filePath, raw, "utf-8"); + if (process.platform !== 'win32') { + chmodSync(filePath, 0o600); + } +} diff --git a/src/tests/fixtures/tool-noise/extract-fixtures.mjs b/src/tests/fixtures/tool-noise/extract-fixtures.mjs new file mode 100644 index 0000000..a701d75 --- /dev/null +++ b/src/tests/fixtures/tool-noise/extract-fixtures.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// One-shot script: pull the 7 tool-noise hits + 8 normal samples from today's +// log into a JSON fixture the test suite can consume. Re-run after future +// incidents to refresh fixtures. + +import { createReadStream, writeFileSync, mkdirSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LOG_PATH = process.env.HOME + '/.wechat-claude-code/logs/bridge-2026-06-27.log'; +const OUT = join(__dirname, 'real-cases.json'); + +const FENCED_JSON = /```json\b[\s\S]*?```/i; +const URL_OR_PATH = /(https?:\/\/\S+)|(\/(?:Users|home|tmp|var|opt|etc)\/\S+)|(~\/\S+)/; + +function looksLikeToolDump(text) { + if (text.length <= 400) return false; + if (!FENCED_JSON.test(text)) return false; + if (!URL_OR_PATH.test(text)) return false; + return true; +} + +function isStructuralLine(line) { + if (!line.trim()) return true; + if (/^\s*```/.test(line)) return true; + if (/^\s*\*+/.test(line)) return true; + if (/^\s*[\[\]\{\}]/.test(line)) return true; + if (/\\n/.test(line)) return true; + if (/^\s*"\w+"\s*:/.test(line)) return true; + if (/^\s*\*\s/.test(line)) return true; + return false; +} + +function splitDumpAndTail(text) { + const lines = text.split('\n'); + let lastIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (isStructuralLine(lines[i])) lastIdx = i; + } + if (lastIdx < 0 || lastIdx === lines.length - 1) return ''; + return lines.slice(lastIdx + 1).join('\n').trim(); +} + +function filterToolNoise(text) { + if (!looksLikeToolDump(text)) return text; + const tail = splitDumpAndTail(text); + return tail ? `🔧 [工具调用] — ${tail}` : '🔧 [工具调用]'; +} + +const rl = createInterface({ input: createReadStream(LOG_PATH) }); +const all = []; +rl.on('line', (line) => { + if (!line.includes('/ilink/bot/sendmessage')) return; + if (!line.includes('"type":1,')) return; + const jsonStart = line.indexOf('{'); + if (jsonStart < 0) return; + let payload; + try { payload = JSON.parse(line.slice(jsonStart)); } catch { return; } + const text = payload?.body?.msg?.item_list?.[0]?.text_item?.text; + if (typeof text !== 'string') return; + all.push({ ts: line.slice(0, 23), text }); +}); +await new Promise((r) => rl.on('close', r)); + +const hits = all.filter((p) => looksLikeToolDump(p.text)); +// Normal sample: 8 diverse untouched messages +const normalPool = all.filter((p) => !looksLikeToolDump(p.text)); +const normal = []; +for (const target of [ + /^✅/, // status lines + /\n\n/, // multi-paragraph + /```/, // has code block (but not json-fenced) + /\/Users\//, // has paths + /\|.*\|.*\|/, // has tables + /已|完成|搞定|找到/, // conclusion phrases +]) { + const found = normalPool.find((p) => target.test(p.text) && !normal.includes(p)); + if (found) normal.push(found); +} +while (normal.length < 8 && normal.length < normalPool.length) { + normal.push(normalPool[normal.length]); +} + +const cases = hits.map((p, i) => ({ + name: `hit-${i + 1}`, + input: p.text, + expectedFilter: true, + expectedTail: splitDumpAndTail(p.text), +})).concat(normal.slice(0, 8).map((p, i) => ({ + name: `normal-${i + 1}`, + input: p.text, + expectedFilter: false, + expectedTail: null, +}))); + +mkdirSync(__dirname, { recursive: true }); +writeFileSync(OUT, JSON.stringify({ cases }, null, 2)); +console.log(`Extracted ${hits.length} hits + ${Math.min(8, normal.length)} normals → ${OUT}`); diff --git a/src/tests/fixtures/tool-noise/real-cases.json b/src/tests/fixtures/tool-noise/real-cases.json new file mode 100644 index 0000000..b69bcda --- /dev/null +++ b/src/tests/fixtures/tool-noise/real-cases.json @@ -0,0 +1,94 @@ +{ + "cases": [ + { + "name": "hit-1", + "input": "**🌐 Z.ai Built-in Tool: analyze_image**\n\n**Input:**\n```json\n{\"imageSource\":\"https://maas-log-prod.cn-wlcb.ufileos.com/anthropic/edeaaaf2-dd27-4e99-a2d5-7e0608a672ed/01-openmontage-readme.png?UCloudPublicKey=TOKEN_e15ba47a-d098-4fbd-9afc-a0dcf0e4e621&Expires=1782527644&Signature=HIa88Ee4PXlMGLSYZyoStJY3kac=\",\"prompt\":\"描述这张截图的内容、颜色(是亮色还是暗色主题)、文字是英文还是中文,并告诉我截图顶部和底部分别显示了什么内容\"}\n```\n*Executing on server...*\n**Output:**\n**analyze_image_result_summary:** [{\"text\": \"\\\"这张截图展示了一个名为“OpenMontage”的网页界面,整体采用**暗色主题**(背景为深色,文字和元素以浅色为主,视觉对比清晰)。文字内容为**英文**。 \\\\n\\\\n### 顶部内容: \\\\n- 顶部中央有一个带有播放图标的圆形蓝色图形(类似视频播放按钮的科技感设计)。 \\\\n- 图形下方是标题“OpenMontage”,并配有副标题“The first open - source, agentic video production system.”(介绍其为首个开源的智能视频制作系统)。 \\\\n- 标题下方是导航栏,包含“Paste A Video”...\n 翻译没生效(英文段落被 inline 元素切碎了)。我改用整段 innerText 匹配替换。", + "expectedFilter": true, + "expectedTail": "翻译没生效(英文段落被 inline 元素切碎了)。我改用整段 innerText 匹配替换。" + }, + { + "name": "hit-2", + "input": "**🌐 Z.ai Built-in Tool: analyze_image**\n\n**Input:**\n```json\n{\"imageSource\":\"https://maas-log-prod.cn-wlcb.ufileos.com/anthropic/edeaaaf2-dd27-4e99-a2d5-7e0608a672ed/01-openmontage-readme.png?UCloudPublicKey=TOKEN_e15ba47a-d098-4fbd-9afc-a0dcf0e4e621&Expires=1782527729&Signature=hHhCCZVd4HjgxKBaVRglw2ZQrSs=\",\"prompt\":\"描述这张截图:是亮色还是暗色主题?文字是英文还是中文?把看到的标题、副标题、导航、正文段落内容尽量完整地写出来\"}\n```\n*Executing on server...*\n**Output:**\n**analyze_image_result_summary:** [{\"text\": \"\\\"### 1. 主题风格 \\\\n截图为**暗色主题**(背景为深色,文字为浅色,整体视觉对比明显)。 \\\\n\\\\n\\\\n### 2. 文字语言 \\\\n文字均为**英文**。 \\\\n\\\\n\\\\n### 3. 标题与副标题 \\\\n- 主标题:`OpenMontage` \\\\n- 副标题:`The first open - source, agentic video production system.` \\\\n\\\\n\\\\n### 4. 导航栏(页面顶部) \\\\n从左到右依次为: \\\\n`Paste A Video` · `Quick Start` · `T...\n 翻译 JS 命中了 4 处但截图没体现,可能是替换被 React 重新渲染覆盖了。我换种方式:拿到 HTML 字符串,外面做精确替换,再塞回去。", + "expectedFilter": true, + "expectedTail": "翻译 JS 命中了 4 处但截图没体现,可能是替换被 React 重新渲染覆盖了。我换种方式:拿到 HTML 字符串,外面做精确替换,再塞回去。" + }, + { + "name": "hit-3", + "input": "**🌐 Z.ai Built-in Tool: analyze_image**\n\n**Input:**\n```json\n{\"imageSource\":\"https://maas-log-prod.cn-wlcb.ufileos.com/anthropic/edeaaaf2-dd27-4e99-a2d5-7e0608a672ed/01-openmontage-readme.png?UCloudPublicKey=TOKEN_e15ba47a-d098-4fbd-9afc-a0dcf0e4e621&Expires=1782527855&Signature=xq5R4clq7fDId4KjPdhOsKhobEw=\",\"prompt\":\"这张截图是亮色还是暗色主题?文字是英文还是中文?请把截图里能看到的所有文字内容尽量完整地写出来,包括标题、副标题、导航、段落、按钮等。\"}\n```\n*Executing on server...*\n**Output:**\n**analyze_image_result_summary:** [{\"text\": \"\\\"文字内容: \\\\nOpenMontage \\\\nThe first open - source, agentic video production system. \\\\nPaste A Video Quick Start Try These Prompts Pipelines How It Works Providers Review Guide Agent Guide \\\\n⭐ 1K ON GITHUB FOLLOWING REPOSITORY OF THE DAY \\\\nFollowing The Build \\\\nYOUTUBE OPENMONTAG...\n 替换 outerHTML 后可能被框架重渲染了。我换用 `page.route` 在网络层拦截 HTML,从根上替换。", + "expectedFilter": true, + "expectedTail": "替换 outerHTML 后可能被框架重渲染了。我换用 `page.route` 在网络层拦截 HTML,从根上替换。" + }, + { + "name": "hit-4", + "input": "**🌐 Z.ai Built-in Tool: analyze_image**\n\n**Input:**\n```json\n{\"imageSource\":\"https://maas-log-prod.cn-wlcb.ufileos.com/anthropic/edeaaaf2-dd27-4e99-a2d5-7e0608a672ed/01-openmontage-readme.png?UCloudPublicKey=TOKEN_e15ba47a-d098-4fbd-9afc-a0dcf0e4e621&Expires=1782528397&Signature=NAZIrJl4ikfiVX4lVaD2+Am1zXY=\",\"prompt\":\"这张截图是亮色还是暗色主题?文字是英文还是中文?把标题、副标题、导航、第一段正文逐字写出来\"}\n```\n*Executing on server...*\n**Output:**\n**analyze_image_result_summary:** [{\"text\": \"\\\"这张截图是**亮色主题**,文字是**英文**。 \\\\n\\\\n标题:OpenMontage \\\\n副标题:The first open - source, agentic video production system. \\\\n导航:Paste A Video • Quick Start • Try These Prompts • Pipelines • How It Works • Providers • Review Guide • Agent • Guide \\\\n第一段正文:Turn your AI coding assistant into a fu...\n setContent 没生效。我改用 file:// 加载本地 HTML,这是最干净的方式。", + "expectedFilter": true, + "expectedTail": "setContent 没生效。我改用 file:// 加载本地 HTML,这是最干净的方式。" + }, + { + "name": "hit-5", + "input": "**🌐 Z.ai Built-in Tool: analyze_image**\n\n**Input:**\n```json\n{\"imageSource\":\"https://maas-log-prod.cn-wlcb.ufileos.com/anthropic/edeaaaf2-dd27-4e99-a2d5-7e0608a672ed/01-openmontage-readme.png?UCloudPublicKey=TOKEN_e15ba47a-d098-4fbd-9afc-a0dcf0e4e621&Expires=1782528397&Signature=NAZIrJl4ikfiVX4lVaD2+Am1zXY=\",\"prompt\":\"这张截图是亮色还是暗色主题?文字是英文还是中文?把标题、副标题、导航、第一段正文逐字写出来\"}\n```\n*Executing on server...*\n**Output:**\n**analyze_image_result_summary:** [{\"text\": \"\\\"这张截图是**亮色主题**,文字是**英文**。 \\\\n\\\\n标题:OpenMontage \\\\n副标题:The first open - source, agentic video production system. \\\\n导航:Paste A Video • Quick Start • Try These Prompts • Pipelines • How It Works • Providers • Review Guide • Agent • Guide \\\\n第一段正文:Turn your AI coding assistant into a fu...\n setContent 没生效。我改用 file:// 加载本地 HTML,这是最干净的方式。", + "expectedFilter": true, + "expectedTail": "setContent 没生效。我改用 file:// 加载本地 HTML,这是最干净的方式。" + }, + { + "name": "hit-6", + "input": "**🌐 Z.ai Built-in Tool: analyze_image**\n\n**Input:**\n```json\n{\"imageSource\":\"https://maas-log-prod.cn-wlcb.ufileos.com/anthropic/edeaaaf2-dd27-4e99-a2d5-7e0608a672ed/01-openmontage-readme.png?UCloudPublicKey=TOKEN_e15ba47a-d098-4fbd-9afc-a0dcf0e4e621&Expires=1782528466&Signature=WHnW4ampXafoX39MEtN1//RWGQU=\",\"prompt\":\"这张截图是亮色还是暗色主题?文字是英文还是中文?把标题、副标题、导航、第一段正文逐字写出来\"}\n```\n*Executing on server...*\n**Output:**\n**analyze_image_result_summary:** [{\"text\": \"\\\"这张截图是**亮色主题**。文字是**英文**。 \\\\n\\\\n标题:OpenMontage \\\\n副标题:The first open - source, agentic video production system. \\\\n导航:Paste A Video · Quick Start · Try These Prompts · Pipelines · How It Works · Providers · Review Guide · Agent · Guide \\\\n第一段正文:Turn your AI coding assistant into a fu...\n 非常奇怪。截图还是英文亮色,说明 Playwright 实际没去打开我的文件。让我用 Bash 检查文件是否写好,并强制 Playwright 重新打开。", + "expectedFilter": true, + "expectedTail": "非常奇怪。截图还是英文亮色,说明 Playwright 实际没去打开我的文件。让我用 Bash 检查文件是否写好,并强制 Playwright 重新打开。" + }, + { + "name": "hit-7", + "input": "**🌐 Z.ai Built-in Tool: analyze_image**\n\n**Input:**\n```json\n{\"imageSource\":\"https://maas-log-prod.cn-wlcb.ufileos.com/anthropic/edeaaaf2-dd27-4e99-a2d5-7e0608a672ed/01-openmontage-readme.png?UCloudPublicKey=TOKEN_e15ba47a-d098-4fbd-9afc-a0dcf0e4e621&Expires=1782527855&Signature=xq5R4clq7fDId4KjPdhOsKhobEw=\",\"prompt\":\"这张截图是亮色还是暗色主题?文字是英文还是中文?请把截图里能看到的所有文字内容尽量完整地写出来,包括标题、副标题、导航、段落、按钮等。\"}\n```\n*Executing on server...*\n**Output:**\n**analyze_image_result_summary:** [{\"text\": \"\\\"文字内容: \\nOpenMontage \\nThe first - source, agentic video production system. \\nPaste A Video Quick Start Try These Prompts Pipelines How It Works Providers Review Guide Agent Guide \\n⭐ 1K ON GITHUB FOLLOWING REPOSITORY OF THE DAY \\nFollowing The Build innerText 读出来是英文,证明 `page.route` 没替换到响应体(GitHub README 内容在 SSR HTML 里),所以浏览器收到的就是英文。换思路:跳过 GitHub 框架,我自己用 about:blank 页面渲染中文 markdown,做出一张干净的暗色主题截图。这样最稳。我先用 marked.js CDN 在 about:blank 渲染 README markdown + 翻译,这样 100% 可控。我之前已经把 OpenMontage README markdown 存到了 /tmp/calesthio_OpenMontage_README.md。我读取它,做翻译,然后用 about:blank + marked 渲染。\n\n实际上我之前已经做过翻译映射,现在只需要:\n1. 把 raw README markdown 的英文段落替换为中文\n2. 把 markdown 渲染成 HTML\n3. 在 about:blank 页面渲染暗色 + 中文 HTML\n4. 截图\n\n让我重新构建。这次我直接读 markdown 文件,做替换。不过另一个更简单的思路:我直接 outerHTML 替换后**立即**截图(在同一个 evaluate 调用里)。这样 react 没机会重渲染。\n\n实际上,做最终方案:**用 page.setContent 直接设置整个 HTML**,这样 Playwright 用纯静态方式打开页面,无 React 重渲染问题。我先从 GitHub 拿 README HTML,翻译,然后 setContent。\n\n我之前的尝试 read outerHTML(118KB)+ 替换 + write 回去,逻辑没问题。但截图前等了 1500ms,可能 react 重渲染了。\n\n让我试 setContent 方案:\n- page.goto GitHub 主页(带 React hydration)\n- evaluate 拿 outerHTML\n- 在外面做翻译替换\n- 在新 page(about:blank)setContent 这个翻译后的 HTML + 必要 CSS\n\n让我开始。我用一个新思路:直接生成一个静态 HTML 页面,包含 GitHub 风格的暗色 README 渲染,里面是我手动翻译的中文内容。然后 screenshot。\n\n最稳的方案:写一个本地 HTML 文件,里面是 markdown-body 风格的暗色 CSS + 我的中文 README 内容。用 Playwright 打开 file:// 截图。我来做:1. 读 OpenMontage README markdown2. 我翻译关键部分3. 渲染成 GitHub 风格的 HTML(用 marked CDN)\n4. 用 Playwright 打开(用 setContent 或 file://)\n5. 截图让我直接干。我用 marked.js CDN 在 Playwright about:blank 里渲染。我已经从前面拿到 OpenMontage README 的 markdown 内容了。但 118KB 太长,我会聚焦翻译\"开头的部分\"(截图只看顶部)。\n\n让我直接构造一个简洁的方案:在 about:blank 页面注入:\n1. 暗色 CSS\n2. 我的中文翻译内容(已组织好)\n3. screenshot\n\n让我开始构造中文 README 内容。我之前已经准备好了翻译映射,组合成一个完整的 README 顶部中文 HTML 即可。实际上更省事:我用 setContent 直接塞一个完整的中文 HTML 进去。这个 HTML 内容就是我手动组织的 README 顶部翻译。\n\n但是这种\"伪造\"的截图可能失去 GitHub 的视觉风格(badge、布局等),看起来不像 GitHub README 截图。\n\n权衡之下,用户要的是:- 暗色主题 ✅- 翻译过的内容 ✅- 风格看起来还像 GitHub README截真实 GitHub 截图失败,那我做一个\"GitHub 风格的暗色 README 截图\",看起来非常接近 GitHub。我组织中文 README,加上 GitHub 风格的 CSS,渲染截图。\n\n让我直接动手。我会做一个看起来像 GitHub 暗色 README 的静态 HTML,里面是 OpenMontage 项目的中文介绍。最终方案确定。先做 OpenMontage 的。我用 setContent 注入完整的中文 README HTML。但截图不需要包含全部 README,只需要顶部 1200px。所以我做一个紧凑的顶部版即可。\n\nOpenMontage README 顶部内容(截图里要展示的):\n1. logo + 项目名2. 副标题:世界首个开源 agentic 视频生产系统\n3. 导航链接(中文)4. Follow The Build\n5. 社交媒体图标\n6. 第一段:把你的 AI 编程助手变成完整的视频制片工作室...7. 第二段:重要区别:...8. SIGNAL FROM TOMORROW 案例为了让截图真实,我需要包含 logo(已有 01-openmontage-logo.jpg)。但 logo 是 GitHub CDN,file:// 加载可能失败。我用 file:// 直接读本地图片。\n\n好,开始干。我用 file:// 协议加载本地 HTML,里面用相对路径引用图片。具体步骤:\n1. 在 /tmp/build_openmontage.html 写一个完整的 README 风格 HTML2. 复制 logo 到同目录\n3. Playwright 打开 file:// 截图\n4. 截图保存到目标位置让我直接做。但更直接的方法:用 page.setContent 设置完整 HTML,使用 base64 嵌入 logo。这样无需文件依赖。让我执行这个方案。我已经在前面截图过程中,截到的是英文的,但浏览器加载 GitHub 主页时实际拿到了完整的 README HTML。我可以从那个截图反推 GitHub 的视觉风格,然后做\"伪造版\"。", + "expectedFilter": true, + "expectedTail": "好,开始干。我用 file:// 协议加载本地 HTML,里面用相对路径引用图片。具体步骤:\n1. 在 /tmp/build_openmontage.html 写一个完整的 README 风格 HTML2. 复制 logo 到同目录\n3. Playwright 打开 file:// 截图\n4. 截图保存到目标位置让我直接做。但更直接的方法:用 page.setContent 设置完整 HTML,使用 base64 嵌入 logo。这样无需文件依赖。让我执行这个方案。我已经在前面截图过程中,截到的是英文的,但浏览器加载 GitHub 主页时实际拿到了完整的 README HTML。我可以从那个截图反推 GitHub 的视觉风格,然后做\"伪造版\"。" + }, + { + "name": "normal-1", + "input": "✅ 会话已清除,下次消息将开始新会话。", + "expectedFilter": false, + "expectedTail": null + }, + { + "name": "normal-2", + "input": "找到了。你今天凌晨(06-27 01:18)写了 3 篇,应该就是这 3 篇:\n\n**📁 `/Users/rikiwang/Documents/Agent/ClaudeCode/文章/2026-06-27/`**\n\n1. **《盘点3个最近爆火的GitHub开源项目》**\n 📄 `三个AI Coding开源项目/盘点3个最近爆火的GitHub开源项目.md`\n\n2. **《本周GitHub Trending周榜Top10|Fable5 prompt留档、一行命令克隆网站、零成本AI操盘手》**\n 📄 `本周GitHub-Trending-Top10/本周GitHub-Trending周榜Top10-Fable5-prompt留档-一行命令克隆网站-零成本AI操盘手.md`\n\n3. **《text-to-lottie:用自然语言生成生产级 Lottie 动效》**\n 📄 `text-to-lottie-用自然语言生成生产级-lottie-动效.md`\n\n另外提醒一下,06-26 那天还有 2 篇:`google-design-md.md` 和 `firecrawl-免API-key.md`。如果你说的\"昨天\"是严格指 06-26,那只有这 2 篇;如果想找的是\"最近写的 3 篇\",那就是 06-27 这三篇。需要我把哪一篇的全文发你看吗?", + "expectedFilter": false, + "expectedTail": null + }, + { + "name": "normal-3", + "input": "已经全部搞定了 ✅\n\n**进度回顾:**\n\n1. ✅ **配图搜集**(10/10 完成)\n - 7 个项目:直接从 GitHub 官方 README 拉 demo / 截图 / hero 图(daily_stock_analysis、Stirling-PDF、ai-website-cloner、system_prompts_leaks、orca、no-mistakes、insomnia)\n - 3 个项目(OpenMontage、Agent-Reach、design.md):README 里没静态图,用 Playwright 截的 GitHub 主页顶部\n\n2. ✅ **图片插入 + 转 Word**\n - Markdown 文章里 10 个项目章节都已插入对应配图\n - pandoc 转成 docx,10 张图全部嵌入(文件大小 5.3MB)\n\n**📁 最终文件位置:**\n\n```\n/Users/rikiwang/Documents/Agent/ClaudeCode/文章/2026-06-27/本周GitHub-Trending-Top10/\n├── 本周GitHub-Trending周榜Top10-Fable5-prompt留档-一行命令克隆网站-零成本AI操盘手.md(已加图)\n├── 本周GitHub-Trending周榜Top10-Fable5-prompt留档-一行命令克隆网站-零成本AI操盘手.docx ⬅️ 发你看的这个\n└── images/(13 张原图)\n```\n\n你打开 Word 看一下配图是否合适。如果某张觉得不行,告诉我哪个项目,我换思路再找一张。", + "expectedFilter": false, + "expectedTail": null + }, + { + "name": "normal-4", + "input": "Word 文件已生成,发你了 👇\n\n📄 **`/Users/rikiwang/Documents/Agent/ClaudeCode/文章/2026-06-27/本周GitHub-Trending-Top10/本周GitHub-Trending周榜Top10-Fable5-prompt留档-一行命令克隆网站-零成本AI操盘手.docx`**(5.3 MB)\n\n**10 个项目的配图来源:**\n\n| # | 项目 | 配图类型 | 来源 |\n|---|------|---------|------|\n| 1 | OpenMontage | README 顶部截图 | GitHub 主页(README 引用的全是 MP4) |\n| 2 | Agent-Reach | README 顶部截图 | GitHub 主页(原文只有微信群二维码) |\n| 3 | daily_stock_analysis | 工作区演示动图 | 官方 `readme_workspace_tour_20260510.gif` |\n| 4 | Stirling-PDF | 仪表盘截图 | 官方 `images/home-light.png` |\n| 5 | ai-website-cloner | 克隆前后对比图 | 官方 `docs/design-references/comparison.png` |\n| 6 | system_prompts_leaks | ChatGPT 泄漏截图 | 官方 user-attachment |\n| 7 | orca | 桌面 App hero 图 | 官方 `docs/assets/readme-hero.jpg` |\n| 8 | design.md | README 顶部截图 | GitHub 主页(README 无静态图) |\n| 9 | no-mistakes | 工作流程演示动图 | 官方 `demo.gif` |\n| 10 | insomnia | 主界面截图 | 官方 `screenshots/main.png` |\n\n**几个可能需要你确认的点:**\n- 第 1、2、8 张是 README 顶部截图(裁取 1200~1500px 高),如果觉得不够典型,可以换思路(比如 OpenMontage 截 YouTube demo 视频缩略图,Agent-Reach 截 14 平台支持列表那一段)\n- gif 动图(第 3、9 张)在 Word 里能动;如果上公众号可能要单独处理\n- 第 6 张 ChatGPT 泄漏图是英文界面,如果想换更冲击的\"Fable 5 prompt diff\"图也可以\n\n要我换哪张直接说。", + "expectedFilter": false, + "expectedTail": null + }, + { + "name": "normal-5", + "input": "润色版 Word 已重新生成 👇\n\n📄 **`/Users/rikiwang/Documents/Agent/ClaudeCode/文章/2026-06-27/本周GitHub-Trending-Top10/本周GitHub-Trending周榜Top10-Fable5-prompt留档-一行命令克隆网站-零成本AI操盘手.docx`**\n\n**对照你那份清单逐条处理的情况:**\n\n| AI 味儿特征 | 原文命中 | 处理方式 |\n|---|---|---|\n| 不是...而是... | L87 \"跑的不是简单抓 HTML,而是 5 阶段流水线\" | 改成\"跑的不是简单抓 HTML,是一条 5 阶段流水线\" |\n| 包裹比喻的引号 | \"眼睛\"\"指挥官\"\"档案室\"\"舰队\"(标题里还有)\"扒所有 Agent 底裤\"\"重武器\" | 删除了正文里的,标题里\"舰队\"保留(小标题允许有画面感) |\n| —— 滥用 | 30+ 处 | 削掉一半以上,改用句号或逗号 |\n| 疑问句+回答开头 | L25、L85、L123 三处 | 全删,直接进入事情 |\n| 省略号罗列 | L13、L51、L127 三处带 …… 的列表 | 去掉省略号,改成自然停顿 |\n| \"一句话讲清楚\"变体 | L103、L173 | 改为正常陈述 |\n| 数字英文逗号 | 没发现命中 | — |\n| 英文逗号 | 没发现命中 | — |\n\n**几个权衡说明:**\n- 数据源、agent 列表(L27、L53、L105-109、L127 等)这种\"密集罗列\"我保留了,因为这是项目核心卖点,删掉等于阉割信息量。你的清单第 9 条主要针对的是为修辞而罗列的情况。\n- 项目名、英文 Slogan、引用句保留了引号,这些是直接引语。\n- 标题里的\"舰队\"\"操盘手\"\"终结者\"这种保留了——这是小标题的张力,不算 AI 腔。\n\n你打开看一眼,如果还有觉得别扭的地方告诉我。", + "expectedFilter": false, + "expectedTail": null + }, + { + "name": "normal-6", + "input": "我先建个任务清单,然后逐个搞定。", + "expectedFilter": false, + "expectedTail": null + }, + { + "name": "normal-7", + "input": "我用更高效的方式:直接拉每个项目 README,提取里面的官方配图(banner / demo / 架构图),跳过手动截图。先并行拉 5 个项目的 README。", + "expectedFilter": false, + "expectedTail": null + }, + { + "name": "normal-8", + "input": "8 个 README 拿到了。现在解析里面的图片链接(每个项目找最有代表性的 1-2 张)。", + "expectedFilter": false, + "expectedTail": null + } + ] +} \ No newline at end of file diff --git a/src/tests/provider.test.ts b/src/tests/provider.test.ts new file mode 100644 index 0000000..5c7a5bc --- /dev/null +++ b/src/tests/provider.test.ts @@ -0,0 +1,105 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { handleStreamLine, type StreamParserState } from '../claude/provider.js'; + +function freshState(): StreamParserState { + return { sessionId: '', textParts: [], trackingSkill: false, skillInputAccum: '' }; +} + +test('handleStreamLine: system init 设置 sessionId', () => { + const state = freshState(); + handleStreamLine( + JSON.stringify({ type: 'system', subtype: 'init', session_id: 'sess-123' }), + state, + {}, + ); + assert.equal(state.sessionId, 'sess-123'); +}); + +test('handleStreamLine: text_delta 触发 onText', () => { + const calls: string[] = []; + handleStreamLine( + JSON.stringify({ + type: 'stream_event', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hello' } }, + }), + freshState(), + { onText: (t) => calls.push(t) }, + ); + assert.deepEqual(calls, ['hello']); +}); + +test('handleStreamLine: content_block_stop 重置 trackingSkill,无回调', () => { + const state = freshState(); + state.trackingSkill = true; + let textCalls = 0; + let turnEndCalls = 0; + handleStreamLine( + JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }), + state, + { onText: () => textCalls++, onTurnEnd: () => turnEndCalls++ }, + ); + assert.equal(state.trackingSkill, false); + assert.equal(textCalls, 0); + assert.equal(turnEndCalls, 0); +}); + +test('handleStreamLine: assistant 消息文本累积到 textParts', () => { + const state = freshState(); + handleStreamLine( + JSON.stringify({ + type: 'assistant', + message: { content: [{ type: 'text', text: '回复内容' }] }, + }), + state, + {}, + ); + assert.deepEqual(state.textParts, ['回复内容']); +}); + +test('handleStreamLine: 空行和非法 JSON 静默跳过', () => { + const state = freshState(); + handleStreamLine('', state, {}); + handleStreamLine('not json', state, {}); + handleStreamLine(' ', state, {}); + assert.deepEqual(state.textParts, []); +}); + +test('handleStreamLine: message_delta 带 stop_reason 触发 onTurnEnd', () => { + const calls: string[] = []; + handleStreamLine( + JSON.stringify({ + type: 'stream_event', + event: { type: 'message_delta', delta: { stop_reason: 'end_turn' } }, + }), + freshState(), + { onTurnEnd: (r) => calls.push(r) }, + ); + assert.deepEqual(calls, ['end_turn']); +}); + +test('handleStreamLine: message_delta 无 stop_reason 不触发 onTurnEnd', () => { + const calls: string[] = []; + handleStreamLine( + JSON.stringify({ + type: 'stream_event', + event: { type: 'message_delta', delta: {} }, + }), + freshState(), + { onTurnEnd: (r) => calls.push(r) }, + ); + assert.deepEqual(calls, []); +}); + +test('handleStreamLine: tool_use stop_reason 也正常透传', () => { + const calls: string[] = []; + handleStreamLine( + JSON.stringify({ + type: 'stream_event', + event: { type: 'message_delta', delta: { stop_reason: 'tool_use' } }, + }), + freshState(), + { onTurnEnd: (r) => calls.push(r) }, + ); + assert.deepEqual(calls, ['tool_use']); +}); diff --git a/src/tests/tool-noise-filter.test.ts b/src/tests/tool-noise-filter.test.ts new file mode 100644 index 0000000..b4cc701 --- /dev/null +++ b/src/tests/tool-noise-filter.test.ts @@ -0,0 +1,94 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { filterToolNoise } from '../claude/tool-noise-filter.js'; + +// 测试跑在 dist/tests/,但 fixture JSON 不会被 tsc 复制——直接从 src/ 读。 +const fixturePath = join(process.cwd(), 'src', 'tests', 'fixtures', 'tool-noise', 'real-cases.json'); +const fixture = JSON.parse(readFileSync(fixturePath, 'utf8')) as { + cases: Array<{ + name: string; + input: string; + expectedFilter: boolean; + expectedTail: string | null; + }>; +}; + +test('real-cases.json 7 条命中样本应被压缩到 🔧 前缀', () => { + const hits = fixture.cases.filter((c) => c.expectedFilter); + assert.equal(hits.length, 7, '今日 log 应该恰好命中 7 条'); + for (const c of hits) { + const got = filterToolNoise(c.input); + assert.ok(got.startsWith('🔧 [工具调用]'), `${c.name}: 缺少 🔧 前缀 → ${got.slice(0, 80)}`); + assert.ok(got.length < c.input.length / 2, `${c.name}: 压缩率不够 (input=${c.input.length} got=${got.length})`); + if (c.expectedTail) { + assert.ok(got.includes(c.expectedTail), `${c.name}: 没有保留期望的尾巴 → ${c.expectedTail.slice(0, 60)}`); + } + } +}); + +test('real-cases.json 正常样本不应被改写', () => { + const normals = fixture.cases.filter((c) => !c.expectedFilter); + assert.ok(normals.length >= 5, '至少 5 条正常样本'); + for (const c of normals) { + const got = filterToolNoise(c.input); + assert.equal(got, c.input, `${c.name}: 正常消息被误改`); + } +}); + +test('短消息直接放行', () => { + assert.equal(filterToolNoise('OK, 看一下'), 'OK, 看一下'); + assert.equal(filterToolNoise(''), ''); +}); + +test('有 URL 但无 ```json``` 围栏:不命中', () => { + const text = [ + '我去翻了翻文档:', + 'https://example.com/docs/api', + '', + '```', + 'const x = fetch(url)', + 'const y = await x.json()', + 'const z = JSON.stringify(y)', + 'const w = JSON.parse(z)', + 'const v = JSON.stringify(w)', + 'const u = JSON.parse(v)', + '```', + '就是这么个写法。', + ].join('\n'); + assert.equal(filterToolNoise(text), text); +}); + +test('有 ```json``` 围栏但无 URL:不命中', () => { + const text = [ + '配置长这样:', + '', + '```json', + '{"name": "demo", "version": "1.0.0", "main": "index.js", "license": "MIT"}', + '```', + '', + '直接抄就行。', + ].join('\n'); + assert.equal(filterToolNoise(text), text); +}); + +test('长度恰好 400:放行(边界 < 400)', () => { + const text = 'a'.repeat(400); + assert.equal(filterToolNoise(text), text); +}); + +test('命中但没有可提取尾巴:返回中性占位', () => { + // 全是结构性行,没有 prose tail + const text = [ + '```json', + '{"imageSource":"https://example.com/a.png","prompt":"describe"}', + '```', + '**Output:**', + '**result_summary:** [{"text": "hello world"}]', + ].join('\n') + '\n'; + // bump length so it crosses the 400-char threshold + const padded = text + ' '.repeat(420 - text.length); + const got = filterToolNoise(padded); + assert.equal(got, '🔧 [工具调用]'); +}); diff --git a/src/tests/turn-router.test.ts b/src/tests/turn-router.test.ts new file mode 100644 index 0000000..322471f --- /dev/null +++ b/src/tests/turn-router.test.ts @@ -0,0 +1,84 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { TurnRouter, type RoutedMessage } from '../claude/turn-router.js'; + +function newRouter() { + const emitted: RoutedMessage[] = []; + const router = new TurnRouter((m) => emitted.push(m)); + return { router, emitted }; +} + +test('onText 累积不立即 emit', () => { + const { router, emitted } = newRouter(); + router.onText('hello '); + router.onText('world'); + assert.deepEqual(emitted, []); +}); + +test('onTurnEnd(tool_use) 把 turnBuffer 作为 interstitial emit', () => { + const { router, emitted } = newRouter(); + router.onText('让我看一下'); + router.onTurnEnd('tool_use'); + assert.deepEqual(emitted, [{ text: '让我看一下', role: 'interstitial' }]); +}); + +test('onTurnEnd(end_turn) 不立即 emit,攒到 drain', () => { + const { router, emitted } = newRouter(); + router.onText('最终答案第一段'); + router.onTurnEnd('end_turn'); + assert.deepEqual(emitted, []); + router.drain(); + assert.deepEqual(emitted, [{ text: '最终答案第一段', role: 'final' }]); +}); + +test('多个 end_turn 回合用 \\n\\n 连接成一个 final', () => { + const { router, emitted } = newRouter(); + router.onText('段一'); + router.onTurnEnd('pause_turn'); + router.onText('段二'); + router.onTurnEnd('end_turn'); + router.drain(); + assert.deepEqual(emitted, [{ text: '段一\n\n段二', role: 'final' }]); +}); + +test('tool_use 和 end_turn 混合:interstitial 立即发,final 攒到 drain', () => { + const { router, emitted } = newRouter(); + router.onText('让我查一下'); + router.onTurnEnd('tool_use'); // → interstitial 立即 + router.onText('找到了。'); + router.onText('详细说明...'); + router.onTurnEnd('end_turn'); // → final 攒着 + router.drain(); // → final 发出 + assert.deepEqual(emitted, [ + { text: '让我查一下', role: 'interstitial' }, + { text: '找到了。详细说明...', role: 'final' }, + ]); +}); + +test('空文本回合不产生空消息', () => { + const { router, emitted } = newRouter(); + router.onTurnEnd('tool_use'); // turnBuffer 空 + router.onTurnEnd('end_turn'); // turnBuffer 空 + router.drain(); + assert.deepEqual(emitted, []); +}); + +test('onTurnEnd 未触发时 drain 也能把残留 turnBuffer 当 interstitial 发出', () => { + const { router, emitted } = newRouter(); + router.onText('未结束的残留'); + router.drain(); + assert.deepEqual(emitted, [ + { text: '未结束的残留', role: 'interstitial' }, + ]); +}); + +test('纯文本 Q&A(无 tool_use,单 end_turn)整段作为 final', () => { + const { router, emitted } = newRouter(); + const chunks = ['闭包是...', '举个例子...', '总结...']; + for (const c of chunks) router.onText(c); + router.onTurnEnd('end_turn'); + router.drain(); + assert.deepEqual(emitted, [ + { text: chunks.join(''), role: 'final' }, + ]); +}); diff --git a/src/tools/visualize-logs.ts b/src/tools/visualize-logs.ts new file mode 100644 index 0000000..33b952c --- /dev/null +++ b/src/tools/visualize-logs.ts @@ -0,0 +1,731 @@ +#!/usr/bin/env node +/** + * Log Visualizer for wechat-claude-code + * + * Generates a self-contained HTML page that compares Claude CLI's original + * output with what the user actually saw in WeChat. + * + * Usage: + * npx tsx src/tools/visualize-logs.ts [--date YYYY-MM-DD] [--output path] [--open] + */ + +import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { execSync } from 'node:child_process'; + +const DATA_DIR = process.env.WCC_DATA_DIR || join(homedir(), '.wechat-claude-code'); + +// ─── Keepalive messages (must match main.ts SILENCE_MESSAGES) ─── +const SILENCE_MESSAGES = new Set([ + '我还在处理中,这个问题有点复杂,请再稍等一下', + '正在努力干活中,马上就有结果了,请稍等片刻', + '有点复杂正在处理,再给我一点时间,很快就好', + '快好了别着急,正在收尾阶段,马上给你回复', + '还在跑呢,任务量比较大,不过马上就能出结果了', + '任务比想象的复杂一些,再等等我,正在全力处理', + '正在处理中,进展顺利,再等一会儿就好', + '还没完不过已经快了,再给我一分钟就能搞定', + '我在认真思考这个问题,请再稍等一会儿', + '稍微有点棘手,不过已经快解决了,再等我一下', + '我还在处理,请稍等一下', + '请稍等一下', +]); + +// ─── Types ─── + +interface ParsedLine { + timestamp: string; + level: string; + message: string; + raw?: string; +} + +interface SentMessage { + text: string; + timestamp: string; + clientId: string; + isKeepalive: boolean; +} + +interface Session { + index: number; + startTime: string; + endTime: string | null; + durationSec: number | null; + sessionId: string; + resume: boolean; + cwd: string; + hasError: boolean; + textLength: number | null; + + userMessages: Array<{ text: string; timestamp: string }>; + sentMessages: SentMessage[]; + slashCommands: string[]; + + // Enriched from chatHistory + claudeFullOutput: string | null; +} + +// ─── CLI Args ─── + +function parseArgs(): { date: string; output?: string; open: boolean } { + const args = process.argv.slice(2); + let date = ''; + let output: string | undefined; + let open = false; + + // Default date: today in UTC+8 + const now = new Date(Date.now() + 8 * 60 * 60 * 1000); + date = now.toISOString().slice(0, 10); + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--date' && args[i + 1]) { + date = args[++i]; + } else if (args[i] === '--output' && args[i + 1]) { + output = args[++i]; + } else if (args[i] === '--open') { + open = true; + } + } + return { date, output, open }; +} + +// ─── Log Parsing ─── + +function tryParseJson(raw: string): any { + try { + return JSON.parse(raw); + } catch { + // Try to fix corrupted JSON from redact() — context_token gets mangled + let fixed = raw; + // Pattern: "context_token": "***"***".replace(/"[^"]*"$/, "") + fixed = fixed.replace(/"(?:context_token)"\s*:\s*"\*\*\*"\*\*\*"[^}]*?\.replace\([^)]+\)/g, '"context_token":"***"'); + try { + return JSON.parse(fixed); + } catch { + return null; + } + } +} + +function parseLogFile(logPath: string): ParsedLine[] { + if (!existsSync(logPath)) { + console.error(`Log file not found: ${logPath}`); + process.exit(1); + } + const raw = readFileSync(logPath, 'utf-8'); + const lines: ParsedLine[] = []; + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + // Format: [JSON] + const match = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+(?:Z|[+-]\d{2}:\d{2}))\s+(INFO|WARN|ERROR|DEBUG)\s+(\S+)(?:\s+(.*))?$/); + if (!match) continue; + lines.push({ + timestamp: match[1], + level: match[2], + message: match[3], + raw: match[4], + }); + } + return lines; +} + +// ─── Data Extraction ─── + +function extractSentMessage(line: ParsedLine): { text: string; clientId: string } | null { + if (line.message !== 'API' || !line.raw) return null; + // "API request {"url":"...sendmessage..." + const idx = line.raw.indexOf('sendmessage'); + if (idx === -1) return null; + // Find the JSON object start + const jsonStart = line.raw.indexOf('{'); + if (jsonStart === -1) return null; + const data = tryParseJson(line.raw.slice(jsonStart)); + if (!data?.body?.msg?.item_list) return null; + for (const item of data.body.msg.item_list) { + if (item.text_item?.text) { + return { text: item.text_item.text, clientId: data.body.msg.client_id || '' }; + } + } + return null; +} + +function extractUserMessage(line: ParsedLine): { text: string; timestamp: string } | null { + if (line.message !== 'API' || !line.raw) return null; + const jsonStart = line.raw.indexOf('{'); + if (jsonStart === -1) return null; + const data = tryParseJson(line.raw.slice(jsonStart)); + if (!data?.msgs || !Array.isArray(data.msgs)) return null; + for (const msg of data.msgs) { + if (msg.from_user_id?.includes('@im.wechat') && msg.item_list) { + for (const item of msg.item_list) { + if (item.text_item?.text) { + return { text: item.text_item.text, timestamp: line.timestamp }; + } + if (item.voice_item?.text) { + return { text: `[语音] ${item.voice_item.text}`, timestamp: line.timestamp }; + } + } + } + } + return null; +} + +// ─── Session Reconstruction ─── + +function reconstructSessions(lines: ParsedLine[]): Session[] { + const sessions: Session[] = []; + let current: Session | null = null; + let pendingUserMsgs: Array<{ text: string; timestamp: string }> = []; + + for (const line of lines) { + // Collect user messages that arrive before a query starts + const userMsg = extractUserMessage(line); + if (userMsg) { + pendingUserMsgs.push(userMsg); + } + + // Slash commands — handled outside sessions + if (line.message === 'Slash' && line.raw?.startsWith('command:')) { + if (current) { + current.slashCommands.push(line.raw.replace('command: ', '')); + } + pendingUserMsgs = []; + continue; + } + + // Query start + if (line.message === 'Starting' && line.raw?.includes('Claude CLI query')) { + const data = tryParseJson(line.raw.replace(/^.*?\{/, '{')); + current = { + index: sessions.length + 1, + startTime: line.timestamp, + endTime: null, + durationSec: null, + sessionId: '', + resume: data?.resume ?? false, + cwd: data?.cwd ?? '', + hasError: false, + textLength: null, + userMessages: [...pendingUserMsgs], + sentMessages: [], + slashCommands: [], + claudeFullOutput: null, + }; + pendingUserMsgs = []; + sessions.push(current); + continue; + } + + // Query completed + if (line.message === 'Claude' && line.raw?.includes('CLI query completed')) { + if (current) { + current.endTime = line.timestamp; + const start = new Date(current.startTime).getTime(); + const end = new Date(line.timestamp).getTime(); + current.durationSec = Math.round((end - start) / 1000); + const data = tryParseJson(line.raw.replace(/^.*?\{/, '{')); + if (data) { + current.sessionId = data.sessionId || ''; + current.textLength = data.textLength ?? null; + current.hasError = data.hasError ?? false; + } + } + current = null; + continue; + } + + // Query aborted + if (line.message === 'Claude' && line.raw?.includes('CLI query aborted')) { + if (current) { + current.endTime = line.timestamp; + const start = new Date(current.startTime).getTime(); + const end = new Date(line.timestamp).getTime(); + current.durationSec = Math.round((end - start) / 1000); + current.hasError = true; + } + current = null; + continue; + } + + // Query timed out + if (line.message === 'Claude' && line.raw?.includes('query timed out')) { + if (current) { + current.hasError = true; + } + continue; + } + + // Sent messages during a session + if (current) { + const sent = extractSentMessage(line); + if (sent) { + current.sentMessages.push({ + text: sent.text, + timestamp: line.timestamp, + clientId: sent.clientId, + isKeepalive: SILENCE_MESSAGES.has(sent.text), + }); + } + } + } + + return sessions; +} + +// ─── Chat History Enrichment ─── + +interface ChatEntry { + role: string; + content: string; + timestamp: number; +} + +function loadChatHistories(): ChatEntry[] { + const sessionDir = join(DATA_DIR, 'sessions'); + if (!existsSync(sessionDir)) return []; + const entries: ChatEntry[] = []; + for (const f of readdirSync(sessionDir)) { + if (!f.endsWith('.json')) continue; + try { + const data = JSON.parse(readFileSync(join(sessionDir, f), 'utf-8')); + if (data.chatHistory && Array.isArray(data.chatHistory)) { + entries.push(...data.chatHistory); + } + } catch { /* skip */ } + } + return entries; +} + +function enrichSessions(sessions: Session[], chatEntries: ChatEntry[]): void { + // Build lookup of assistant entries by timestamp + const assistantEntries = chatEntries + .filter(e => e.role === 'assistant') + .sort((a, b) => a.timestamp - b.timestamp); + + for (const session of sessions) { + if (!session.endTime) continue; + const endMs = new Date(session.endTime).getTime(); + // Find the closest assistant entry within 60s of session end + let best: ChatEntry | null = null; + let bestDist = Infinity; + for (const entry of assistantEntries) { + const dist = Math.abs(entry.timestamp - endMs); + if (dist < bestDist && dist < 60_000) { + bestDist = dist; + best = entry; + } + } + if (best) { + session.claudeFullOutput = best.content; + } + } +} + +// ─── Diff ─── + +interface DiffSegment { + type: 'same' | 'lost' | 'extra'; + text: string; +} + +function computeDiff(fullOutput: string, sentMessages: SentMessage[]): DiffSegment[] { + const sent = sentMessages + .filter(m => !m.isKeepalive) + .map(m => m.text) + .join('\n'); + + if (!fullOutput && !sent) return []; + if (!fullOutput) return [{ type: 'extra', text: sent }]; + if (!sent) return [{ type: 'lost', text: fullOutput }]; + + // Simple character-level diff: walk both strings + const segments: DiffSegment[] = []; + let i = 0, j = 0; + + while (i < fullOutput.length || j < sent.length) { + // Find common prefix from current positions + let commonLen = 0; + while (i + commonLen < fullOutput.length && j + commonLen < sent.length && fullOutput[i + commonLen] === sent[j + commonLen]) { + commonLen++; + } + + if (commonLen > 0) { + // Check if there's lost text before the common section + if (i > 0 && j > 0) { + // Already handled + } + segments.push({ type: 'same', text: fullOutput.slice(i, i + commonLen) }); + i += commonLen; + j += commonLen; + } else { + // Find where they realign + let lostEnd = i; + let extraEnd = j; + + // Look ahead for realignment + let found = false; + const lookAhead = Math.min(fullOutput.length - i, sent.length - j, 200); + for (let k = 1; k <= lookAhead; k++) { + // Check if skipping k chars from fullOutput matches + if (i + k < fullOutput.length && fullOutput[i + k] === sent[j]) { + segments.push({ type: 'lost', text: fullOutput.slice(i, i + k) }); + i += k; + found = true; + break; + } + // Check if skipping k chars from sent matches + if (j + k < sent.length && fullOutput[i] === sent[j + k]) { + segments.push({ type: 'extra', text: sent.slice(j, j + k) }); + j += k; + found = true; + break; + } + } + + if (!found) { + // No realignment found nearby, mark rest as different + if (i < fullOutput.length) { + segments.push({ type: 'lost', text: fullOutput.slice(i) }); + i = fullOutput.length; + } + if (j < sent.length) { + segments.push({ type: 'extra', text: sent.slice(j) }); + j = sent.length; + } + } + } + } + + return segments; +} + +// ─── HTML Generation ─── + +function esc(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function fmtTime(ts: string): string { + const d = new Date(ts); + // Convert to UTC+8 + const utc8 = new Date(d.getTime() + 8 * 60 * 60 * 1000); + return utc8.toISOString().slice(11, 19); +} + +function fmtDuration(sec: number | null): string { + if (sec === null) return '?'; + if (sec < 60) return `${sec}s`; + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}m${s}s`; +} + +function renderDiff(segments: DiffSegment[]): string { + if (segments.length === 0) return '无法比较'; + const isAllSame = segments.every(s => s.type === 'same'); + if (isAllSame) return '完全一致,无文本丢失'; + + let html = ''; + for (const seg of segments) { + const text = esc(seg.text); + if (seg.type === 'same') { + html += text; + } else if (seg.type === 'lost') { + html += `${text}`; + } else { + html += `${text}`; + } + } + return html; +} + +function generateHtml(sessions: Session[], date: string): string { + const totalSent = sessions.reduce((sum, s) => sum + s.sentMessages.filter(m => !m.isKeepalive).length, 0); + const totalKeepalive = sessions.reduce((sum, s) => sum + s.sentMessages.filter(m => m.isKeepalive).length, 0); + const totalErrors = sessions.filter(s => s.hasError).length; + const withFullOutput = sessions.filter(s => s.claudeFullOutput !== null).length; + + return ` + + + + +WeChat-Claude-Code Log Viewer — ${esc(date)} + + + +

WeChat-Claude-Code Log Viewer

+

${esc(date)} — ${sessions.length} 个会话

+ +
+
会话 ${sessions.length}
+
发送消息 ${totalSent}
+
心跳 ${totalKeepalive}
+
错误 ${totalErrors}
+
有完整输出 ${withFullOutput}/${sessions.length}
+
+ +
+ + + + + +
+ +${sessions.map(s => renderSessionCard(s)).join('\n')} + + + +`; +} + +function renderSessionCard(s: Session): string { + const startT = fmtTime(s.startTime); + const endT = s.endTime ? fmtTime(s.endTime) : '?'; + const dur = fmtDuration(s.durationSec); + const resumeBadge = s.resume + ? 'resume' + : 'new'; + const errorBadge = s.hasError ? 'error' : ''; + + // Sent messages HTML + let sentHtml = ''; + const realMessages = s.sentMessages.filter(m => !m.isKeepalive); + const keepaliveMessages = s.sentMessages.filter(m => m.isKeepalive); + + if (s.sentMessages.length === 0) { + sentHtml = '
无发送记录
'; + } else { + sentHtml = '
'; + for (const m of realMessages) { + sentHtml += `
${fmtTime(m.timestamp)}
${esc(m.text)}
`; + } + for (const m of keepaliveMessages) { + sentHtml += `
${fmtTime(m.timestamp)} [心跳]
${esc(m.text)}
`; + } + sentHtml += '
'; + } + + // Claude full output + const claudeHtml = s.claudeFullOutput + ? `
${esc(s.claudeFullOutput)}
` + : '
完整输出不可用(会话历史已修剪或未记录)
'; + + // Diff + let diffHtml: string; + let hasDiff = false; + if (s.claudeFullOutput) { + const segments = computeDiff(s.claudeFullOutput, s.sentMessages); + hasDiff = !segments.every(seg => seg.type === 'same'); + diffHtml = `
${renderDiff(segments)}
`; + } else { + diffHtml = '
无可比数据
'; + hasDiff = false; + } + + const totalSentChars = realMessages.reduce((sum, m) => sum + m.text.length, 0); + const diffIndicator = hasDiff ? ' style="color:var(--red)"' : ' style="color:var(--green)"'; + + return `
+
+
+ #${s.index} ${startT} → ${endT} (${dur}) + ${resumeBadge}${errorBadge} +
+
+ ${s.claudeFullOutput ? `${s.claudeFullOutput.length} → ${totalSentChars} chars` : 'no history'} + ${s.sessionId ? `sid: ${s.sessionId.slice(0, 8)}...` : ''} +
+
+
+
+
用户输入
+
${s.userMessages.map(m => esc(m.text)).join('\n') || '(无)'}
+
+
+
Claude 完整输出${s.claudeFullOutput ? ` ${s.claudeFullOutput.length} chars` : ''}
+ ${claudeHtml} +
+
+
发送到微信 ${realMessages.length} 条消息${keepaliveMessages.length > 0 ? ` + ${keepaliveMessages.length} 心跳` : ''}
+ ${sentHtml} +
+
+
差异对比
+ ${diffHtml} +
+
+
`; +} + +// ─── Main ─── + +function main() { + const { date, output, open } = parseArgs(); + + const logPath = join(DATA_DIR, 'logs', `bridge-${date}.log`); + console.log(`Parsing log: ${logPath}`); + + const lines = parseLogFile(logPath); + console.log(`Parsed ${lines.length} log lines`); + + const sessions = reconstructSessions(lines); + console.log(`Reconstructed ${sessions.length} sessions`); + + const chatEntries = loadChatHistories(); + console.log(`Loaded ${chatEntries.length} chat history entries`); + enrichSessions(sessions, chatEntries); + + const enriched = sessions.filter(s => s.claudeFullOutput !== null).length; + console.log(`Enriched ${enriched}/${sessions.length} sessions with full output`); + + const html = generateHtml(sessions, date); + + if (output) { + writeFileSync(output, html, 'utf-8'); + console.log(`Written to: ${output}`); + if (open) { + execSync(`open "${output}"`); + } + } else { + process.stdout.write(html); + } +} + +main(); diff --git a/src/wechat/accounts.ts b/src/wechat/accounts.ts new file mode 100644 index 0000000..aa057a8 --- /dev/null +++ b/src/wechat/accounts.ts @@ -0,0 +1,64 @@ +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { readdirSync, statSync } from 'node:fs'; +import { loadJson, saveJson, validateAccountId } from '../store.js'; +import { logger } from '../logger.js'; + +export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'; + +export interface AccountData { + botToken: string; + accountId: string; + baseUrl: string; + userId: string; + createdAt: string; +} + +const ACCOUNTS_DIR = join(homedir(), '.wechat-claude-code', 'accounts'); + +function accountPath(accountId: string): string { + validateAccountId(accountId); + return join(ACCOUNTS_DIR, `${accountId}.json`); +} + +/** Persist account credentials to disk. */ +export function saveAccount(data: AccountData): void { + const filePath = accountPath(data.accountId); + saveJson(filePath, data); + logger.info('Account saved', { accountId: data.accountId }); +} + +/** Load account credentials by ID. Returns null if not found. */ +export function loadAccount(accountId: string): AccountData | null { + const filePath = accountPath(accountId); + const data = loadJson(filePath, null); + if (data) { + logger.info('Account loaded', { accountId }); + } + return data; +} + +/** Load the most recently modified account. Returns null if none exist. */ +export function loadLatestAccount(): AccountData | null { + try { + const files = readdirSync(ACCOUNTS_DIR).filter((f) => f.endsWith('.json')); + if (files.length === 0) return null; + + let latestFile = files[0]; + let latestMtime = 0; + + for (const file of files) { + const stat = statSync(join(ACCOUNTS_DIR, file)); + if (stat.mtimeMs > latestMtime) { + latestMtime = stat.mtimeMs; + latestFile = file; + } + } + + const accountId = latestFile.replace(/\.json$/, ''); + return loadAccount(accountId); + } catch { + // Directory does not exist or is unreadable + return null; + } +} diff --git a/src/wechat/api.ts b/src/wechat/api.ts new file mode 100644 index 0000000..0eb5abb --- /dev/null +++ b/src/wechat/api.ts @@ -0,0 +1,235 @@ +import type { + GetUpdatesResp, + SendMessageReq, + GetUploadUrlResp, + SendTypingReq, + GetConfigResp, +} from './types.js'; +import { logger } from '../logger.js'; + +/** Generate a random base64 identifier. */ +function generateUin(): string { + const buf = new Uint8Array(4); + crypto.getRandomValues(buf); + return Buffer.from(buf).toString('base64'); +} + +export class WeChatApi { + private readonly token: string; + private readonly baseUrl: string; + private readonly uin: string; + private readonly nextSendTime = new Map(); + private static readonly MIN_SEND_INTERVAL = 2500; + // Cooldown applied after a rate-limit (ret:-2). Aligned with the circuit + // breaker window so they don't fight each other. + private static readonly RATE_LIMIT_COOLDOWN_MS = 30_000; + + // ── Circuit breaker ──────────────────────────────────────────────────── + // Borrowed from Hermes WeChat adapter: trip after the first genuine + // rate-limit in a 30s window, stay open 30s. While open, all sends fail + // fast without hitting the API — breaking the 14-minute "head-banging" + // loop we observed in production logs. + private static readonly CIRCUIT_THRESHOLD = 1; + private static readonly CIRCUIT_WINDOW_MS = 30_000; + private static readonly CIRCUIT_OPEN_MS = 30_000; + private readonly _rateLimitEvents: number[] = []; + private _circuitUntil = 0; + + // ret:-2 + errmsg="unknown error" is a stale-session signal (same family + // as errcode:-14), not a real rate-limit. Pause that user 10 minutes + // instead of cycling through the rate-limit path. + private static readonly STALE_SESSION_PAUSE_MS = 10 * 60 * 1000; + + constructor(token: string, baseUrl: string = 'https://ilinkai.weixin.qq.com') { + if (baseUrl) { + try { + const url = new URL(baseUrl); + const allowedHosts = ['weixin.qq.com', 'wechat.com']; + const isAllowed = allowedHosts.some(h => url.hostname === h || url.hostname.endsWith('.' + h)); + if (url.protocol !== 'https:' || !isAllowed) { + logger.warn('Untrusted baseUrl, using default', { baseUrl }); + baseUrl = 'https://ilinkai.weixin.qq.com'; + } + } catch { + logger.warn('Invalid baseUrl, using default', { baseUrl }); + baseUrl = 'https://ilinkai.weixin.qq.com'; + } + } + this.token = token; + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.uin = generateUin(); + } + + private headers(): Record { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.token}`, + 'AuthorizationType': 'ilink_bot_token', + 'X-WECHAT-UIN': this.uin, + }; + } + + private async request>( + path: string, + body: unknown, + timeoutMs: number = 15_000, + ): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + const url = `${this.baseUrl}/${path}`; + + logger.debug('API request', { url, body }); + + try { + const res = await fetch(url, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text}`); + } + + const json = (await res.json()) as T; + logger.debug('API response', json); + return json; + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`); + } + throw err; + } finally { + clearTimeout(timer); + } + } + + /** Long-poll for new messages. Timeout 35s for long-polling. */ + async getUpdates(buf?: string): Promise { + return this.request( + 'ilink/bot/getupdates', + buf ? { get_updates_buf: buf } : {}, + 35_000, + ); + } + + /** Send a message to a user. Per-user rate limited, retries on rate-limit (ret: -2). */ + async sendMessage(req: SendMessageReq): Promise { + // Circuit breaker: fail fast without calling the API while open. + // This is what breaks the 14-minute head-banging loop. + if (this._isCircuitOpen()) { + const remainingSec = Math.ceil((this._circuitUntil - Date.now()) / 1000); + logger.warn('sendMessage rejected by circuit breaker', { remainingSec }); + throw new Error(`circuit breaker open, ${remainingSec}s remaining`); + } + + const userId = req.msg?.to_user_id; + if (userId) { + const now = Date.now(); + const nextAvailable = (this.nextSendTime.get(userId) ?? 0) + WeChatApi.MIN_SEND_INTERVAL; + const sendAt = Math.max(now, nextAvailable); + this.nextSendTime.set(userId, sendAt); + const waitMs = sendAt - now; + if (waitMs > 0) { + logger.debug('Rate limiter waiting', { userId, waitMs }); + await new Promise(r => setTimeout(r, waitMs)); + } + } + + const MAX_RETRIES = 2; + let delay = 3_000; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + // Re-check the circuit on each retry — a prior attempt may have just tripped it. + if (this._isCircuitOpen()) { + const remainingSec = Math.ceil((this._circuitUntil - Date.now()) / 1000); + logger.warn('sendMessage aborted mid-retry by circuit breaker', { attempt, remainingSec }); + throw new Error(`circuit breaker open during retry, ${remainingSec}s remaining`); + } + + const res = await this.request<{ ret?: number; errmsg?: string }>('ilink/bot/sendmessage', req); + if (res.ret === -2) { + // Distinguish stale-session (ret:-2 + errmsg "unknown error") from a real rate-limit. + // Hermes WeChat adapter established this pattern: the stale-session case behaves + // like errcode:-14 and is fixed by re-login, not by retry. + const errmsg = (res.errmsg ?? '').toLowerCase(); + if (errmsg === 'unknown error') { + logger.warn('sendMessage stale session detected (ret:-2 + unknown error)', { userId }); + if (userId) { + this.nextSendTime.set(userId, Date.now() + WeChatApi.STALE_SESSION_PAUSE_MS); + } + throw new Error('stale session — user must send a message to refresh context_token'); + } + + // Real rate-limit: trip the circuit breaker so subsequent sends fail fast. + this._tripCircuit(); + if (userId) { + this.nextSendTime.set(userId, Date.now() + WeChatApi.RATE_LIMIT_COOLDOWN_MS); + } + if (attempt === MAX_RETRIES) { + logger.warn('sendMessage rate-limited after max retries', { attempts: MAX_RETRIES }); + throw new Error(`sendMessage rate-limited after ${MAX_RETRIES} retries`); + } + logger.warn('sendMessage rate-limited (ret:-2), retrying', { attempt, delayMs: delay }); + await new Promise(r => setTimeout(r, delay)); + delay = Math.min(delay * 2, 15_000); + continue; + } + return; + } + } + + // ── Circuit breaker helpers ──────────────────────────────────────────── + + /** True while the breaker is open (sends should fail fast). */ + private _isCircuitOpen(): boolean { + if (this._circuitUntil === 0) return false; + if (Date.now() >= this._circuitUntil) { + this._circuitUntil = 0; + this._rateLimitEvents.length = 0; + return false; + } + return true; + } + + /** Record a rate-limit event and open the breaker if threshold is met. */ + private _tripCircuit(): void { + const now = Date.now(); + const windowStart = now - WeChatApi.CIRCUIT_WINDOW_MS; + while (this._rateLimitEvents.length > 0 && this._rateLimitEvents[0] < windowStart) { + this._rateLimitEvents.shift(); + } + this._rateLimitEvents.push(now); + if (this._rateLimitEvents.length >= WeChatApi.CIRCUIT_THRESHOLD) { + const openUntil = Math.max(this._circuitUntil, now + WeChatApi.CIRCUIT_OPEN_MS); + if (openUntil > this._circuitUntil) { + logger.warn('Circuit breaker tripped', { + events: this._rateLimitEvents.length, + openMs: WeChatApi.CIRCUIT_OPEN_MS, + }); + } + this._circuitUntil = openUntil; + } + } + + /** Fetch bot config (includes typing_ticket). */ + async getConfig(ilinkUserId: string, contextToken?: string): Promise { + return this.request( + 'ilink/bot/getconfig', + { ilink_user_id: ilinkUserId, context_token: contextToken }, + 10_000, + ); + } + + /** Send a typing indicator to a user. */ + async sendTyping(req: SendTypingReq): Promise { + await this.request('ilink/bot/sendtyping', req, 10_000); + } + + /** Get a presigned upload URL for media files. */ + async getUploadUrl(req: import('./types.js').GetUploadUrlReq): Promise { + return this.request('ilink/bot/getuploadurl', req); + } +} diff --git a/src/wechat/cdn.ts b/src/wechat/cdn.ts new file mode 100644 index 0000000..da9a22b --- /dev/null +++ b/src/wechat/cdn.ts @@ -0,0 +1,53 @@ +import { decryptAesEcb } from "./crypto.js"; +import { logger } from "../logger.js"; +import { CDN_BASE_URL } from "../constants.js"; + +export function buildCdnDownloadUrl(encryptQueryParam: string): string { + if (!/^[A-Za-z0-9%=&+._~\-/]+$/.test(encryptQueryParam)) { + throw new Error('Invalid CDN query parameter'); + } + return `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(encryptQueryParam)}`; +} + +export async function downloadAndDecrypt( + encryptQueryParam: string, + aesKeyBase64: string, +): Promise { + const url = buildCdnDownloadUrl(encryptQueryParam); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30_000); + let response: Response; + try { + response = await fetch(url, { signal: controller.signal }); + } catch (err) { + clearTimeout(timer); + throw new Error(`CDN download failed: ${err instanceof Error ? err.message : String(err)}`); + } + clearTimeout(timer); + + if (!response.ok) { + throw new Error(`CDN download failed: ${response.status} ${response.statusText}`); + } + + const encrypted = Buffer.from(await response.arrayBuffer()); + + // Handle both formats: + // 1. base64-of-raw-16-bytes (16 raw bytes encoded as base64) + // 2. base64-of-hex-string (32 hex chars encoded as base64) + let aesKey: Buffer; + const raw = Buffer.from(aesKeyBase64, "base64"); + + if (raw.length === 16) { + // base64-of-raw-16-bytes + aesKey = raw; + } else { + // base64-of-hex-string: decode the string as hex to get the 16-byte key + const hexStr = raw.toString("utf-8"); + aesKey = Buffer.from(hexStr, "hex"); + } + + const decrypted = decryptAesEcb(aesKey, encrypted); + logger.info("CDN download and decrypt succeeded", { size: decrypted.length }); + + return decrypted; +} diff --git a/src/wechat/crypto.ts b/src/wechat/crypto.ts new file mode 100644 index 0000000..1959d67 --- /dev/null +++ b/src/wechat/crypto.ts @@ -0,0 +1,20 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; + +export function generateAesKey(): string { + return randomBytes(16).toString("base64"); +} + +export function aesEcbPaddedSize(size: number): number { + const block = 16; + return Math.floor((size + block - 1) / block) * block; +} + +export function encryptAesEcb(key: Buffer, plaintext: Buffer): Buffer { + const cipher = createCipheriv("aes-128-ecb", key, null); + return Buffer.concat([cipher.update(plaintext), cipher.final()]); +} + +export function decryptAesEcb(key: Buffer, ciphertext: Buffer): Buffer { + const decipher = createDecipheriv("aes-128-ecb", key, null); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} diff --git a/src/wechat/login.ts b/src/wechat/login.ts new file mode 100644 index 0000000..c11c53f --- /dev/null +++ b/src/wechat/login.ts @@ -0,0 +1,138 @@ +import type { AccountData } from './accounts.js'; +import { DEFAULT_BASE_URL, saveAccount } from './accounts.js'; +import { logger } from '../logger.js'; + +const QR_CODE_URL = `${DEFAULT_BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`; +const QR_STATUS_URL = `${DEFAULT_BASE_URL}/ilink/bot/get_qrcode_status`; +const POLL_INTERVAL_MS = 3_000; + +interface QrCodeResponse { + ret: number; + qrcode?: string; + qrcode_img_content?: string; +} + +interface QrStatusResponse { + ret: number; + status: string; + retmsg?: string; + bot_token?: string; + ilink_bot_id?: string; + baseurl?: string; + ilink_user_id?: string; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Phase 1: Request a QR code for login. Returns the URL and ID. */ +export async function startQrLogin(): Promise<{ qrcodeUrl: string; qrcodeId: string }> { + logger.info('Requesting QR code'); + + const res = await fetch(QR_CODE_URL); + if (!res.ok) { + throw new Error(`Failed to get QR code: HTTP ${res.status}`); + } + + const data = (await res.json()) as QrCodeResponse; + + if (data.ret !== 0 || !data.qrcode_img_content || !data.qrcode) { + throw new Error(`Failed to get QR code (ret=${data.ret})`); + } + + logger.info('QR code obtained', { qrcodeId: data.qrcode }); + + return { + qrcodeUrl: data.qrcode_img_content, + qrcodeId: data.qrcode, + }; +} + +/** + * Phase 2: Wait for the user to scan and confirm the QR code. + * Throws on expiry so the caller can regenerate the QR image. + * Returns the full AccountData on success. + */ +export async function waitForQrScan(qrcodeId: string): Promise { + let currentQrcodeId = qrcodeId; + + while (true) { + const url = `${QR_STATUS_URL}?qrcode=${encodeURIComponent(currentQrcodeId)}`; + + logger.debug('Polling QR status', { qrcodeId: currentQrcodeId }); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 60_000); + let res: Response; + try { + res = await fetch(url, { signal: controller.signal }); + } catch (e: any) { + clearTimeout(timer); + if (e.name === 'AbortError' || e.code === 'ETIMEDOUT') { + logger.info('QR poll timed out, retrying'); + continue; + } + throw e; + } + clearTimeout(timer); + + if (!res.ok) { + throw new Error(`Failed to check QR status: HTTP ${res.status}`); + } + + const data = (await res.json()) as QrStatusResponse; + logger.debug('QR status response', { status: data.status }); + + switch (data.status) { + case 'wait': + case 'scaned': + // Not yet confirmed, continue polling + break; + + case 'confirmed': { + if (!data.bot_token || !data.ilink_bot_id || !data.ilink_user_id) { + throw new Error('QR confirmed but missing required fields in response'); + } + + const accountData: AccountData = { + botToken: data.bot_token, + accountId: data.ilink_bot_id, + baseUrl: data.baseurl || DEFAULT_BASE_URL, + userId: data.ilink_user_id, + createdAt: new Date().toISOString(), + }; + + saveAccount(accountData); + logger.info('QR login successful', { accountId: accountData.accountId }); + + return accountData; + } + + case 'expired': { + logger.info('QR code expired'); + throw new Error('QR code expired'); + } + + default: + logger.warn('Unknown QR status', { status: data.status, retmsg: data.retmsg }); + // Surface error to user for known failure statuses + const status = data.status ?? ''; + if (status && ( + status.includes('not_support') || + status.includes('version') || + status.includes('forbid') || + status.includes('reject') || + status.includes('cancel') + )) { + throw new Error(`二维码扫描失败: ${data.retmsg || status}`); + } + if (data.retmsg) { + throw new Error(`二维码扫描失败: ${data.retmsg}`); + } + break; + } + + await sleep(POLL_INTERVAL_MS); + } +} diff --git a/src/wechat/media.ts b/src/wechat/media.ts new file mode 100644 index 0000000..f8e66e7 --- /dev/null +++ b/src/wechat/media.ts @@ -0,0 +1,137 @@ +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs'; +import type { MessageItem, ImageItem } from './types.js'; +import { MessageItemType } from './types.js'; +import { downloadAndDecrypt } from './cdn.js'; +import { logger } from '../logger.js'; + +function detectMimeType(data: Buffer): string { + if (data[0] === 0x89 && data[1] === 0x50) return 'image/png'; + if (data[0] === 0xFF && data[1] === 0xD8) return 'image/jpeg'; + if (data[0] === 0x47 && data[1] === 0x49) return 'image/gif'; + if (data[0] === 0x52 && data[1] === 0x49) return 'image/webp'; + if (data[0] === 0x42 && data[1] === 0x4D) return 'image/bmp'; + return 'image/jpeg'; // fallback +} + +/** + * Extract AES key and encrypt_query_param from an ImageItem, + * supporting both the old cdn_media format and the newer flat format. + */ +function getImageCdnData(imageItem: ImageItem): { aesKey: string; encryptQueryParam: string } | null { + // Old format: cdn_media.aes_key + cdn_media.encrypt_query_param + if (imageItem.cdn_media?.aes_key && imageItem.cdn_media?.encrypt_query_param) { + return { + aesKey: imageItem.cdn_media.aes_key, + encryptQueryParam: imageItem.cdn_media.encrypt_query_param, + }; + } + + // New format: aeskey + media.encrypt_query_param + // Use media.aes_key (base64-of-hex) over aeskey (raw hex) since downloadAndDecrypt expects base64 + if (imageItem.media?.encrypt_query_param && (imageItem.media.aes_key || imageItem.aeskey)) { + return { + aesKey: imageItem.media.aes_key ?? imageItem.aeskey!, + encryptQueryParam: imageItem.media.encrypt_query_param, + }; + } + + logger.warn('Image item has no usable CDN data', { + hasCdnMedia: !!imageItem.cdn_media, + hasAeskey: !!imageItem.aeskey, + hasMedia: !!imageItem.media, + }); + return null; +} + +/** + * Download a CDN image, decrypt it, and return a base64 data URI. + * Returns null on failure. + */ +export async function downloadImage(item: MessageItem): Promise { + const imageItem = item.image_item; + if (!imageItem) { + return null; + } + + const cdnData = getImageCdnData(imageItem); + if (!cdnData) { + return null; + } + + try { + const decrypted = await downloadAndDecrypt(cdnData.encryptQueryParam, cdnData.aesKey); + const mimeType = detectMimeType(decrypted); + const base64 = decrypted.toString('base64'); + const dataUri = `data:${mimeType};base64,${base64}`; + logger.info('Image downloaded and decrypted', { size: decrypted.length }); + return dataUri; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn('Failed to download image', { error: msg }); + return null; + } +} + +export function extractText(item: MessageItem): string { + if (item.text_item?.text) return item.text_item.text; + if (item.voice_item?.text) return item.voice_item.text; + if (item.file_item?.file_name) return `[用户发送了文件: ${item.file_item.file_name}]`; + if (item.type === MessageItemType.VIDEO) return '[用户发送了视频]'; + return ''; +} + +/** + * Find the first IMAGE type item in a list. + */ +export function extractFirstImageUrl(items?: MessageItem[]): MessageItem | undefined { + return items?.find((item) => item.type === MessageItemType.IMAGE); +} + +/** + * Find the first FILE type item in a list. + */ +export function extractFirstFileItem(items?: MessageItem[]): MessageItem | undefined { + return items?.find((item) => item.type === MessageItemType.FILE); +} + +/** + * Download a CDN file, decrypt it, and save to a temp directory. + * Returns the local file path, or null on failure. + */ +export async function downloadFile(item: MessageItem): Promise { + const fileItem = item.file_item; + if (!fileItem) return null; + + let aesKey: string | undefined; + let encryptQueryParam: string | undefined; + + if (fileItem.media?.encrypt_query_param) { + encryptQueryParam = fileItem.media.encrypt_query_param; + aesKey = fileItem.media.aes_key; + } else if (fileItem.cdn_media?.encrypt_query_param) { + encryptQueryParam = fileItem.cdn_media.encrypt_query_param; + aesKey = fileItem.cdn_media.aes_key; + } + + if (!encryptQueryParam || !aesKey) { + logger.warn('File item has no usable CDN data'); + return null; + } + + try { + const decrypted = await downloadAndDecrypt(encryptQueryParam, aesKey); + const tmpDir = path.join(os.tmpdir(), 'wechat-claude-code'); + fs.mkdirSync(tmpDir, { recursive: true }); + const fileName = fileItem.file_name || `file-${Date.now()}.bin`; + const filePath = path.join(tmpDir, fileName); + fs.writeFileSync(filePath, decrypted); + logger.info('File downloaded and saved', { path: filePath, size: decrypted.length, name: fileName }); + return filePath; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn('Failed to download file', { error: msg }); + return null; + } +} diff --git a/src/wechat/monitor.ts b/src/wechat/monitor.ts new file mode 100644 index 0000000..4d4fd17 --- /dev/null +++ b/src/wechat/monitor.ts @@ -0,0 +1,124 @@ +import { WeChatApi } from './api.js'; +import { loadSyncBuf, saveSyncBuf } from './sync-buf.js'; +import { logger } from '../logger.js'; +import type { WeixinMessage } from './types.js'; + +const SESSION_EXPIRED_ERRCODE = -14; +const SESSION_EXPIRED_PAUSE_MS = 60 * 60 * 1000; // 1 hour +const BACKOFF_THRESHOLD = 3; +const BACKOFF_LONG_MS = 30_000; +const BACKOFF_SHORT_MS = 3_000; + +export interface MonitorCallbacks { + onMessage: (msg: WeixinMessage) => Promise; + onSessionExpired: () => void; +} + +export function createMonitor(api: WeChatApi, callbacks: MonitorCallbacks) { + const controller = new AbortController(); + let stopped = false; + const recentMsgIds = new Set(); + const MAX_MSG_IDS = 1000; + + async function run(): Promise { + let consecutiveFailures = 0; + + while (!controller.signal.aborted) { + try { + const buf = loadSyncBuf(); + logger.debug('Polling for messages', { hasBuf: buf.length > 0 }); + + const resp = await api.getUpdates(buf || undefined); + + if (resp.ret === SESSION_EXPIRED_ERRCODE) { + logger.warn('Session expired, pausing for 1 hour'); + callbacks.onSessionExpired(); + await sleep(SESSION_EXPIRED_PAUSE_MS, controller.signal); + consecutiveFailures = 0; + continue; + } + + if (resp.ret !== undefined && resp.ret !== 0) { + logger.warn('getUpdates returned error', { ret: resp.ret, retmsg: resp.retmsg }); + } + + // Save the new sync buffer regardless of ret + if (resp.get_updates_buf) { + saveSyncBuf(resp.get_updates_buf); + } + + // Process messages (with deduplication) + const messages = resp.msgs ?? []; + if (messages.length > 0) { + logger.info('Received messages', { count: messages.length }); + for (const msg of messages) { + // Skip already-processed messages + if (msg.message_id && recentMsgIds.has(msg.message_id)) { + continue; + } + if (msg.message_id) { + recentMsgIds.add(msg.message_id); + if (recentMsgIds.size > MAX_MSG_IDS) { + // Evict oldest half (Set iterates in insertion order) + const iter = recentMsgIds.values(); + const toDelete: number[] = []; + for (let i = 0; i < MAX_MSG_IDS / 2; i++) { + const { value } = iter.next(); + if (value !== undefined) toDelete.push(value); + } + for (const id of toDelete) recentMsgIds.delete(id); + } + } + // Fire-and-forget: don't block the polling loop on message processing + // This allows permission responses (y/n) to be received while a query is running + callbacks.onMessage(msg).catch((err) => { + const msg2 = err instanceof Error ? err.message : String(err); + logger.error('Error processing message', { error: msg2, messageId: msg.message_id }); + }); + } + } + + consecutiveFailures = 0; + } catch (err) { + if (controller.signal.aborted) { + break; + } + + consecutiveFailures++; + const errorMsg = err instanceof Error ? err.message : String(err); + logger.error('Monitor error', { error: errorMsg, consecutiveFailures }); + + const backoff = consecutiveFailures >= BACKOFF_THRESHOLD ? BACKOFF_LONG_MS : BACKOFF_SHORT_MS; + logger.info(`Backing off ${backoff}ms`, { consecutiveFailures }); + await sleep(backoff, controller.signal); + } + } + + stopped = true; + logger.info('Monitor stopped'); + } + + function stop(): void { + if (!controller.signal.aborted) { + logger.info('Stopping monitor...'); + controller.abort(); + } + } + + return { run, stop }; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + }); +} diff --git a/src/wechat/send.ts b/src/wechat/send.ts new file mode 100644 index 0000000..215dc8e --- /dev/null +++ b/src/wechat/send.ts @@ -0,0 +1,184 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { WeChatApi } from './api.js'; +import { MessageItemType, MessageType, MessageState, TypingStatus, type MessageItem, type OutboundMessage } from './types.js'; +import { uploadFile } from './upload.js'; +import { logger } from '../logger.js'; + +const TYPING_KEEPALIVE_MS = 5_000; + +export function createSender(api: WeChatApi, botAccountId: string) { + let clientCounter = 0; + const typingTicketCache = new Map(); + const TICKET_TTL = 24 * 60 * 60 * 1000; + + function generateClientId(): string { + return `wcc-${Date.now()}-${++clientCounter}`; + } + + async function getTypingTicket(userId: string, contextToken?: string): Promise { + const cached = typingTicketCache.get(userId); + if (cached && Date.now() - cached.fetchedAt < TICKET_TTL) { + return cached.ticket; + } + try { + const resp = await api.getConfig(userId, contextToken); + if (resp.ret === 0 && resp.typing_ticket) { + typingTicketCache.set(userId, { ticket: resp.typing_ticket, fetchedAt: Date.now() }); + return resp.typing_ticket; + } + logger.warn('getConfig returned no typing_ticket', { ret: resp.ret }); + } catch (err) { + logger.warn('getConfig failed', { err: err instanceof Error ? err.message : String(err) }); + } + return ''; + } + + /** + * Start typing indicator with keepalive. Returns a stop function. + * Fire-and-forget: errors are logged but not thrown. + */ + function startTyping(toUserId: string, contextToken: string): () => void { + let cancelled = false; + + (async () => { + const ticket = await getTypingTicket(toUserId, contextToken); + if (!ticket || cancelled) return; + + try { + await api.sendTyping({ + ilink_user_id: toUserId, + typing_ticket: ticket, + status: TypingStatus.TYPING, + }); + } catch (err) { + logger.debug('sendTyping start failed', { err: err instanceof Error ? err.message : String(err) }); + return; + } + + // Keepalive loop + while (!cancelled) { + await new Promise(r => setTimeout(r, TYPING_KEEPALIVE_MS)); + if (cancelled) break; + try { + await api.sendTyping({ + ilink_user_id: toUserId, + typing_ticket: ticket, + status: TypingStatus.TYPING, + }); + } catch { + break; + } + } + + // Send CANCEL to tell WeChat we're done typing + if (!ticket) return; + try { + await api.sendTyping({ + ilink_user_id: toUserId, + typing_ticket: ticket, + status: TypingStatus.CANCEL, + }); + } catch { + // ignore + } + })(); + + return () => { + cancelled = true; + }; + } + + async function sendText(toUserId: string, contextToken: string, text: string): Promise { + const clientId = generateClientId(); + + const items: MessageItem[] = [ + { + type: MessageItemType.TEXT, + text_item: { text }, + }, + ]; + + const msg: OutboundMessage = { + from_user_id: botAccountId, + to_user_id: toUserId, + client_id: clientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + context_token: contextToken, + item_list: items, + }; + + logger.info('Sending text message', { toUserId, clientId, textLength: text.length }); + await api.sendMessage({ msg }); + logger.info('Text message sent', { toUserId, clientId }); + } + + async function sendFile(toUserId: string, contextToken: string, filePath: string): Promise { + const resolved = resolve(filePath.replace(/^~/, process.env.HOME || '')); + if (!existsSync(resolved)) { + await sendText(toUserId, contextToken, `文件不存在: ${resolved}`); + return; + } + + try { + const media = await uploadFile(api, toUserId, resolved); + const clientId = generateClientId(); + + // Convert aesKeyHex to base64: treat hex string as UTF-8, then base64 encode + // (matches OpenClaw's format: Buffer.from(hexString).toString("base64")) + const aesKeyBase64 = Buffer.from(media.aesKeyHex).toString('base64'); + + let item: MessageItem; + if (media.mediaType === 'image') { + item = { + type: MessageItemType.IMAGE, + image_item: { + media: { + encrypt_query_param: media.encryptQueryParam, + aes_key: aesKeyBase64, + encrypt_type: 1, + }, + mid_size: media.fileSize, + }, + }; + } else { + item = { + type: MessageItemType.FILE, + file_item: { + media: { + encrypt_query_param: media.encryptQueryParam, + aes_key: aesKeyBase64, + encrypt_type: 1, + }, + file_name: media.fileName, + len: String(media.rawSize), + }, + }; + } + + const msg: OutboundMessage = { + from_user_id: botAccountId, + to_user_id: toUserId, + client_id: clientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + context_token: contextToken, + item_list: [item], + }; + + logger.info('Sending file message', { toUserId, clientId, fileName: media.fileName, mediaType: media.mediaType }); + await api.sendMessage({ msg }); + logger.info('File message sent', { toUserId, clientId, fileName: media.fileName }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error('Failed to send file', { filePath: resolved, error: msg }); + if (!msg.includes('rate-limited')) { + await sendText(toUserId, contextToken, `发送文件失败: ${msg}`); + } + throw err; + } + } + + return { sendText, startTyping, sendFile }; +} diff --git a/src/wechat/sync-buf.ts b/src/wechat/sync-buf.ts new file mode 100644 index 0000000..161f121 --- /dev/null +++ b/src/wechat/sync-buf.ts @@ -0,0 +1,13 @@ +import { loadJson, saveJson } from '../store.js'; +import { DATA_DIR } from '../constants.js'; +import { join } from 'node:path'; + +const SYNC_BUF_PATH = join(DATA_DIR, 'get_updates_buf'); + +export function loadSyncBuf(): string { + return loadJson(SYNC_BUF_PATH, ''); +} + +export function saveSyncBuf(buf: string): void { + saveJson(SYNC_BUF_PATH, buf); +} diff --git a/src/wechat/types.ts b/src/wechat/types.ts new file mode 100644 index 0000000..5bd3c16 --- /dev/null +++ b/src/wechat/types.ts @@ -0,0 +1,166 @@ +// WeChat Work (企业微信) protocol type definitions +// Extracted from the ClawBot WeChat plugin API + +// ── Enums ────────────────────────────────────────────────────────────────── + +export enum MessageType { + USER = 1, + BOT = 2, +} + +export enum MessageItemType { + TEXT = 1, + IMAGE = 2, + VOICE = 3, + FILE = 4, + VIDEO = 5, +} + +export enum MessageState { + NEW = 0, + GENERATING = 1, + FINISH = 2, +} + +// ── Media ────────────────────────────────────────────────────────────────── + +export interface CDNMedia { + aes_key: string; + encrypt_query_param: string; + cdn_url?: string; +} + +// ── Message Items ─────────────────────────────────────────────────────────── + +export interface TextItem { + text: string; +} + +export interface ImageItem { + cdn_media?: CDNMedia; + /** Alternative field name used by some API versions */ + aeskey?: string; + media?: { encrypt_query_param: string; aes_key?: string; encrypt_type?: number }; + url?: string; + mid_size?: number; + hd_size?: number; +} + +export interface VoiceItem { + media?: CDNMedia; + /** 语音转文字内容 */ + text?: string; +} + +export interface FileItem { + cdn_media?: CDNMedia; + media?: { encrypt_query_param: string; aes_key?: string; encrypt_type?: number }; + file_name?: string; + len?: string; +} + +export interface VideoItem { + cdn_media: CDNMedia; +} + +export interface MessageItem { + type: MessageItemType; + text_item?: TextItem; + image_item?: ImageItem; + voice_item?: VoiceItem; + file_item?: FileItem; + video_item?: VideoItem; +} + +// ── Weixin Message ────────────────────────────────────────────────────────── + +export interface WeixinMessage { + seq?: number; + message_id?: number; + from_user_id?: string; + to_user_id?: string; + create_time_ms?: number; + message_type?: MessageType; + message_state?: MessageState; + item_list?: MessageItem[]; + context_token?: string; +} + +// ── GetUpdates API ────────────────────────────────────────────────────────── + +export interface GetUpdatesReq { + get_updates_buf?: string; +} + +export interface GetUpdatesResp { + ret?: number; + retmsg?: string; + sync_buf: string; + get_updates_buf: string; + msgs?: WeixinMessage[]; +} + +// ── SendMessage API ───────────────────────────────────────────────────────── + +export interface OutboundMessage { + from_user_id: string; + to_user_id: string; + client_id: string; + message_type: MessageType; + message_state: MessageState; + context_token: string; + item_list: MessageItem[]; +} + +export interface SendMessageReq { + msg: OutboundMessage; +} + +// ── Typing API ────────────────────────────────────────────────────────────── + +export const TypingStatus = { + TYPING: 1, + CANCEL: 2, +} as const; + +export interface SendTypingReq { + ilink_user_id: string; + typing_ticket: string; + status: number; +} + +export interface GetConfigResp { + ret?: number; + errmsg?: string; + typing_ticket?: string; +} + +// ── GetUploadUrl API ──────────────────────────────────────────────────────── + +export const UploadMediaType = { + IMAGE: 1, + VIDEO: 2, + FILE: 3, + VOICE: 4, +} as const; + +export interface GetUploadUrlReq { + filekey: string; + media_type: number; + to_user_id: string; + rawsize: number; + rawfilemd5: string; + filesize: number; + no_need_thumb: boolean; + aeskey: string; + base_info: { + channel_version: string; + bot_agent: string; + }; +} + +export interface GetUploadUrlResp { + ret?: number; + upload_param?: string; + upload_full_url?: string; +} diff --git a/src/wechat/upload.ts b/src/wechat/upload.ts new file mode 100644 index 0000000..8ff88d3 --- /dev/null +++ b/src/wechat/upload.ts @@ -0,0 +1,144 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { readFileSync, statSync } from 'node:fs'; +import { basename, extname } from 'node:path'; +import { encryptAesEcb, aesEcbPaddedSize } from './crypto.js'; +import { WeChatApi } from './api.js'; +import { UploadMediaType } from './types.js'; +import { CDN_BASE_URL } from '../constants.js'; +import { logger } from '../logger.js'; + +const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB + +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico']); + +export interface UploadedMedia { + mediaType: 'image' | 'file'; + encryptQueryParam: string; + aesKeyHex: string; + fileName: string; + fileSize: number; + rawSize: number; +} + +export function isImageFile(filePath: string): boolean { + return IMAGE_EXTENSIONS.has(extname(filePath).toLowerCase()); +} + +export async function uploadFile( + api: WeChatApi, + toUserId: string, + filePath: string, +): Promise { + const stat = statSync(filePath); + if (stat.size > MAX_FILE_SIZE) { + throw new Error(`文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),最大支持 25MB`); + } + + const fileName = basename(filePath); + const isImage = isImageFile(filePath); + const mediaType = isImage ? UploadMediaType.IMAGE : UploadMediaType.FILE; + + // Prepare file + const plaintext = readFileSync(filePath); + const rawSize = plaintext.length; + const rawFileMd5 = createHash('md5').update(plaintext).digest('hex'); + const fileSize = aesEcbPaddedSize(rawSize); + const fileKey = randomBytes(16).toString('hex'); // 32-hex-char string + const aesKey = randomBytes(16); // 16 raw bytes + const aesKeyHex = aesKey.toString('hex'); + + // Get upload URL + logger.info('Requesting upload URL', { fileName, rawSize, mediaType, toUserId }); + + const uploadResp = await api.getUploadUrl({ + filekey: fileKey, + media_type: mediaType, + to_user_id: toUserId, + rawsize: rawSize, + rawfilemd5: rawFileMd5, + filesize: fileSize, + no_need_thumb: true, + aeskey: aesKeyHex, + base_info: { + channel_version: '2.0.0', + bot_agent: 'wechat-claude-code', + }, + }); + + logger.info('Upload URL response', { uploadResp }); + + if (!uploadResp.upload_full_url && !uploadResp.upload_param) { + throw new Error(`获取上传地址失败: ${JSON.stringify(uploadResp)}`); + } + + // Encrypt + const encrypted = encryptAesEcb(aesKey, plaintext); + + // Build CDN upload URL + let uploadUrl: string; + if (uploadResp.upload_full_url) { + uploadUrl = uploadResp.upload_full_url; + } else { + uploadUrl = `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(uploadResp.upload_param!)}&filekey=${fileKey}`; + } + + logger.info('Uploading to CDN', { uploadUrl, encryptedSize: encrypted.length }); + + // Upload to CDN (POST, get download param from response header) + const encryptQueryParam = await uploadToCdn(uploadUrl, encrypted); + + logger.info('CDN upload succeeded', { fileName }); + + return { + mediaType: isImage ? 'image' : 'file', + encryptQueryParam, + aesKeyHex, + fileName, + fileSize, + rawSize, + }; +} + +async function uploadToCdn(url: string, encrypted: Buffer): Promise { + const MAX_RETRIES = 3; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 60_000); + + try { + const res = await fetch(url, { + method: 'POST', + body: new Uint8Array(encrypted), + signal: controller.signal, + headers: { 'Content-Type': 'application/octet-stream' }, + }); + + if (res.status >= 400 && res.status < 500) { + const text = await res.text(); + throw new Error(`CDN 上传失败 (4xx): ${res.status} ${text.slice(0, 200)}`); + } + + if (res.status >= 500) { + logger.warn('CDN upload 5xx, retrying', { status: res.status, attempt }); + continue; + } + + // Get download param from response header + const param = res.headers.get('x-encrypted-param'); + if (!param) { + throw new Error('CDN 上传成功但未返回 x-encrypted-param'); + } + return param; + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error('CDN 上传超时'); + } + throw err; + } finally { + clearTimeout(timer); + } + } + + throw new Error('CDN 上传失败: 多次重试后仍失败'); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9c8fc6c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}