@xdfnet/ispeak 1.6.7 → 1.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # iSpeak
2
2
 
3
- ![Version](https://img.shields.io/badge/version-1.6.7-blue)
3
+ ![Version](https://img.shields.io/badge/version-1.6.9-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)
@@ -14,12 +14,6 @@ iSpeak 让 AI 编程助手开口说话。你写代码,它播结果——眼睛
14
14
  ```
15
15
  # 默认音色:温柔女声
16
16
  ispeak "Pull request 已合并,3 个测试通过"
17
-
18
- # Claude 模式:专属音色
19
- ispeak-claude "Code review 完成,发现 2 处可优化"
20
-
21
- # Codex 模式:另一种音色
22
- ispeak-codex "构建完成,耗时 12 秒"
23
17
  ```
24
18
 
25
19
  ## 为什么选 iSpeak
@@ -29,7 +23,7 @@ ispeak-codex "构建完成,耗时 12 秒"
29
23
  | AI 生成多条回复,TTS 账单飞涨 | 新消息只保留最新待执行任务,避免无效合成 |
30
24
  | 回复快慢不一,音频播报乱序 | 单 transaction worker,FIFO 顺序稳定 |
31
25
  | 修改配置要重启服务 | 热更新:编辑 `config.json` 立即生效 |
32
- | 默认音色太无聊 | 来源专属音色,Claude Codex 声音不同 |
26
+ | 默认音色太无聊 | hook 按来源前缀选择音色 |
33
27
 
34
28
  ## 快速上手
35
29
 
@@ -113,12 +107,6 @@ ispeak restart # 重启服务
113
107
  ispeak version # 版本
114
108
  ```
115
109
 
116
- 语音专属快捷命令(指向 ispeak 的软链接):
117
- ```bash
118
- ispeak-claude "消息" # Claude 专属音色
119
- ispeak-codex "消息" # Codex 专属音色
120
- ```
121
-
122
110
  ## 配置说明
123
111
 
124
112
  `~/.config/iSpeak/config.json`:
@@ -148,49 +136,9 @@ ispeak-codex "消息" # Codex 专属音色
148
136
 
149
137
  ## 集成说明
150
138
 
151
- ### Claude Code
152
-
153
- 在 `~/.claude/settings.json` 中添加 Stop Hook:
154
-
155
- ```json
156
- {
157
- "hooks": {
158
- "Stop": [{
159
- "hooks": [{
160
- "type": "command",
161
- "command": "bash $HOME/.config/iSpeak/hook-speak.sh claude",
162
- "timeout": 30
163
- }]
164
- }]
165
- }
166
- }
167
- ```
168
-
169
- ### Codex
170
-
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:
178
-
179
- ```json
180
- {
181
- "hooks": {
182
- "Stop": [{
183
- "hooks": [{
184
- "type": "command",
185
- "command": "bash $HOME/.config/iSpeak/hook-speak.sh codex",
186
- "timeout": 30
187
- }]
188
- }]
189
- }
190
- }
191
- ```
139
+ Claude Code 和 Codex 的详细 hook 配置见 [docs/hook-text-extraction.md](/Users/admin/iCode/iSpeak/docs/hook-text-extraction.md)。
192
140
 
193
- `hook-speak.sh` 会按 `turn_id` 做一次去重,所以即使 `notify` 和 `Stop` 都启用,同一回合也只会播一次。
141
+ `hook-speak.sh` 会按 `turn_id` 做一次去重,所以同一回合不会播两次。
194
142
 
195
143
  ## 开发命令
196
144
 
@@ -207,7 +155,7 @@ make help # 显示帮助
207
155
 
208
156
  | 文件 | 用途 |
209
157
  |------|------|
210
- | `~/Library/LaunchAgents/com.iSpeak.plist` | macOS 自动启动服务 |
158
+ | `~/Library/LaunchAgents/com.ispeak.plist` | macOS 自动启动服务 |
211
159
  | `~/.config/iSpeak/ispeak.sock` | Unix Socket |
212
160
  | `~/.config/iSpeak/ispeak.log` | 日志(轮转) |
213
161
  | `~/.config/iSpeak/config.json` | 你的 API Key 和音色配置 |
@@ -3,7 +3,7 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>Label</key>
6
- <string>com.iSpeak</string>
6
+ <string>com.ispeak</string>
7
7
  <key>ProgramArguments</key>
8
8
  <array>
9
9
  <string>BINARY_PATH_PLACEHOLDER</string>
@@ -52,7 +52,7 @@ function lastClaudeAssistant(payload) {
52
52
  if (direct) return { text: direct, turnId: extractTurnId(payload) };
53
53
 
54
54
  const transcript = firstString(payload.transcript_path, payload.transcriptPath);
55
- return transcript ? lastAssistantFromTranscript(transcript, "claude") : { text: "", turnId: extractTurnId(payload) };
55
+ return transcript ? lastClaudeTranscript(transcript, payload) : { text: "", turnId: extractTurnId(payload) };
56
56
  }
57
57
 
58
58
  function lastCodexAssistant(payload) {
@@ -98,11 +98,29 @@ function firstString(...values) {
98
98
 
99
99
  function collectText(content) {
100
100
  if (typeof content === "string") return content;
101
- if (!Array.isArray(content)) return "";
102
- return content
103
- .map(item => item && typeof item.text === "string" ? item.text : "")
104
- .filter(Boolean)
105
- .join(" ");
101
+ if (Array.isArray(content)) {
102
+ return content
103
+ .map(item => collectText(item))
104
+ .filter(Boolean)
105
+ .join(" ");
106
+ }
107
+ if (!content || typeof content !== "object") return "";
108
+ if (typeof content.text === "string") return content.text;
109
+ if (content.content) return collectText(content.content);
110
+ return "";
111
+ }
112
+
113
+ function lastClaudeTranscript(file, payload) {
114
+ const deadline = Date.now() + 1200;
115
+ let result = { text: "", turnId: extractTurnId(payload) };
116
+
117
+ while (Date.now() <= deadline) {
118
+ result = lastAssistantFromTranscript(file, "claude");
119
+ if (result.text) return result;
120
+ sleepMs(120);
121
+ }
122
+
123
+ return result;
106
124
  }
107
125
 
108
126
  function lastAssistantFromTranscript(file, source) {
@@ -179,6 +197,10 @@ function textHash(text) {
179
197
  return crypto.createHash("sha1").update(text, "utf8").digest("hex");
180
198
  }
181
199
 
200
+ function sleepMs(ms) {
201
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
202
+ }
203
+
182
204
  NODE
183
205
  )
184
206
 
@@ -192,6 +214,23 @@ echo "SOURCE: $SOURCE" >> "$LOG"
192
214
  echo "TEXT_LEN: ${#result}" >> "$LOG"
193
215
  echo "PREVIEW: ${result:0:150}" >> "$LOG"
194
216
 
217
+ # Claude Code Stop Hook 调试
218
+ if [[ "$SOURCE" == "claude" && -n "$input" ]]; then
219
+ # 用 grep 提取 transcript_path
220
+ tp=$(echo "$input" | grep -o '"transcript_path":"[^"]*"' | head -1 | sed 's/"transcript_path":"//;s/"$//')
221
+ if [[ -n "$tp" ]]; then
222
+ echo "CLAUDE_TRANSCRIPT_PATH: $tp" >> "$LOG"
223
+ if [[ -f "$tp" ]]; then
224
+ echo "CLAUDE_TRANSCRIPT_EXISTS: yes" >> "$LOG"
225
+ else
226
+ echo "CLAUDE_TRANSCRIPT_EXISTS: no" >> "$LOG"
227
+ fi
228
+ else
229
+ echo "CLAUDE_TRANSCRIPT_PATH: none" >> "$LOG"
230
+ echo "CLAUDE_RAW: ${input:0:300}" >> "$LOG"
231
+ fi
232
+ fi
233
+
195
234
  if [[ -n "$result" && -S "$SOCK" ]]; then
196
235
  printf "{source:%s}%s" "$SOURCE" "$result" | nc -U -w5 "$SOCK" 2>> "$LOG"
197
236
  echo "SPOKE: OK" >> "$LOG"
@@ -180,7 +180,7 @@ pending -> running -> delete
180
180
  └── hook-speak.sh # Claude/Codex Hook
181
181
 
182
182
  ~/Library/LaunchAgents/
183
- └── com.iSpeak.plist # launchd 服务配置
183
+ └── com.ispeak.plist # launchd 服务配置
184
184
  ```
185
185
 
186
186
  ## 稳定性设计
@@ -6,9 +6,9 @@
6
6
 
7
7
  推荐优先级:
8
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`。
9
+ 1. **Codex `notify`**:从脚本第二个参数 `$2` 读取 JSON,取 `last-assistant-message`(kebab-case)。
10
+ 2. **Codex Stop Hook**:从 stdin 读取 JSON,取 `last_assistant_message`(snake_case)。
11
+ 3. **Claude Code Stop Hook**:从 stdin 读取 JSON,只读 `transcript_path`(官方无 direct 字段)。
12
12
 
13
13
  不扫描 `~/.codex/sessions`。没有 direct 字段也没有 `transcript_path` 时,本次不播报。
14
14
 
@@ -61,7 +61,7 @@ input="${2:-}"
61
61
  payload["last-assistant-message"]
62
62
  ```
63
63
 
64
- 源码依据:`codex-rs/hooks/src/legacy_notify.rs`。该文件把 `last_assistant_message` 序列化为 kebab-case 的 `last-assistant-message`,并在执行命令前 `command.arg(notify_payload)`。
64
+ 源码依据:`codex-rs/hooks/src/legacy_notify.rs`(https://github.com/openai/codex,2026-05-11)。该文件把 `last_assistant_message` 序列化为 kebab-case 的 `last-assistant-message`,并在执行命令前 `command.arg(notify_payload)`。
65
65
 
66
66
  ## Codex CLI:Stop Hook
67
67
 
@@ -95,7 +95,23 @@ $2 = empty
95
95
  stdin = '{"hook_event_name":"Stop",...,"last_assistant_message":"..."}'
96
96
  ```
97
97
 
98
- 核心字段:
98
+ 核心字段(源码 `StopCommandInput` struct):
99
+
100
+ ```rust
101
+ struct StopCommandInput {
102
+ session_id: String,
103
+ turn_id: String,
104
+ transcript_path: NullableString,
105
+ cwd: String,
106
+ hook_event_name: String,
107
+ model: String,
108
+ permission_mode: String,
109
+ stop_hook_active: bool,
110
+ last_assistant_message: NullableString, // ← Codex 有此字段
111
+ }
112
+ ```
113
+
114
+ 对应 JSON:
99
115
 
100
116
  ```json
101
117
  {
@@ -113,9 +129,8 @@ stdin = '{"hook_event_name":"Stop",...,"last_assistant_message":"..."}'
113
129
 
114
130
  源码依据:
115
131
 
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`。
132
+ - `codex-rs/hooks/src/events/stop.rs`(https://github.com/openai/codex,2026-05-11):构造 `StopCommandInput`,包含 `last_assistant_message` 和 `transcript_path`。
133
+ - `codex-rs/hooks/src/engine/command_runner.rs`(同上):Hook 命令通过 stdin 接收 `input_json`。
119
134
 
120
135
  ## Codex Transcript
121
136
 
@@ -153,30 +168,35 @@ event.payload.content[].text
153
168
 
154
169
  ## Claude Code:Stop Hook
155
170
 
156
- Claude Code 官方 Stop Hook 通过 stdin 传 JSON,核心字段是:
171
+ > **来源**:[Claude Code Hooks Reference](https://code.claude.com/docs/en/hooks.md),更新时间:2026-05-11
172
+
173
+ Claude Code 官方 Stop Hook **没有 `last_assistant_message` 字段**。
174
+
175
+ 根据官方文档,Stop Hook 的 Common Input Fields 为:
157
176
 
158
177
  ```json
159
178
  {
160
- "session_id": "...",
161
- "transcript_path": "...",
179
+ "session_id": "abc123",
180
+ "transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
181
+ "cwd": "/home/user/my-project",
182
+ "permission_mode": "default",
162
183
  "hook_event_name": "Stop",
163
- "stop_hook_active": false
184
+ "effort": {
185
+ "level": "medium"
186
+ }
164
187
  }
165
188
  ```
166
189
 
167
- 有些版本或场景可能直接提供:
190
+ 子 agent 上下文中额外字段:
168
191
 
169
192
  ```json
170
193
  {
171
- "last_assistant_message": "最后一条 assistant 回复"
194
+ "agent_id": "subagent_xyz",
195
+ "agent_type": "Explore"
172
196
  }
173
197
  ```
174
198
 
175
- 所以 Claude 的读取顺序是:
176
-
177
- 1. `last_assistant_message`
178
- 2. `message`
179
- 3. `transcript_path`
199
+ **结论**:Claude Code Stop Hook 官方设计只提供 `transcript_path`,没有直接内嵌 `last_assistant_message`。旧版本脚本的 `last_assistant_message` / `message` fallback 实际上**从未被官方文档支持**。
180
200
 
181
201
  Claude transcript 常见 assistant 形态:
182
202
 
@@ -207,11 +227,11 @@ fi
207
227
  - Claude / Codex Stop Hook:读 stdin
208
228
  - 如果 Codex 的 `notify` 和 `Stop` 同时启用,脚本会按 `turn_id` 去重,避免同一回合播两次
209
229
 
210
- Codex 文本字段优先级:
230
+ Codex 文本字段优先级(源码确认):
211
231
 
212
232
  ```js
213
- payload["last-assistant-message"]
214
- payload.last_assistant_message
233
+ payload["last-assistant-message"] // notify: kebab-case
234
+ payload.last_assistant_message // Stop Hook: snake_case
215
235
  payload.lastAssistantMessage
216
236
  payload.message
217
237
  payload.lastMessage
@@ -220,15 +240,14 @@ payload.transcriptPath
220
240
  payload["transcript-path"]
221
241
  ```
222
242
 
223
- Claude 文本字段优先级:
243
+ Claude Code 文本字段优先级(官方文档):
224
244
 
225
245
  ```js
226
- payload.last_assistant_message
227
- payload.message
228
- payload.transcript_path
229
- payload.transcriptPath
246
+ payload.transcript_path // 官方支持的唯一方式
230
247
  ```
231
248
 
249
+ > **注**:Claude Code Stop Hook 官方 payload 中**没有 `last_assistant_message` 字段**,这是与 Codex 的本质区别。
250
+
232
251
  ## 为什么不能只读 stdin
233
252
 
234
253
  因为 Codex `notify` 不走 stdin。只读 stdin 会导致:
@@ -239,3 +258,17 @@ SPOKE: SKIP
239
258
  ```
240
259
 
241
260
  正确做法是先读 `$2`,再读 stdin;不扫历史 session。
261
+
262
+ ## Claude Code TEXT_LEN: 0 的根因
263
+
264
+ 当 Claude Code Stop Hook 触发但 `TEXT_LEN: 0` 时:
265
+
266
+ 1. **官方字段不存在**:Claude Code Stop Hook 官方 payload 中**没有 `last_assistant_message` 字段**,只有 `transcript_path`
267
+ 2. **transcript 文件可能晚一点才写完**:Hook 触发时文件虽已存在,但最后一条 assistant 文本还没落盘
268
+ 3. **结果**:如果只读一次,`hook-speak.sh` 可能拿到空串,本次不播报
269
+
270
+ 当前脚本对 Claude transcript 做了很短的轮询,等最后一条 assistant 文本真正出现再播,避免这个时序窗。
271
+
272
+ 这是 **Claude Code 与 Codex 的设计差异**,非 bug。Codex CLI(无论 notify 还是 Stop Hook)都提供 `last_assistant_message`,而 Claude Code 官方只提供 `transcript_path`。
273
+
274
+ 解决方案:从 `transcript_path` 读取并解析为最终一条 assistant 回复,并在 Claude 路径上补一个短轮询。
@@ -10,7 +10,8 @@ const root = path.resolve(__dirname, "..");
10
10
  const home = os.homedir();
11
11
  const binDir = path.join(home, ".local", "bin");
12
12
  const configDir = path.join(home, ".config", "iSpeak");
13
- const plistPath = path.join(home, "Library", "LaunchAgents", "com.iSpeak.plist");
13
+ const plistPath = path.join(home, "Library", "LaunchAgents", "com.ispeak.plist");
14
+ const legacyPlistPath = path.join(home, "Library", "LaunchAgents", "com.iSpeak.plist");
14
15
  const socketPath = path.join(configDir, "ispeak.sock");
15
16
  const binaryPath = path.join(binDir, "ispeakd");
16
17
  const cliPath = path.join(binDir, "ispeak");
@@ -136,12 +137,16 @@ function main() {
136
137
  run("go", ["build", "-ldflags=-s -w", "-o", buildPath, "."]);
137
138
 
138
139
  console.log("停止旧服务...");
140
+ run("launchctl", ["unload", legacyPlistPath], { allowFailure: true, stdio: "ignore" });
139
141
  run("launchctl", ["unload", plistPath], { allowFailure: true, stdio: "ignore" });
142
+ try {
143
+ fs.rmSync(legacyPlistPath, { force: true });
144
+ } catch (_) {
145
+ // Ignore migration cleanup failures.
146
+ }
140
147
 
141
148
  copyExecutable(buildPath, binaryPath);
142
149
  copyExecutable(path.join(root, "scripts", "ispeak"), cliPath);
143
- symlinkForce(cliPath, path.join(binDir, "ispeak-claude"));
144
- symlinkForce(cliPath, path.join(binDir, "ispeak-codex"));
145
150
 
146
151
  const configPath = path.join(configDir, "config.json");
147
152
  copyIfMissing(path.join(root, "configs", "config.example.json"), configPath);
@@ -149,7 +154,7 @@ function main() {
149
154
  installHook(path.join(root, "configs", "hook-speak.sh"), path.join(configDir, "hook-speak.sh"));
150
155
 
151
156
  const plist = fs
152
- .readFileSync(path.join(root, "configs", "com.iSpeak.plist"), "utf8")
157
+ .readFileSync(path.join(root, "configs", "com.ispeak.plist"), "utf8")
153
158
  .replaceAll("BINARY_PATH_PLACEHOLDER", binaryPath);
154
159
  fs.writeFileSync(plistPath, plist);
155
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdfnet/ispeak",
3
- "version": "1.6.7",
3
+ "version": "1.6.9",
4
4
  "description": "Local macOS TTS daemon for AI coding assistants, powered by Volcengine streaming TTS.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/xdfnet/iSpeak#readme",
@@ -19,9 +19,7 @@
19
19
  "x64"
20
20
  ],
21
21
  "bin": {
22
- "ispeak": "scripts/ispeak",
23
- "ispeak-claude": "scripts/ispeak",
24
- "ispeak-codex": "scripts/ispeak"
22
+ "ispeak": "scripts/ispeak"
25
23
  },
26
24
  "scripts": {
27
25
  "build": "go build -ldflags=\"-s -w\" -o build/ispeakd .",
@@ -38,7 +36,7 @@
38
36
  "scripts/ispeak",
39
37
  "configs/",
40
38
  "npm/",
41
- "Docs/",
39
+ "docs/",
42
40
  "README.md",
43
41
  "LICENSE"
44
42
  ],
package/scripts/ispeak CHANGED
@@ -2,16 +2,10 @@
2
2
  # ispeak — iSpeak 控制命令
3
3
  set -euo pipefail
4
4
 
5
- VERSION="1.6.7"
5
+ VERSION="1.6.9"
6
6
  SOCK="$HOME/.config/iSpeak/ispeak.sock"
7
- PLIST="$HOME/Library/LaunchAgents/com.iSpeak.plist"
8
- CMD_NAME="$(basename "$0")"
9
- SOURCE_PREFIX=""
10
-
11
- case "$CMD_NAME" in
12
- ispeak-claude) SOURCE_PREFIX="{source:claude}" ;;
13
- ispeak-codex) SOURCE_PREFIX="{source:codex}" ;;
14
- esac
7
+ PLIST="$HOME/Library/LaunchAgents/com.ispeak.plist"
8
+ LEGACY_PLIST="$HOME/Library/LaunchAgents/com.iSpeak.plist"
15
9
 
16
10
  cmd_status() {
17
11
  echo "== iSpeak =="
@@ -28,6 +22,7 @@ cmd_status() {
28
22
  }
29
23
 
30
24
  cmd_restart() {
25
+ launchctl unload "$LEGACY_PLIST" 2>/dev/null || true
31
26
  launchctl unload "$PLIST" 2>/dev/null || true
32
27
  launchctl load "$PLIST"
33
28
  sleep 0.5
@@ -53,6 +48,6 @@ case "${1:-}" in
53
48
  echo " ispeak version 版本"
54
49
  ;;
55
50
  *)
56
- printf "%s%s" "$SOURCE_PREFIX" "$*" | nc -U -w5 "$SOCK" 2>/dev/null || echo "ispeak: socket 不可用" >&2
51
+ printf "%s" "$*" | nc -U -w5 "$SOCK" 2>/dev/null || echo "ispeak: socket 不可用" >&2
57
52
  ;;
58
53
  esac