@xdfnet/ispeak 1.6.5 → 1.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -177,7 +177,7 @@ pending -> running -> delete
177
177
  ├── config.json # API Key、音色配置
178
178
  ├── ispeak.sock # Unix Socket
179
179
  ├── ispeak.log # 日志(lumberjack 轮转)
180
- └── hook-speak.sh # Claude/Codex Stop Hook
180
+ └── hook-speak.sh # Claude/Codex Hook
181
181
 
182
182
  ~/Library/LaunchAgents/
183
183
  └── com.iSpeak.plist # launchd 服务配置
@@ -0,0 +1,241 @@
1
+ # Hook 文本提取链路
2
+
3
+ 本文记录 Claude Code / Codex CLI 在 Hook 中拿到“最后一条 assistant 回复”的实际方式。`hook-speak.sh` 的目标只做两件事:取最后一条 assistant 回复,发给 iSpeak socket。
4
+
5
+ ## 结论
6
+
7
+ 推荐优先级:
8
+
9
+ 1. **Codex `notify`**:从脚本第二个参数 `$2` 读取 JSON,取 `last-assistant-message`。
10
+ 2. **Claude / Codex Stop Hook**:从 stdin 读取 JSON,优先取 `last_assistant_message`。
11
+ 3. **明确 transcript**:如果没有直接字段,只读取 payload 里明确传入的 `transcript_path`。
12
+
13
+ 不扫描 `~/.codex/sessions`。没有 direct 字段也没有 `transcript_path` 时,本次不播报。
14
+
15
+ ## Codex CLI:notify
16
+
17
+ 当前本机版本:
18
+
19
+ ```text
20
+ codex-cli 0.130.0
21
+ ```
22
+
23
+ Codex CLI 的 `notify = [...]` 是 legacy notify 机制。官方源码里会把通知 JSON 追加成命令的最后一个 argv 参数,不写 stdin。
24
+
25
+ 配置示例:
26
+
27
+ ```toml
28
+ notify = ["/Users/你的用户名/.config/iSpeak/hook-speak.sh", "codex"]
29
+ ```
30
+
31
+ 脚本实际收到:
32
+
33
+ ```bash
34
+ $1 = "codex"
35
+ $2 = '{"type":"agent-turn-complete",...,"last-assistant-message":"..."}'
36
+ stdin = empty
37
+ ```
38
+
39
+ 核心字段:
40
+
41
+ ```json
42
+ {
43
+ "type": "agent-turn-complete",
44
+ "thread-id": "...",
45
+ "turn-id": "...",
46
+ "cwd": "...",
47
+ "input-messages": ["..."],
48
+ "last-assistant-message": "最后一条 assistant 回复"
49
+ }
50
+ ```
51
+
52
+ 所以 Codex `notify` 的正确读取方式是:
53
+
54
+ ```bash
55
+ input="${2:-}"
56
+ ```
57
+
58
+ 然后解析:
59
+
60
+ ```js
61
+ payload["last-assistant-message"]
62
+ ```
63
+
64
+ 源码依据:`codex-rs/hooks/src/legacy_notify.rs`。该文件把 `last_assistant_message` 序列化为 kebab-case 的 `last-assistant-message`,并在执行命令前 `command.arg(notify_payload)`。
65
+
66
+ ## Codex CLI:Stop Hook
67
+
68
+ Codex 也支持 Claude 风格 Hook。Stop Hook 的输入 JSON 写入 stdin。
69
+
70
+ 配置示例:
71
+
72
+ ```json
73
+ {
74
+ "hooks": {
75
+ "Stop": [
76
+ {
77
+ "hooks": [
78
+ {
79
+ "type": "command",
80
+ "command": "bash $HOME/.config/iSpeak/hook-speak.sh codex",
81
+ "timeout": 30
82
+ }
83
+ ]
84
+ }
85
+ ]
86
+ }
87
+ }
88
+ ```
89
+
90
+ 脚本实际收到:
91
+
92
+ ```bash
93
+ $1 = "codex"
94
+ $2 = empty
95
+ stdin = '{"hook_event_name":"Stop",...,"last_assistant_message":"..."}'
96
+ ```
97
+
98
+ 核心字段:
99
+
100
+ ```json
101
+ {
102
+ "session_id": "...",
103
+ "turn_id": "...",
104
+ "transcript_path": "...",
105
+ "cwd": "...",
106
+ "hook_event_name": "Stop",
107
+ "model": "...",
108
+ "permission_mode": "bypassPermissions",
109
+ "stop_hook_active": false,
110
+ "last_assistant_message": "最后一条 assistant 回复"
111
+ }
112
+ ```
113
+
114
+ 源码依据:
115
+
116
+ - `codex-rs/hooks/src/events/stop.rs`:构造 `StopCommandInput`,包含 `last_assistant_message` 和 `transcript_path`。
117
+ - `codex-rs/hooks/schema/generated/stop.command.input.schema.json`:Stop stdin schema。
118
+ - `codex-rs/hooks/src/engine/command_runner.rs`:Hook 命令通过 stdin 接收 `input_json`。
119
+
120
+ ## Codex Transcript
121
+
122
+ Codex 的 transcript/session 文件是 JSONL。实际 assistant 回复形态:
123
+
124
+ ```json
125
+ {
126
+ "type": "response_item",
127
+ "payload": {
128
+ "type": "message",
129
+ "role": "assistant",
130
+ "content": [
131
+ {
132
+ "type": "output_text",
133
+ "text": "最后一条 assistant 回复"
134
+ }
135
+ ]
136
+ }
137
+ }
138
+ ```
139
+
140
+ 提取规则:
141
+
142
+ ```js
143
+ event.type === "response_item" &&
144
+ event.payload?.type === "message" &&
145
+ event.payload?.role === "assistant"
146
+ ```
147
+
148
+ 然后拼接:
149
+
150
+ ```js
151
+ event.payload.content[].text
152
+ ```
153
+
154
+ ## Claude Code:Stop Hook
155
+
156
+ Claude Code 官方 Stop Hook 通过 stdin 传 JSON,核心字段是:
157
+
158
+ ```json
159
+ {
160
+ "session_id": "...",
161
+ "transcript_path": "...",
162
+ "hook_event_name": "Stop",
163
+ "stop_hook_active": false
164
+ }
165
+ ```
166
+
167
+ 有些版本或场景可能直接提供:
168
+
169
+ ```json
170
+ {
171
+ "last_assistant_message": "最后一条 assistant 回复"
172
+ }
173
+ ```
174
+
175
+ 所以 Claude 的读取顺序是:
176
+
177
+ 1. `last_assistant_message`
178
+ 2. `message`
179
+ 3. `transcript_path`
180
+
181
+ Claude transcript 常见 assistant 形态:
182
+
183
+ ```json
184
+ {"role":"assistant","content":[{"type":"text","text":"..."}]}
185
+ ```
186
+
187
+ 或:
188
+
189
+ ```json
190
+ {"message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}
191
+ ```
192
+
193
+ ## 当前脚本策略
194
+
195
+ `configs/hook-speak.sh` 当前入口:
196
+
197
+ ```bash
198
+ input="${2:-}"
199
+ if [[ -z "$input" ]]; then
200
+ input=$(cat)
201
+ fi
202
+ ```
203
+
204
+ 含义:
205
+
206
+ - Codex `notify`:读 `$2`
207
+ - Claude / Codex Stop Hook:读 stdin
208
+ - 如果 Codex 的 `notify` 和 `Stop` 同时启用,脚本会按 `turn_id` 去重,避免同一回合播两次
209
+
210
+ Codex 文本字段优先级:
211
+
212
+ ```js
213
+ payload["last-assistant-message"]
214
+ payload.last_assistant_message
215
+ payload.lastAssistantMessage
216
+ payload.message
217
+ payload.lastMessage
218
+ payload.transcript_path
219
+ payload.transcriptPath
220
+ payload["transcript-path"]
221
+ ```
222
+
223
+ Claude 文本字段优先级:
224
+
225
+ ```js
226
+ payload.last_assistant_message
227
+ payload.message
228
+ payload.transcript_path
229
+ payload.transcriptPath
230
+ ```
231
+
232
+ ## 为什么不能只读 stdin
233
+
234
+ 因为 Codex `notify` 不走 stdin。只读 stdin 会导致:
235
+
236
+ ```text
237
+ TEXT_LEN: 0
238
+ SPOKE: SKIP
239
+ ```
240
+
241
+ 正确做法是先读 `$2`,再读 stdin;不扫历史 session。
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # iSpeak
2
2
 
