@xdfnet/ispeak 1.6.15 → 1.6.16
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/AGENTS.md +12 -19
- package/Makefile +1 -1
- package/avaudioengine_player_darwin.go +0 -6
- package/configs/hook-speak.sh +11 -192
- package/docs/hook-text-extraction.md +41 -227
- package/main.go +14 -31
- package/package.json +1 -1
- package/scripts/ispeak +1 -1
package/AGENTS.md
CHANGED
|
@@ -28,11 +28,9 @@ make help # 显示帮助
|
|
|
28
28
|
ispeak (CLI, bash)
|
|
29
29
|
└─ nc -U ~/.config/iSpeak/ispeak.sock
|
|
30
30
|
└─ ispeakd (Go daemon)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
└─ pending -> running -> delete
|
|
35
|
-
└─ SSE PCM chunk -> AVAudioEngine
|
|
31
|
+
└─ Player (channel, buffer=1)
|
|
32
|
+
└─ loop goroutine: 单 AVAudioEngine 实例复用
|
|
33
|
+
└─ SSE PCM chunk → AVAudioEngine
|
|
36
34
|
```
|
|
37
35
|
|
|
38
36
|
- **Socket**: `~/.config/iSpeak/ispeak.sock`
|
|
@@ -42,7 +40,7 @@ ispeak (CLI, bash)
|
|
|
42
40
|
|
|
43
41
|
## 核心文件
|
|
44
42
|
|
|
45
|
-
- `main.go` —
|
|
43
|
+
- `main.go` — 守护进程、Player (channel 驱动)、TTS 流式请求、SSE 解析
|
|
46
44
|
- `avaudioengine_player_darwin.go` — macOS 原生 `AVAudioEngine` PCM 播放器
|
|
47
45
|
- `clean_text.go` — TTS 播报文本清洗
|
|
48
46
|
- `main_test.go` — 任务引擎关键行为测试
|
|
@@ -62,18 +60,13 @@ CLI 与 daemon 通过 socket 传输原始文本,支持音色前缀:
|
|
|
62
60
|
## 任务策略(节省 TTS 费用)
|
|
63
61
|
|
|
64
62
|
新消息到达时:
|
|
65
|
-
1.
|
|
66
|
-
2.
|
|
67
|
-
3.
|
|
68
|
-
|
|
69
|
-
**任务状态流转:**
|
|
70
|
-
```
|
|
71
|
-
pending → running → delete
|
|
72
|
-
```
|
|
63
|
+
1. 丢弃 channel 中排队的旧消息
|
|
64
|
+
2. 不打断当前正在合成/播放的任务
|
|
65
|
+
3. 新消息入队
|
|
73
66
|
|
|
74
67
|
## 失败策略
|
|
75
68
|
|
|
76
|
-
-
|
|
69
|
+
- 流式合成/播放失败:日志记录,继续处理下一条,不重试
|
|
77
70
|
|
|
78
71
|
## 配置
|
|
79
72
|
|
|
@@ -93,11 +86,11 @@ pending → running → delete
|
|
|
93
86
|
|
|
94
87
|
## 稳定性设计
|
|
95
88
|
|
|
96
|
-
- 单
|
|
97
|
-
-
|
|
89
|
+
- 单 Player goroutine,合成与播放同链路,降低首播延迟
|
|
90
|
+
- AVAudioEngine 实例复用,避免重复初始化开销
|
|
91
|
+
- Channel buffer=1 + drain,新消息自动丢弃旧排队消息
|
|
98
92
|
- 配置热更新(mtime 缓存 + 自动重载)
|
|
99
93
|
- TTS HTTP Client 复用,减少连接开销
|
|
100
94
|
- 主链路使用 macOS 原生 `AVAudioEngine` 播放 PCM
|
|
101
|
-
-
|
|
95
|
+
- 合成/播放失败直接跳过,不重试
|
|
102
96
|
- 日志轮转,防止文件过大
|
|
103
|
-
- 进程级 temp 目录,退出时自动清理
|
package/Makefile
CHANGED
|
@@ -252,12 +252,6 @@ func (p *avAudioEngineStreamPlayer) CloseAndWait() error {
|
|
|
252
252
|
return p.closeLocked()
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
func (p *avAudioEngineStreamPlayer) Abort() error {
|
|
256
|
-
p.mu.Lock()
|
|
257
|
-
defer p.mu.Unlock()
|
|
258
|
-
return p.closeLocked()
|
|
259
|
-
}
|
|
260
|
-
|
|
261
255
|
func (p *avAudioEngineStreamPlayer) writeChunk(data []byte) error {
|
|
262
256
|
if len(data) == 0 {
|
|
263
257
|
return nil
|
package/configs/hook-speak.sh
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Claude Code / Codex 共用播报 Hook:
|
|
3
|
-
#
|
|
3
|
+
# 取 last_assistant_message,加 {source:<name>} 前缀后发给 ispeakd。
|
|
4
|
+
# Claude: payload.last_assistant_message (snake_case)
|
|
5
|
+
# Codex: payload["last-assistant-message"] (kebab-case)
|
|
4
6
|
[[ "$ISPEAK_SKIP" == "1" ]] && exit 0
|
|
5
7
|
|
|
6
8
|
SOURCE="${1:-claude}"
|
|
7
9
|
SOCK="$HOME/.config/iSpeak/ispeak.sock"
|
|
8
10
|
LOG="$HOME/.config/iSpeak/hook.log"
|
|
9
|
-
STATE_FILE="$HOME/.config/iSpeak/hook.last"
|
|
10
11
|
|
|
11
|
-
# Codex `notify` 会把 JSON 作为最后一个参数传入;
|
|
12
|
-
# Claude/Claude 风格 Stop Hook 会把 JSON 写到 stdin。
|
|
13
12
|
input="${2:-}"
|
|
14
13
|
if [[ -z "$input" ]]; then
|
|
15
14
|
input=$(cat)
|
|
@@ -18,183 +17,26 @@ input_file=$(mktemp)
|
|
|
18
17
|
trap 'rm -f "$input_file"' EXIT
|
|
19
18
|
printf "%s" "$input" > "$input_file"
|
|
20
19
|
|
|
21
|
-
result=$(
|
|
20
|
+
result=$(HOOK_INPUT_FILE="$input_file" node <<'NODE' 2>>"$LOG"
|
|
22
21
|
const fs = require("fs");
|
|
23
|
-
const crypto = require("crypto");
|
|
24
22
|
|
|
25
23
|
(() => {
|
|
26
24
|
const input = readFile(process.env.HOOK_INPUT_FILE || "");
|
|
27
25
|
const payload = parseJSON(input) || {};
|
|
28
|
-
const source = process.env.SOURCE || "";
|
|
29
|
-
const stateFile = process.env.HOOK_STATE_FILE || "";
|
|
30
|
-
const result = source.startsWith("codex")
|
|
31
|
-
? lastCodexAssistant(payload)
|
|
32
|
-
: lastClaudeAssistant(payload);
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
const text = payload.last_assistant_message
|
|
28
|
+
|| payload["last-assistant-message"]
|
|
29
|
+
|| "";
|
|
37
30
|
|
|
38
|
-
if (
|
|
39
|
-
if (isDuplicateTurn(stateFile, source, result.turnId)) {
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
saveTurn(stateFile, source, result.turnId, result.text);
|
|
43
|
-
} else if (stateFile) {
|
|
44
|
-
saveTurn(stateFile, source, "", result.text);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
process.stdout.write(result.text);
|
|
31
|
+
if (text) process.stdout.write(text);
|
|
48
32
|
})();
|
|
49
33
|
|
|
50
|
-
function lastClaudeAssistant(payload) {
|
|
51
|
-
const direct = firstString(payload.last_assistant_message, payload.message);
|
|
52
|
-
if (direct) return { text: direct, turnId: extractTurnId(payload) };
|
|
53
|
-
|
|
54
|
-
const transcript = firstString(payload.transcript_path, payload.transcriptPath);
|
|
55
|
-
return transcript ? lastClaudeTranscript(transcript, payload) : { text: "", turnId: extractTurnId(payload) };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function lastCodexAssistant(payload) {
|
|
59
|
-
const direct = firstString(
|
|
60
|
-
payload["last-assistant-message"],
|
|
61
|
-
payload.last_assistant_message,
|
|
62
|
-
payload.lastAssistantMessage,
|
|
63
|
-
payload.message,
|
|
64
|
-
payload.lastMessage
|
|
65
|
-
);
|
|
66
|
-
if (direct) return { text: direct, turnId: extractTurnId(payload) };
|
|
67
|
-
|
|
68
|
-
const transcript = firstString(
|
|
69
|
-
payload.transcript_path,
|
|
70
|
-
payload.transcriptPath,
|
|
71
|
-
payload["transcript-path"]
|
|
72
|
-
);
|
|
73
|
-
return transcript ? lastAssistantFromTranscript(transcript, "codex") : { text: "", turnId: extractTurnId(payload) };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
34
|
function readFile(file) {
|
|
77
|
-
try {
|
|
78
|
-
return fs.readFileSync(file, "utf8");
|
|
79
|
-
} catch {
|
|
80
|
-
return "";
|
|
81
|
-
}
|
|
35
|
+
try { return fs.readFileSync(file, "utf8"); } catch { return ""; }
|
|
82
36
|
}
|
|
83
|
-
|
|
84
37
|
function parseJSON(text) {
|
|
85
|
-
try {
|
|
86
|
-
return JSON.parse(text);
|
|
87
|
-
} catch {
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function firstString(...values) {
|
|
93
|
-
for (const value of values) {
|
|
94
|
-
if (typeof value === "string" && value !== "") return value;
|
|
95
|
-
}
|
|
96
|
-
return "";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function collectText(content) {
|
|
100
|
-
if (typeof content === "string") return content;
|
|
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 "";
|
|
38
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
111
39
|
}
|
|
112
|
-
|
|
113
|
-
function lastClaudeTranscript(file, payload) {
|
|
114
|
-
const deadline = Date.now() + 5000;
|
|
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;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function lastAssistantFromTranscript(file, source) {
|
|
127
|
-
let data = "";
|
|
128
|
-
try {
|
|
129
|
-
data = fs.readFileSync(file, "utf8");
|
|
130
|
-
} catch {
|
|
131
|
-
return "";
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
let last = "";
|
|
135
|
-
let turnId = "";
|
|
136
|
-
for (const line of data.split(/\r?\n/)) {
|
|
137
|
-
if (!line.trim()) continue;
|
|
138
|
-
const event = parseJSON(line);
|
|
139
|
-
if (!event) continue;
|
|
140
|
-
|
|
141
|
-
if (source === "claude") {
|
|
142
|
-
if (event.role === "assistant") {
|
|
143
|
-
last = collectText(event.content) || last;
|
|
144
|
-
}
|
|
145
|
-
if (event.message && event.message.role === "assistant") {
|
|
146
|
-
last = collectText(event.message.content) || last;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (source === "codex" &&
|
|
151
|
-
event.type === "response_item" &&
|
|
152
|
-
event.payload &&
|
|
153
|
-
event.payload.type === "message" &&
|
|
154
|
-
event.payload.role === "assistant"
|
|
155
|
-
) {
|
|
156
|
-
last = collectText(event.payload.content) || last;
|
|
157
|
-
turnId = turnId || extractTurnId(event) || extractTurnId(event.payload);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return { text: last, turnId };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function extractTurnId(payload) {
|
|
164
|
-
return firstString(
|
|
165
|
-
payload.turn_id,
|
|
166
|
-
payload.turnId,
|
|
167
|
-
payload["turn-id"]
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function isDuplicateTurn(stateFile, source, turnId) {
|
|
172
|
-
const current = `${source}:${turnId}`;
|
|
173
|
-
try {
|
|
174
|
-
return fs.readFileSync(stateFile, "utf8").trim() === current;
|
|
175
|
-
} catch {
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function saveTurn(stateFile, source, turnId, text) {
|
|
181
|
-
const current = `${source}:${turnId || textHash(text)}`;
|
|
182
|
-
try {
|
|
183
|
-
fs.mkdirSync(require("path").dirname(stateFile), { recursive: true });
|
|
184
|
-
fs.writeFileSync(stateFile, current, "utf8");
|
|
185
|
-
} catch {
|
|
186
|
-
// 去重失败不影响播报。
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function textHash(text) {
|
|
191
|
-
return crypto.createHash("sha1").update(text, "utf8").digest("hex");
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function sleepMs(ms) {
|
|
195
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
40
|
NODE
|
|
199
41
|
)
|
|
200
42
|
|
|
@@ -203,31 +45,8 @@ if [[ "$ISPEAK_HOOK_PRINT_TEXT" == "1" ]]; then
|
|
|
203
45
|
exit 0
|
|
204
46
|
fi
|
|
205
47
|
|
|
206
|
-
echo "=== $(date) ===" >> "$LOG"
|
|
207
|
-
echo "SOURCE: $SOURCE" >> "$LOG"
|
|
208
|
-
echo "TEXT_LEN: ${#result}" >> "$LOG"
|
|
209
|
-
echo "PREVIEW: ${result:0:150}" >> "$LOG"
|
|
210
|
-
|
|
211
|
-
# Claude Code Stop Hook 调试
|
|
212
|
-
if [[ "$SOURCE" == "claude" && -n "$input" ]]; then
|
|
213
|
-
# 用 grep 提取 transcript_path
|
|
214
|
-
tp=$(echo "$input" | grep -o '"transcript_path":"[^"]*"' | head -1 | sed 's/"transcript_path":"//;s/"$//')
|
|
215
|
-
if [[ -n "$tp" ]]; then
|
|
216
|
-
echo "CLAUDE_TRANSCRIPT_PATH: $tp" >> "$LOG"
|
|
217
|
-
if [[ -f "$tp" ]]; then
|
|
218
|
-
echo "CLAUDE_TRANSCRIPT_EXISTS: yes" >> "$LOG"
|
|
219
|
-
else
|
|
220
|
-
echo "CLAUDE_TRANSCRIPT_EXISTS: no" >> "$LOG"
|
|
221
|
-
fi
|
|
222
|
-
else
|
|
223
|
-
echo "CLAUDE_TRANSCRIPT_PATH: none" >> "$LOG"
|
|
224
|
-
echo "CLAUDE_RAW: ${input:0:300}" >> "$LOG"
|
|
225
|
-
fi
|
|
226
|
-
fi
|
|
227
|
-
|
|
228
48
|
if [[ -n "$result" && -S "$SOCK" ]]; then
|
|
229
49
|
printf "{source:%s}%s" "$SOURCE" "$result" | nc -U -w5 "$SOCK" 2>> "$LOG"
|
|
230
|
-
echo "SPOKE: OK" >> "$LOG"
|
|
231
50
|
else
|
|
232
|
-
echo "
|
|
51
|
+
echo "$(date): SKIP source=$SOURCE text_len=${#result}" >> "$LOG"
|
|
233
52
|
fi
|
|
@@ -1,274 +1,88 @@
|
|
|
1
1
|
# Hook 文本提取链路
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`hook-speak.sh` 只做一件事:从 Hook JSON 里取 assistant 回复文本,发给 iSpeak socket。
|
|
4
4
|
|
|
5
5
|
## 结论
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
一行提取,覆盖所有来源:
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
## Codex CLI:notify
|
|
16
|
-
|
|
17
|
-
当前本机版本:
|
|
18
|
-
|
|
19
|
-
```text
|
|
20
|
-
codex-cli 0.130.0
|
|
9
|
+
```js
|
|
10
|
+
const text = payload.last_assistant_message // Claude Stop / Codex Stop (snake_case)
|
|
11
|
+
|| payload["last-assistant-message"] // Codex notify (kebab-case)
|
|
12
|
+
|| payload.message // fallback
|
|
13
|
+
|| "";
|
|
21
14
|
```
|
|
22
15
|
|
|
23
|
-
|
|
16
|
+
不再需要 transcript 轮询、去重、状态文件。
|
|
24
17
|
|
|
25
|
-
|
|
18
|
+
## 输入来源
|
|
26
19
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
| 来源 | 传参方式 | 字段名 |
|
|
21
|
+
|------|---------|--------|
|
|
22
|
+
| Claude Code Stop Hook | stdin | `last_assistant_message` |
|
|
23
|
+
| Codex Stop Hook | stdin | `last_assistant_message` |
|
|
24
|
+
| Codex notify | `$2` (argv) | `last-assistant-message` |
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
脚本统一处理:
|
|
32
27
|
|
|
33
28
|
```bash
|
|
34
|
-
$
|
|
35
|
-
|
|
36
|
-
|
|
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"]
|
|
29
|
+
input="${2:-}" # Codex notify 走 $2
|
|
30
|
+
if [[ -z "$input" ]]; then
|
|
31
|
+
input=$(cat) # Claude / Codex Stop Hook 走 stdin
|
|
32
|
+
fi
|
|
62
33
|
```
|
|
63
34
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
## Codex CLI:Stop Hook
|
|
35
|
+
## Codex notify
|
|
67
36
|
|
|
68
|
-
Codex
|
|
37
|
+
Codex `notify = [...]` 把 JSON 追加为命令最后一个 argv 参数。
|
|
69
38
|
|
|
70
|
-
|
|
39
|
+
配置:
|
|
71
40
|
|
|
72
|
-
```
|
|
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
|
-
}
|
|
41
|
+
```toml
|
|
42
|
+
notify = ["/Users/xxx/.config/iSpeak/hook-speak.sh", "codex"]
|
|
88
43
|
```
|
|
89
44
|
|
|
90
|
-
|
|
45
|
+
脚本收到:
|
|
91
46
|
|
|
92
47
|
```bash
|
|
93
48
|
$1 = "codex"
|
|
94
|
-
$2 =
|
|
95
|
-
stdin = '{"hook_event_name":"Stop",...,"last_assistant_message":"..."}'
|
|
49
|
+
$2 = '{"type":"agent-turn-complete",...,"last-assistant-message":"..."}'
|
|
96
50
|
```
|
|
97
51
|
|
|
98
|
-
|
|
52
|
+
源码:`codex-rs/hooks/src/legacy_notify.rs` — `last_assistant_message` 序列化为 kebab-case `last-assistant-message`,通过 `command.arg(notify_payload)` 传入。
|
|
99
53
|
|
|
100
|
-
|
|
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
|
-
```
|
|
54
|
+
## Codex Stop Hook
|
|
113
55
|
|
|
114
|
-
|
|
56
|
+
stdin JSON:
|
|
115
57
|
|
|
116
58
|
```json
|
|
117
59
|
{
|
|
118
|
-
"session_id": "...",
|
|
119
60
|
"turn_id": "...",
|
|
120
61
|
"transcript_path": "...",
|
|
121
|
-
"cwd": "...",
|
|
122
|
-
"hook_event_name": "Stop",
|
|
123
|
-
"model": "...",
|
|
124
|
-
"permission_mode": "bypassPermissions",
|
|
125
|
-
"stop_hook_active": false,
|
|
126
62
|
"last_assistant_message": "最后一条 assistant 回复"
|
|
127
63
|
}
|
|
128
64
|
```
|
|
129
65
|
|
|
130
|
-
|
|
131
|
-
|
|
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`。
|
|
66
|
+
源码:`codex-rs/hooks/src/events/stop.rs` — `StopCommandInput` struct 包含 `last_assistant_message`。
|
|
134
67
|
|
|
135
|
-
##
|
|
68
|
+
## Claude Code Stop Hook
|
|
136
69
|
|
|
137
|
-
|
|
70
|
+
stdin JSON(实测,2026-05):
|
|
138
71
|
|
|
139
72
|
```json
|
|
140
73
|
{
|
|
141
|
-
"
|
|
142
|
-
"
|
|
143
|
-
|
|
144
|
-
"role": "assistant",
|
|
145
|
-
"content": [
|
|
146
|
-
{
|
|
147
|
-
"type": "output_text",
|
|
148
|
-
"text": "最后一条 assistant 回复"
|
|
149
|
-
}
|
|
150
|
-
]
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
提取规则:
|
|
156
|
-
|
|
157
|
-
```js
|
|
158
|
-
event.type === "response_item" &&
|
|
159
|
-
event.payload?.type === "message" &&
|
|
160
|
-
event.payload?.role === "assistant"
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
然后拼接:
|
|
164
|
-
|
|
165
|
-
```js
|
|
166
|
-
event.payload.content[].text
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
## Claude Code:Stop Hook
|
|
170
|
-
|
|
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 为:
|
|
176
|
-
|
|
177
|
-
```json
|
|
178
|
-
{
|
|
179
|
-
"session_id": "abc123",
|
|
180
|
-
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
|
|
181
|
-
"cwd": "/home/user/my-project",
|
|
182
|
-
"permission_mode": "default",
|
|
74
|
+
"session_id": "...",
|
|
75
|
+
"transcript_path": "/Users/admin/.claude/projects/.../xxx.jsonl",
|
|
76
|
+
"cwd": "...",
|
|
183
77
|
"hook_event_name": "Stop",
|
|
184
|
-
"
|
|
185
|
-
"level": "medium"
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
子 agent 上下文中额外字段:
|
|
191
|
-
|
|
192
|
-
```json
|
|
193
|
-
{
|
|
194
|
-
"agent_id": "subagent_xyz",
|
|
195
|
-
"agent_type": "Explore"
|
|
78
|
+
"last_assistant_message": "最后一条 assistant 回复"
|
|
196
79
|
}
|
|
197
80
|
```
|
|
198
81
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
Claude transcript 常见 assistant 形态:
|
|
202
|
-
|
|
203
|
-
```json
|
|
204
|
-
{"role":"assistant","content":[{"type":"text","text":"..."}]}
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
或:
|
|
208
|
-
|
|
209
|
-
```json
|
|
210
|
-
{"message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
## 当前脚本策略
|
|
214
|
-
|
|
215
|
-
`configs/hook-speak.sh` 当前入口:
|
|
216
|
-
|
|
217
|
-
```bash
|
|
218
|
-
input="${2:-}"
|
|
219
|
-
if [[ -z "$input" ]]; then
|
|
220
|
-
input=$(cat)
|
|
221
|
-
fi
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
含义:
|
|
225
|
-
|
|
226
|
-
- Codex `notify`:读 `$2`
|
|
227
|
-
- Claude / Codex Stop Hook:读 stdin
|
|
228
|
-
- 如果 Codex 的 `notify` 和 `Stop` 同时启用,脚本会按 `turn_id` 去重,避免同一回合播两次
|
|
229
|
-
|
|
230
|
-
Codex 文本字段优先级(源码确认):
|
|
231
|
-
|
|
232
|
-
```js
|
|
233
|
-
payload["last-assistant-message"] // notify: kebab-case
|
|
234
|
-
payload.last_assistant_message // Stop Hook: snake_case
|
|
235
|
-
payload.lastAssistantMessage
|
|
236
|
-
payload.message
|
|
237
|
-
payload.lastMessage
|
|
238
|
-
payload.transcript_path
|
|
239
|
-
payload.transcriptPath
|
|
240
|
-
payload["transcript-path"]
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
Claude Code 文本字段优先级(官方文档):
|
|
244
|
-
|
|
245
|
-
```js
|
|
246
|
-
payload.transcript_path // 官方支持的唯一方式
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
> **注**:Claude Code Stop Hook 官方 payload 中**没有 `last_assistant_message` 字段**,这是与 Codex 的本质区别。
|
|
250
|
-
|
|
251
|
-
## 为什么不能只读 stdin
|
|
252
|
-
|
|
253
|
-
因为 Codex `notify` 不走 stdin。只读 stdin 会导致:
|
|
254
|
-
|
|
255
|
-
```text
|
|
256
|
-
TEXT_LEN: 0
|
|
257
|
-
SPOKE: SKIP
|
|
258
|
-
```
|
|
259
|
-
|
|
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 文本真正出现再播,避免这个时序窗。
|
|
82
|
+
Claude Code 官方文档只列出 `transcript_path`,但实际 payload **包含 `last_assistant_message`**(实测确认)。优先用 direct 字段,无需读 transcript。
|
|
271
83
|
|
|
272
|
-
|
|
84
|
+
## 历史演进
|
|
273
85
|
|
|
274
|
-
|
|
86
|
+
- v1(250 行):transcript 轮询 + turn_id 去重 + state file + text hash。复杂度高,`session_id` 做去重 key 导致同一 session 只播第一条。
|
|
87
|
+
- v2(53 行):省略去重和 transcript 轮询,但 Claude/Codex 分开写提取逻辑。
|
|
88
|
+
- v3(当前,51 行):统一提取,一行覆盖所有来源。
|
package/main.go
CHANGED
|
@@ -40,7 +40,6 @@ var errAlreadyRunning = errors.New("iSpeak already running")
|
|
|
40
40
|
type StreamPlayer interface {
|
|
41
41
|
Write(audio []byte) error
|
|
42
42
|
CloseAndWait() error
|
|
43
|
-
Abort() error
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
// 最简单的播放器:channel 队列,串行播报
|
|
@@ -55,25 +54,31 @@ type job struct {
|
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
func NewPlayer() *Player {
|
|
58
|
-
p := &Player{ch: make(chan job,
|
|
57
|
+
p := &Player{ch: make(chan job, 1)}
|
|
59
58
|
go p.loop()
|
|
60
59
|
return p
|
|
61
60
|
}
|
|
62
61
|
|
|
63
62
|
func (p *Player) Submit(text string, voice VoiceInfo, cfg Config) {
|
|
64
63
|
log.Printf("TTS: %s", text)
|
|
64
|
+
// 丢弃队列中的旧消息,只保留最新
|
|
65
|
+
select {
|
|
66
|
+
case <-p.ch:
|
|
67
|
+
default:
|
|
68
|
+
}
|
|
65
69
|
p.ch <- job{text, voice, cfg}
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
func (p *Player) loop() {
|
|
73
|
+
player, err := newDefaultStreamPlayer()
|
|
74
|
+
if err != nil {
|
|
75
|
+
log.Printf("启动播放器失败: %v", err)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
defer player.CloseAndWait()
|
|
79
|
+
|
|
69
80
|
for j := range p.ch {
|
|
70
|
-
player, err := newDefaultStreamPlayer()
|
|
71
|
-
if err != nil {
|
|
72
|
-
log.Printf("启动播放器失败: %v", err)
|
|
73
|
-
continue
|
|
74
|
-
}
|
|
75
81
|
p.play(j, player)
|
|
76
|
-
_ = player.CloseAndWait()
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
|
|
@@ -231,7 +236,7 @@ func synthesizeStream(ctx context.Context, cfg Config, text string, voice *Voice
|
|
|
231
236
|
return fmt.Errorf("http request: %w", err)
|
|
232
237
|
}
|
|
233
238
|
if resp.StatusCode != 200 {
|
|
234
|
-
io.Copy(io.Discard, resp.Body)
|
|
239
|
+
io.Copy(io.Discard, resp.Body)
|
|
235
240
|
resp.Body.Close()
|
|
236
241
|
return fmt.Errorf("http status %d", resp.StatusCode)
|
|
237
242
|
}
|
|
@@ -240,28 +245,6 @@ func synthesizeStream(ctx context.Context, cfg Config, text string, voice *Voice
|
|
|
240
245
|
return parseSSEStream(resp.Body, onAudio)
|
|
241
246
|
}
|
|
242
247
|
|
|
243
|
-
// 解析 SSE 流,提取 base64 音频数据
|
|
244
|
-
func parseSSE(r io.Reader) ([]byte, error) {
|
|
245
|
-
var chunks [][]byte
|
|
246
|
-
if err := parseSSEStream(r, func(audio []byte) error {
|
|
247
|
-
chunk := append([]byte(nil), audio...)
|
|
248
|
-
chunks = append(chunks, chunk)
|
|
249
|
-
return nil
|
|
250
|
-
}); err != nil {
|
|
251
|
-
return nil, err
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
total := 0
|
|
255
|
-
for _, c := range chunks {
|
|
256
|
-
total += len(c)
|
|
257
|
-
}
|
|
258
|
-
result := make([]byte, 0, total)
|
|
259
|
-
for _, c := range chunks {
|
|
260
|
-
result = append(result, c...)
|
|
261
|
-
}
|
|
262
|
-
return result, nil
|
|
263
|
-
}
|
|
264
|
-
|
|
265
248
|
func parseSSEStream(r io.Reader, onAudio func([]byte) error) error {
|
|
266
249
|
audioChunks := 0
|
|
267
250
|
reader := bufio.NewReaderSize(r, 64*1024)
|
package/package.json
CHANGED
package/scripts/ispeak
CHANGED