@xdfnet/ispeak 1.6.8 → 1.6.10

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 ADDED
@@ -0,0 +1,103 @@
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
4
+
5
+ ## 项目概述
6
+
7
+ iSpeak — 字节跳动 TTS 本地播报服务。守护进程 `ispeakd` 监听 Unix Socket,接收文本后调用火山引擎 TTS 流式 API,边合成边播放。
8
+
9
+ ## 常用命令
10
+
11
+ ```bash
12
+ make build # 编译 ispeakd
13
+ make install # 安装 + 启动 launchd 服务
14
+ make deploy # 同 install
15
+ make uninstall # 卸载(停止服务 + 删除文件)
16
+ make clean # 清理编译产物
17
+ make help # 显示帮助
18
+ ```
19
+
20
+ ## 命令行测试约定
21
+
22
+ - 测试 Claude:`claude -p "你好"`
23
+ - 测试 Codex:`codex exec "你好"`
24
+
25
+ ## 架构
26
+
27
+ ```
28
+ ispeak (CLI, bash)
29
+ └─ nc -U ~/.config/iSpeak/ispeak.sock
30
+ └─ ispeakd (Go daemon)
31
+ ├─ Task Engine (任务仓库)
32
+ │ └─ pending FIFO
33
+ └─ transactionWorker (single)
34
+ └─ pending -> running -> delete
35
+ └─ SSE PCM chunk -> AVAudioEngine
36
+ ```
37
+
38
+ - **Socket**: `~/.config/iSpeak/ispeak.sock`
39
+ - **日志**: `~/.config/iSpeak/ispeak.log` (lumberjack 轮转, 10MB/份, 保留3份)
40
+ - **Temp**: 进程级 tempDir,退出时清理
41
+ - **Launchd PLIST**: `~/Library/LaunchAgents/com.ispeak.plist`
42
+
43
+ ## 核心文件
44
+
45
+ - `main.go` — 守护进程、任务引擎、TTS 流式请求、SSE 解析、流式播放
46
+ - `avaudioengine_player_darwin.go` — macOS 原生 `AVAudioEngine` PCM 播放器
47
+ - `clean_text.go` — TTS 播报文本清洗
48
+ - `main_test.go` — 任务引擎关键行为测试
49
+ - `scripts/ispeak` — CLI 入口,通过 nc 发送文本到 socket
50
+ - `configs/hook-speak.sh` — Claude/Codex Hook,bash + Node 解析输入
51
+
52
+ ## 消息格式
53
+
54
+ CLI 与 daemon 通过 socket 传输原始文本,支持音色前缀:
55
+
56
+ ```
57
+ {source:claude}文本 → 使用 claude 来源音色
58
+ {source:codex}文本 → 使用 codex 来源音色
59
+ 文本 → 使用默认音色
60
+ ```
61
+
62
+ ## 任务策略(节省 TTS 费用)
63
+
64
+ 新消息到达时:
65
+ 1. 删除所有 `pending` 任务(未开始)
66
+ 2. 不打断当前 `running` 事务
67
+ 3. 创建新任务并进入 `pending`
68
+
69
+ **任务状态流转:**
70
+ ```
71
+ pending → running → delete
72
+ ```
73
+
74
+ ## 失败策略
75
+
76
+ - 流式合成/播放失败:直接删除任务,不重试,避免重复播报
77
+
78
+ ## 配置
79
+
80
+ `~/.config/iSpeak/config.json`:
81
+
82
+ ```json
83
+ {
84
+ "apiKey": "...",
85
+ "endpoint": "https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse",
86
+ "defaultVoice": { "voice_type": "zh_female_mizai_uranus_bigtts", "resourceId": "seed-tts-2.0" },
87
+ "sourceVoices": {
88
+ "claude": { "voice_type": "zh_female_tianmeitaozi_uranus_bigtts", "resourceId": "seed-tts-2.0" },
89
+ "codex": { "voice_type": "zh_male_shaonianzixin_uranus_bigtts", "resourceId": "seed-tts-2.0" }
90
+ }
91
+ }
92
+ ```
93
+
94
+ ## 稳定性设计
95
+
96
+ - 单 transaction worker,合成与播放同链路,降低首播延迟
97
+ - 关键 goroutine 有 `panic recover`
98
+ - 配置热更新(mtime 缓存 + 自动重载)
99
+ - TTS HTTP Client 复用,减少连接开销
100
+ - 主链路使用 macOS 原生 `AVAudioEngine` 播放 PCM
101
+ - 播放失败直接删除任务,不重试
102
+ - 日志轮转,防止文件过大
103
+ - 进程级 temp 目录,退出时自动清理
package/CLAUDE.md ADDED
@@ -0,0 +1,9 @@
1
+ # CLAUDE.md
2
+
3
+ 这个仓库的共享工程约定、命令、架构和配置,统一看 [AGENTS.md](/Users/admin/iCode/iSpeak/AGENTS.md)。
4
+
5
+ 这里仅补充 Claude Code 相关的最小约定:
6
+
7
+ - `configs/hook-speak.sh` 是 Claude/Codex 共用 hook
8
+ - `{source:claude}` 会走 Claude 音色
9
+ - 其余行为与 [AGENTS.md](/Users/admin/iCode/iSpeak/AGENTS.md) 保持一致
package/Makefile ADDED
@@ -0,0 +1,120 @@
1
+ .PHONY: build test pack release push install deploy uninstall clean help
2
+
3
+ VERSION := 1.6.9
4
+ TAG := v$(VERSION)
5
+ NPM_PKG := @xdfnet/ispeak
6
+ BIN := build/ispeakd
7
+ BIN_DIR := $(HOME)/.local/bin
8
+ DST := $(BIN_DIR)/ispeakd
9
+ PLIST := $(HOME)/Library/LaunchAgents/com.ispeak.plist
10
+ LEGACY_PLIST := $(HOME)/Library/LaunchAgents/com.iSpeak.plist
11
+ CONFIG := $(HOME)/.config/iSpeak
12
+ LOG := $(HOME)/.config/iSpeak/ispeak.log
13
+ CLI_SRC := scripts/ispeak
14
+ HOOK_SRC := configs/hook-speak.sh
15
+ PLIST_SRC := configs/com.ispeak.plist
16
+ CONFIG_SRC := configs/config.example.json
17
+
18
+ help:
19
+ @echo "iSpeak $(VERSION)"
20
+ @echo ""
21
+ @echo " make build # 编译 ispeakd"
22
+ @echo " make test # 运行 Go 测试、race 测试、构建、npm 打包预检"
23
+ @echo " make release # 推送 GitHub tag 并发布 npm latest"
24
+ @echo " make push # 同 release"
25
+ @echo " make install # 安装并启动服务(首次运行会部署配置和 hook)"
26
+ @echo " make deploy # 同 install"
27
+ @echo " make uninstall # 卸载(停止服务 + 删除文件)"
28
+ @echo " make clean # 清理编译产物"
29
+
30
+ build:
31
+ @mkdir -p build
32
+ @go build -ldflags="-s -w" -o $(BIN) .
33
+ @echo "编译完成: $(BIN)"
34
+
35
+ test:
36
+ @go test -count=1 ./...
37
+ @go test -race -count=1 ./...
38
+ @go build ./...
39
+ @bash scripts/test-hook-speak.sh
40
+ @npm pack --dry-run
41
+
42
+ pack:
43
+ @npm pack --dry-run
44
+
45
+ release: test
46
+ @if [ -n "$$(git status --porcelain)" ]; then \
47
+ echo "工作区不干净,请先提交或暂存改动"; \
48
+ git status --short; \
49
+ exit 1; \
50
+ fi
51
+ @if npm view $(NPM_PKG)@$(VERSION) version >/dev/null 2>&1; then \
52
+ echo "npm 版本已存在: $(NPM_PKG)@$(VERSION)"; \
53
+ exit 1; \
54
+ fi
55
+ @if git rev-parse "$(TAG)" >/dev/null 2>&1; then \
56
+ echo "tag 已存在: $(TAG)"; \
57
+ else \
58
+ git tag "$(TAG)"; \
59
+ fi
60
+ @git push origin HEAD
61
+ @git push origin "$(TAG)"
62
+ @npm publish --access public
63
+ @echo "已发布: $(NPM_PKG)@$(VERSION) / $(TAG)"
64
+
65
+ push: release
66
+
67
+ install: build
68
+ @# 停止旧服务
69
+ @launchctl unload $(LEGACY_PLIST) 2>/dev/null || true
70
+ @launchctl unload $(PLIST) 2>/dev/null || true
71
+ @rm -f $(LEGACY_PLIST)
72
+ @# 安装二进制和 CLI
73
+ @mkdir -p $(BIN_DIR)
74
+ @install -m 0755 $(BIN) $(DST)
75
+ @install -m 0755 $(CURDIR)/$(CLI_SRC) $(BIN_DIR)/ispeak
76
+ @# 部署配置文件(首次不覆盖已有)
77
+ @mkdir -p $(CONFIG)
78
+ @if [ ! -f $(CONFIG)/config.json ]; then \
79
+ cp $(CONFIG_SRC) $(CONFIG)/config.json; \
80
+ echo "配置文件已创建: $(CONFIG)/config.json"; \
81
+ else \
82
+ echo "配置文件已存在: $(CONFIG)/config.json"; \
83
+ fi
84
+ @if grep -q '"endpoint"[[:space:]]*:[[:space:]]*"https://openspeech.bytedance.com/api/v3/tts/unidirectional"' $(CONFIG)/config.json; then \
85
+ cp $(CONFIG)/config.json $(CONFIG)/config.json.bak; \
86
+ perl -pi -e 's|"https://openspeech.bytedance.com/api/v3/tts/unidirectional"|"https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse"|g' $(CONFIG)/config.json; \
87
+ echo "配置 endpoint 已迁移到 SSE,旧配置备份: $(CONFIG)/config.json.bak"; \
88
+ fi
89
+ @# 部署 hook 脚本(覆盖安装;如有本地改动先备份)
90
+ @if [ -f $(CONFIG)/hook-speak.sh ] && ! cmp -s $(HOOK_SRC) $(CONFIG)/hook-speak.sh; then \
91
+ cp $(CONFIG)/hook-speak.sh $(CONFIG)/hook-speak.sh.bak; \
92
+ echo "旧 Hook 已备份: $(CONFIG)/hook-speak.sh.bak"; \
93
+ fi
94
+ @cp $(HOOK_SRC) $(CONFIG)/hook-speak.sh
95
+ @chmod +x $(CONFIG)/hook-speak.sh
96
+ @echo "Hook 脚本已安装: $(CONFIG)/hook-speak.sh"
97
+ @# 安装 launchd plist
98
+ @sed 's|BINARY_PATH_PLACEHOLDER|$(DST)|' $(PLIST_SRC) > $(PLIST)
99
+ @# 启动
100
+ @launchctl load $(PLIST)
101
+ @sleep 0.5
102
+ @# 自检
103
+ @$(BIN_DIR)/ispeak status && echo "" && echo "安装成功!" || { echo "安装失败,请检查日志: $(LOG)"; exit 1; }
104
+
105
+ deploy: install
106
+
107
+ uninstall:
108
+ @echo "停止服务..."
109
+ @launchctl unload $(LEGACY_PLIST) 2>/dev/null || true
110
+ @launchctl unload $(PLIST) 2>/dev/null || true
111
+ @rm -f $(LEGACY_PLIST)
112
+ @rm -f $(PLIST)
113
+ @echo "删除文件..."
114
+ @rm -f $(BIN_DIR)/ispeakd $(BIN_DIR)/ispeak
115
+ @echo "保留配置目录: $(CONFIG)"
116
+ @echo "卸载完成"
117
+
118
+ clean:
119
+ @rm -rf build
120
+ @echo "清理完成"
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # iSpeak
2
2
 