3
- ![Version](https://img.shields.io/badge/version-1.6.3-blue)
3
+ ![Version](https://img.shields.io/badge/version-1.6.7-blue)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
5
  [![Go Version](https://img.shields.io/badge/Go-1.26-blue)](https://golang.org/dl/)
6
6
  ![Platform](https://img.shields.io/badge/platform-macOS-green)
7
7
 
8
8
  iSpeak 让 AI 编程助手开口说话。你写代码,它播结果——眼睛休息,耳朵来听。
9
9
 
10
- 适合 Claude Code 或 Codex 常驻后台的开发者。AI 完成任务后自动播报;你发新消息时,旧播报立即中断,不花冤枉钱。
10
+ 适合 Claude Code 或 Codex 常驻后台的开发者。AI 完成任务后自动播报;你发新消息时,未开始的旧播报会被丢弃,不花冤枉钱。
11
11
 
12
12
  ## 效果示例
13
13
 
@@ -39,11 +39,7 @@ ispeak-codex "构建完成,耗时 12 秒"
39
39
  npm i -g @xdfnet/ispeak
40
40
  ```
41
41
 
42
- 当前 npm 安装会在本机编译 `ispeakd`,需要已安装 Go。没有 `ffplay` 时会自动回退 `afplay`;推荐安装 `ffmpeg` 获得流式播放:
43
-
44
- ```bash
45
- brew install ffmpeg
46
- ```
42
+ 当前 npm 安装会在本机编译 `ispeakd`,需要已安装 Go。主播放链路使用 macOS 原生 `AVAudioEngine`,不依赖 `ffmpeg`。失败时直接记录日志并删除任务。
47
43
 
48
44
  **源码安装:**
49
45
 
@@ -76,11 +72,11 @@ ispeak "iSpeak 准备好了"
76
72
  │ │ │
77
73
  │ ▼ │
78
74
  │ 单 Worker 流式链路 │
79
- │ (SSE audio chunk → 播放器 stdin
75
+ │ (SSE PCM chunk → AVAudioEngine
80
76
  │ │ │
81
77
  │ ▼ │
82
- 流式播放器
83
- (优先 ffplay stdin,无 ffplay 回退 afplay)
78
+ 错误处理
79
+ (失败时记录日志并删除任务)
84
80
  └─────────────────────────────────────────────────────┘
85
81
  ```
86
82
 
@@ -104,8 +100,7 @@ pending → running → delete
104
100
  - Markdown 链接:只保留链接标题,不播 URL
105
101
  - 绝对路径:简化为“路径”
106
102
  - 长 commit hash、UUID、长 ID:不播
107
- - 明显文件列表:如模型分片、代码文件列表、下载文件清单
108
- - 下载进度和终端噪声:百分比、速度、进度条、ANSI 控制符
103
+ - 下载进度噪声:速度、ETA、预计剩余时间、ANSI 控制符
109
104
 
110
105
  保留优先级:结论、成功/失败状态、需要用户操作的下一步、关键错误原因。
111
106
 
@@ -131,7 +126,7 @@ ispeak-codex "消息" # Codex 专属音色
131
126
  ```json
132
127
  {
133
128
  "apiKey": "你的火山引擎 API Key",
134
- "endpoint": "https://openspeech.bytedance.com/api/v3/tts/unidirectional",
129
+ "endpoint": "https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse",
135
130
  "defaultVoice": {
136
131
  "voice_type": "zh_female_mizai_uranus_bigtts",
137
132
  "resourceId": "seed-tts-2.0"
@@ -173,7 +168,13 @@ ispeak-codex "消息" # Codex 专属音色
173
168
 
174
169
  ### Codex
175
170
 
176
- `~/.codex/hooks.json` 中添加 Stop Hook:
171
+ 推荐在 `~/.codex/config.toml` 中添加回合结束通知:
172
+
173
+ ```toml
174
+ notify = ["bash", "/Users/你的用户名/.config/iSpeak/hook-speak.sh", "codex"]
175
+ ```
176
+
177
+ 如果你启用了 Codex hooks,也可以在 `~/.codex/hooks.json` 中添加 Stop Hook:
177
178
 
178
179
  ```json
179
180
  {
@@ -189,6 +190,8 @@ ispeak-codex "消息" # Codex 专属音色
189
190
  }
190
191
  ```
191
192
 
193
+ `hook-speak.sh` 会按 `turn_id` 做一次去重,所以即使 `notify` 和 `Stop` 都启用,同一回合也只会播一次。
194
+
192
195
  ## 开发命令
193
196
 
194
197
  ```bash
package/clean_text.go ADDED
@@ -0,0 +1,153 @@
1
+ package main
2
+
3
+ import (
4
+ "regexp"
5
+ "strings"
6
+ )
7
+
8
+ var (
9
+ markdownLinkRe = regexp.MustCompile(`\[[^\]]+\]\(([^)]*)\)`)
10
+ absolutePathRe = regexp.MustCompile(`/(?:Users|private|tmp|var|opt|usr|bin|sbin|etc|Library|Applications)/\S+`)
11
+ commitHashRe = regexp.MustCompile(`\b[0-9a-f]{7,40}\b`)
12
+ uuidRe = regexp.MustCompile(`\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`)
13
+ urlRe = regexp.MustCompile(`https?://\S+`)
14
+ ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
15
+ multiSpaceRe = regexp.MustCompile(`\s+`)
16
+ markdownListRe = regexp.MustCompile(`^\s*(?:[-*+]\s+|\d+[.)]\s+)`)
17
+ htmlTagRe = regexp.MustCompile(`<[^>]+>`)
18
+ codeFenceStartRe = regexp.MustCompile("^```")
19
+ artifactStartRe = regexp.MustCompile(`(?i)^<artifact\b`)
20
+ htmlDocumentLineRe = regexp.MustCompile(`(?i)^<!doctype html|^<html\b|^<head\b|^<body\b|^<style\b|^</`)
21
+ speedNoiseRe = regexp.MustCompile(`(?i)\d+(?:\.\d+)?\s*(?:kb|mb|gb)/s`)
22
+ etaNoiseRe = regexp.MustCompile(`(?i)\bETA\b|预计剩余|剩余时间`)
23
+ )
24
+
25
+ // 过滤格式符号,保留自然朗读文本。
26
+ // 顺序很重要:先跳过跨行块结构,再跳过整行噪声,最后清理行内符号。
27
+ func cleanText(text string) string {
28
+ var lines []string
29
+ rawLines := strings.Split(text, "\n")
30
+ inCodeBlock := false
31
+ inArtifact := false
32
+ inMarkdownTable := false
33
+ for i := 0; i < len(rawLines); i++ {
34
+ line := rawLines[i]
35
+ line = strings.TrimSpace(line)
36
+ if line == "" {
37
+ inMarkdownTable = false
38
+ continue
39
+ }
40
+ if codeFenceStartRe.MatchString(line) {
41
+ inCodeBlock = !inCodeBlock
42
+ continue
43
+ }
44
+ if inCodeBlock {
45
+ continue
46
+ }
47
+ if artifactStartRe.MatchString(line) {
48
+ inArtifact = !strings.Contains(strings.ToLower(line), "</artifact>")
49
+ continue
50
+ }
51
+ if inArtifact {
52
+ if strings.Contains(strings.ToLower(line), "</artifact>") {
53
+ inArtifact = false
54
+ }
55
+ continue
56
+ }
57
+ if isMarkdownTableSeparator(line) {
58
+ if len(lines) > 0 && isMarkdownTableRow(strings.TrimSpace(rawLines[i-1])) {
59
+ lines = lines[:len(lines)-1]
60
+ }
61
+ inMarkdownTable = true
62
+ continue
63
+ }
64
+ if inMarkdownTable {
65
+ if isMarkdownTableRow(line) {
66
+ continue
67
+ }
68
+ inMarkdownTable = false
69
+ }
70
+ if shouldSkipSpeechLine(line) {
71
+ continue
72
+ }
73
+
74
+ cleaned := cleanSpeechLine(line)
75
+ if cleaned != "" {
76
+ lines = append(lines, cleaned)
77
+ }
78
+ }
79
+ return strings.Join(lines, ",")
80
+ }
81
+
82
+ func shouldSkipSpeechLine(line string) bool {
83
+ if isMarkdownTableSeparator(line) {
84
+ return true
85
+ }
86
+ if strings.HasPrefix(line, "---") && strings.Count(line, "-") > 3 {
87
+ return true
88
+ }
89
+ if htmlDocumentLineRe.MatchString(line) {
90
+ return true
91
+ }
92
+ if isProgressNoiseLine(line) {
93
+ return true
94
+ }
95
+ if isMostlyTableRow(line) {
96
+ return true
97
+ }
98
+ return false
99
+ }
100
+
101
+ func isMarkdownTableSeparator(line string) bool {
102
+ line = strings.TrimSpace(line)
103
+ return strings.Contains(line, "|") && strings.Trim(line, "|-: ") == ""
104
+ }
105
+
106
+ func isMarkdownTableRow(line string) bool {
107
+ line = strings.TrimSpace(line)
108
+ return strings.Count(line, "|") >= 2
109
+ }
110
+
111
+ func cleanSpeechLine(line string) string {
112
+ // Markdown 链接必须在 URL 删除前处理,否则会丢掉链接标题。
113
+ line = ansiEscapeRe.ReplaceAllString(line, "")
114
+ line = markdownListRe.ReplaceAllString(line, "")
115
+ line = markdownLinkRe.ReplaceAllStringFunc(line, func(match string) string {
116
+ if end := strings.Index(match, "]"); end > 1 {
117
+ return match[1:end]
118
+ }
119
+ return ""
120
+ })
121
+ line = urlRe.ReplaceAllString(line, "")
122
+ line = absolutePathRe.ReplaceAllString(line, " 路径 ")
123
+ // UUID 必须在短 hash 前处理,避免先删短片段后破坏 UUID 识别。
124
+ line = uuidRe.ReplaceAllString(line, "")
125
+ line = commitHashRe.ReplaceAllString(line, "")
126
+ line = htmlTagRe.ReplaceAllString(line, "")
127
+ line = strings.NewReplacer(
128
+ "**", "",
129
+ "*", "",
130
+ "`", "",
131
+ "#", "",
132
+ ">", "",
133
+ "✅", "",
134
+ "❌", "",
135
+ "✓", "",
136
+ "✗", "",
137
+ "→", "到",
138
+ ).Replace(line)
139
+ line = strings.Trim(line, " \t-:|")
140
+ line = multiSpaceRe.ReplaceAllString(line, " ")
141
+ return strings.TrimSpace(line)
142
+ }
143
+
144
+ func isMostlyTableRow(line string) bool {
145
+ if !strings.Contains(line, "|") {
146
+ return false
147
+ }
148
+ return strings.Count(line, "|") >= 2 && len([]rune(line)) > 40
149
+ }
150
+
151
+ func isProgressNoiseLine(line string) bool {
152
+ return speedNoiseRe.MatchString(line) || etaNoiseRe.MatchString(line)
153
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "apiKey": "your-api-key",
3
- "endpoint": "https://openspeech.bytedance.com/api/v3/tts/unidirectional",
3
+ "endpoint": "https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse",
4
4
  "defaultVoice": {
5
5
  "voice_type": "zh_female_mizai_uranus_bigtts",
6
6
  "resourceId": "seed-tts-2.0"