3
- ![Version](https://img.shields.io/badge/version-1.6.8-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)
@@ -0,0 +1,315 @@
1
+ //go:build darwin && cgo
2
+
3
+ package main
4
+
5
+ /*
6
+ #cgo CFLAGS: -x objective-c -fblocks
7
+ #cgo LDFLAGS: -framework AVFoundation -framework Foundation
8
+
9
+ #include <AVFoundation/AVFoundation.h>
10
+ #include <pthread.h>
11
+ #include <stdint.h>
12
+ #include <stdio.h>
13
+ #include <stdlib.h>
14
+ #include <string.h>
15
+
16
+ typedef struct {
17
+ AVAudioEngine *engine;
18
+ AVAudioPlayerNode *node;
19
+ AVAudioFormat *format;
20
+ pthread_mutex_t mu;
21
+ pthread_cond_t cond;
22
+ int pending;
23
+ int closing;
24
+ int started;
25
+ } AVNativePlayer;
26
+
27
+ static char *av_make_error(const char *prefix, NSError *error) {
28
+ const char *detail = "";
29
+ if (error != nil && [error localizedDescription] != nil) {
30
+ detail = [[error localizedDescription] UTF8String];
31
+ }
32
+ char buffer[512];
33
+ snprintf(buffer, sizeof(buffer), "%s: %s", prefix, detail);
34
+ return strdup(buffer);
35
+ }
36
+
37
+ static int av_player_create(double sampleRate, unsigned int channels, AVNativePlayer **out, char **err) {
38
+ if (out == NULL) {
39
+ if (err) *err = strdup("av_player_create: out is nil");
40
+ return -1;
41
+ }
42
+ *out = NULL;
43
+
44
+ @autoreleasepool {
45
+ AVNativePlayer *player = (AVNativePlayer *)calloc(1, sizeof(AVNativePlayer));
46
+ if (player == NULL) {
47
+ if (err) *err = strdup("av_player_create: calloc failed");
48
+ return -1;
49
+ }
50
+
51
+ if (pthread_mutex_init(&player->mu, NULL) != 0) {
52
+ if (err) *err = strdup("av_player_create: mutex init failed");
53
+ free(player);
54
+ return -1;
55
+ }
56
+ if (pthread_cond_init(&player->cond, NULL) != 0) {
57
+ if (err) *err = strdup("av_player_create: cond init failed");
58
+ pthread_mutex_destroy(&player->mu);
59
+ free(player);
60
+ return -1;
61
+ }
62
+
63
+ player->engine = [[AVAudioEngine alloc] init];
64
+ player->node = [[AVAudioPlayerNode alloc] init];
65
+ player->format = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32 sampleRate:sampleRate channels:channels interleaved:NO];
66
+ if (player->engine == nil || player->node == nil || player->format == nil) {
67
+ if (err) *err = strdup("av_player_create: failed to allocate AVAudio objects");
68
+ [player->engine release];
69
+ [player->node release];
70
+ [player->format release];
71
+ pthread_cond_destroy(&player->cond);
72
+ pthread_mutex_destroy(&player->mu);
73
+ free(player);
74
+ return -1;
75
+ }
76
+
77
+ [player->engine attachNode:player->node];
78
+ [player->engine connect:player->node to:player->engine.mainMixerNode format:player->format];
79
+ [player->engine prepare];
80
+
81
+ NSError *error = nil;
82
+ if (![player->engine startAndReturnError:&error]) {
83
+ if (err) *err = av_make_error("AVAudioEngine start failed", error);
84
+ [player->node release];
85
+ [player->engine release];
86
+ [player->format release];
87
+ pthread_cond_destroy(&player->cond);
88
+ pthread_mutex_destroy(&player->mu);
89
+ free(player);
90
+ return -1;
91
+ }
92
+
93
+ [player->node play];
94
+ player->started = 1;
95
+ *out = player;
96
+ return 0;
97
+ }
98
+ }
99
+
100
+ static int av_player_write(AVNativePlayer *player, const void *data, size_t len, char **err) {
101
+ if (player == NULL || player->engine == nil || player->node == nil || player->format == nil) {
102
+ if (err) *err = strdup("av_player_write: player is closed");
103
+ return -1;
104
+ }
105
+ if (data == NULL || len == 0) {
106
+ return 0;
107
+ }
108
+
109
+ AVAudioFrameCount frames = (AVAudioFrameCount)(len / 2);
110
+ if (frames == 0) {
111
+ return 0;
112
+ }
113
+
114
+ AVAudioPCMBuffer *buffer = [[AVAudioPCMBuffer alloc] initWithPCMFormat:player->format frameCapacity:frames];
115
+ if (buffer == nil) {
116
+ if (err) *err = strdup("av_player_write: failed to allocate buffer");
117
+ return -1;
118
+ }
119
+ buffer.frameLength = frames;
120
+ float *dest = buffer.floatChannelData[0];
121
+ const int16_t *src = (const int16_t *)data;
122
+ for (AVAudioFrameCount i = 0; i < frames; i++) {
123
+ dest[i] = (float)src[i] / 32768.0f;
124
+ }
125
+
126
+ pthread_mutex_lock(&player->mu);
127
+ player->pending++;
128
+ pthread_mutex_unlock(&player->mu);
129
+
130
+ AVNativePlayer *captured = player;
131
+ [player->node scheduleBuffer:buffer completionHandler:^{
132
+ pthread_mutex_lock(&captured->mu);
133
+ if (captured->pending > 0) {
134
+ captured->pending--;
135
+ }
136
+ if (captured->closing && captured->pending == 0) {
137
+ pthread_cond_signal(&captured->cond);
138
+ }
139
+ pthread_mutex_unlock(&captured->mu);
140
+ }];
141
+
142
+ return 0;
143
+ }
144
+
145
+ static int av_player_close_and_dispose(AVNativePlayer *player, char **err) {
146
+ if (player == NULL) {
147
+ return 0;
148
+ }
149
+
150
+ pthread_mutex_lock(&player->mu);
151
+ player->closing = 1;
152
+ while (player->pending > 0) {
153
+ pthread_cond_wait(&player->cond, &player->mu);
154
+ }
155
+ pthread_mutex_unlock(&player->mu);
156
+
157
+ @autoreleasepool {
158
+ [player->node stop];
159
+ [player->engine stop];
160
+ [player->node release];
161
+ [player->engine release];
162
+ [player->format release];
163
+ }
164
+
165
+ pthread_cond_destroy(&player->cond);
166
+ pthread_mutex_destroy(&player->mu);
167
+ free(player);
168
+ return 0;
169
+ }
170
+
171
+ static int av_player_abort(AVNativePlayer *player, char **err) {
172
+ return av_player_close_and_dispose(player, err);
173
+ }
174
+
175
+ */
176
+ import "C"
177
+
178
+ import (
179
+ "errors"
180
+ "fmt"
181
+ "log"
182
+ "sync"
183
+ "unsafe"
184
+ )
185
+
186
+ const (
187
+ audioEngineSampleRate = 48000
188
+ audioEngineChannels = 1
189
+ audioEngineChunkSize = 32 * 1024
190
+ )
191
+
192
+ type avAudioEngineStreamPlayer struct {
193
+ mu sync.Mutex
194
+ ptr *C.AVNativePlayer
195
+ tail []byte
196
+ }
197
+
198
+ func newDefaultStreamPlayer() (StreamPlayer, error) {
199
+ var ptr *C.AVNativePlayer
200
+ var cerr *C.char
201
+ if C.av_player_create(C.double(audioEngineSampleRate), C.uint(audioEngineChannels), &ptr, &cerr) != 0 {
202
+ return nil, cError(cerr)
203
+ }
204
+ log.Printf("播放器模式: AVAudioEngine PCM 流式 (%d Hz, %d channel)", audioEngineSampleRate, audioEngineChannels)
205
+ return &avAudioEngineStreamPlayer{ptr: ptr}, nil
206
+ }
207
+
208
+ func (p *avAudioEngineStreamPlayer) Write(audio []byte) error {
209
+ if len(audio) == 0 {
210
+ return nil
211
+ }
212
+
213
+ p.mu.Lock()
214
+ defer p.mu.Unlock()
215
+
216
+ if p.ptr == nil {
217
+ return errors.New("AVAudioEngine player 已关闭")
218
+ }
219
+
220
+ data := audio
221
+ if len(p.tail) > 0 {
222
+ data = append(append([]byte(nil), p.tail...), audio...)
223
+ p.tail = nil
224
+ }
225
+
226
+ if len(data)%2 == 1 {
227
+ p.tail = append(p.tail[:0], data[len(data)-1])
228
+ data = data[:len(data)-1]
229
+ }
230
+
231
+ for len(data) > 0 {
232
+ n := audioEngineChunkSize
233
+ if n > len(data) {
234
+ n = len(data)
235
+ }
236
+ if n%2 == 1 {
237
+ n--
238
+ }
239
+ if n <= 0 {
240
+ break
241
+ }
242
+ if err := p.writeChunk(data[:n]); err != nil {
243
+ return err
244
+ }
245
+ data = data[n:]
246
+ }
247
+
248
+ return nil
249
+ }
250
+
251
+ func (p *avAudioEngineStreamPlayer) CloseAndWait() error {
252
+ p.mu.Lock()
253
+ defer p.mu.Unlock()
254
+ return p.closeLocked()
255
+ }
256
+
257
+ func (p *avAudioEngineStreamPlayer) Abort() error {
258
+ p.mu.Lock()
259
+ defer p.mu.Unlock()
260
+ return p.closeLocked()
261
+ }
262
+
263
+ func (p *avAudioEngineStreamPlayer) writeChunk(data []byte) error {
264
+ if len(data) == 0 {
265
+ return nil
266
+ }
267
+ if p.ptr == nil {
268
+ return errors.New("AVAudioEngine player 已关闭")
269
+ }
270
+
271
+ var cerr *C.char
272
+ if C.av_player_write(p.ptr, unsafe.Pointer(&data[0]), C.size_t(len(data)), &cerr) != 0 {
273
+ return cError(cerr)
274
+ }
275
+ return nil
276
+ }
277
+
278
+ func (p *avAudioEngineStreamPlayer) closeLocked() error {
279
+ if p.ptr == nil {
280
+ return nil
281
+ }
282
+
283
+ if len(p.tail) > 0 {
284
+ pad := append(append([]byte(nil), p.tail...), 0)
285
+ p.tail = nil
286
+ if err := p.writeChunk(pad); err != nil {
287
+ _ = p.disposeLocked()
288
+ return err
289
+ }
290
+ }
291
+
292
+ return p.disposeLocked()
293
+ }
294
+
295
+ func (p *avAudioEngineStreamPlayer) disposeLocked() error {
296
+ if p.ptr == nil {
297
+ return nil
298
+ }
299
+
300
+ var cerr *C.char
301
+ if C.av_player_close_and_dispose(p.ptr, &cerr) != 0 {
302
+ p.ptr = nil
303
+ return cError(cerr)
304
+ }
305
+ p.ptr = nil
306
+ return nil
307
+ }
308
+
309
+ func cError(cerr *C.char) error {
310
+ if cerr == nil {
311
+ return nil
312
+ }
313
+ defer C.free(unsafe.Pointer(cerr))
314
+ return fmt.Errorf("%s", C.GoString(cerr))
315
+ }
@@ -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"