@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.
@@ -1,122 +1,199 @@
1
1
  #!/bin/bash
2
- # Stop Hook: 只播报本次停止时的最后一条 assistant 回复
3
- # iAgent 调用 Claude 时设 ISPEAK_SKIP=1,此时跳过(iAgent 自己播)
2
+ # Claude Code / Codex 共用播报 Hook:
3
+ # 只取最后一条 assistant 回复,加 `{source:<name>}` 前缀后发给 ispeakd。
4
4
  [[ "$ISPEAK_SKIP" == "1" ]] && exit 0
5
5
 
6
- # 来源参数: claude 或 codex
7
6
  SOURCE="${1:-claude}"
8
-
9
7
  SOCK="$HOME/.config/iSpeak/ispeak.sock"
10
8
  LOG="$HOME/.config/iSpeak/hook.log"
9
+ STATE_FILE="$HOME/.config/iSpeak/hook.last"
10
+
11
+ # Codex `notify` 会把 JSON 作为最后一个参数传入;
12
+ # Claude/Claude 风格 Stop Hook 会把 JSON 写到 stdin。
13
+ input="${2:-}"
14
+ if [[ -z "$input" ]]; then
15
+ input=$(cat)
16
+ fi
17
+ input_file=$(mktemp)
18
+ trap 'rm -f "$input_file"' EXIT
19
+ printf "%s" "$input" > "$input_file"
20
+
21
+ result=$(SOURCE="$SOURCE" HOOK_INPUT_FILE="$input_file" HOOK_STATE_FILE="$STATE_FILE" node <<'NODE' 2>/dev/null
22
+ const fs = require("fs");
23
+ const crypto = require("crypto");
24
+
25
+ (() => {
26
+ const input = readFile(process.env.HOOK_INPUT_FILE || "");
27
+ 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
+
34
+ if (!result.text) {
35
+ return;
36
+ }
37
+
38
+ if (stateFile && result.turnId) {
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
+ }
11
46
 
12
- input=$(cat)
13
-
14
- json_value() {
15
- local key="$1"
16
- if command -v node >/dev/null 2>&1; then
17
- printf "%s" "$input" | node -e '
18
- const key = process.argv[1];
19
- let input = "";
20
- process.stdin.setEncoding("utf8");
21
- process.stdin.on("data", chunk => input += chunk);
22
- process.stdin.on("end", () => {
23
- try {
24
- const value = JSON.parse(input)[key];
25
- if (typeof value === "string") process.stdout.write(value);
26
- } catch (_) {}
27
- });
28
- ' "$key"
29
- return
30
- fi
31
-
32
- printf "%s" "$input" | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p"
47
+ process.stdout.write(result.text);
48
+ })();
49
+
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 ? lastAssistantFromTranscript(transcript, "claude") : { text: "", turnId: extractTurnId(payload) };
33
56
  }
34
57
 
35
- extract_last_assistant_text() {
36
- local transcript="$1"
37
-
38
- if command -v node >/dev/null 2>&1; then
39
- node -e '
40
- const fs = require("fs");
41
- const file = process.argv[1];
42
- let last = "";
43
-
44
- function collectText(content) {
45
- const out = [];
46
- if (typeof content === "string") {
47
- return content;
48
- }
49
- if (!Array.isArray(content)) return "";
50
- for (const item of content) {
51
- if (item && typeof item.text === "string") out.push(item.text);
52
- }
53
- return out.join(" ");
54
- }
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
+ function readFile(file) {
77
+ try {
78
+ return fs.readFileSync(file, "utf8");
79
+ } catch {
80
+ return "";
81
+ }
82
+ }
55
83
 
56
- for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
57
- if (!line.trim()) continue;
58
- try {
59
- const event = JSON.parse(line);
60
- if (event.role === "assistant") last = collectText(event.content) || last;
61
- if (event.message && event.message.role === "assistant") last = collectText(event.message.content) || last;
62
- } catch (_) {}
84
+ 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)) return "";
102
+ return content
103
+ .map(item => item && typeof item.text === "string" ? item.text : "")
104
+ .filter(Boolean)
105
+ .join(" ");
106
+ }
107
+
108
+ function lastAssistantFromTranscript(file, source) {
109
+ let data = "";
110
+ try {
111
+ data = fs.readFileSync(file, "utf8");
112
+ } catch {
113
+ return "";
114
+ }
115
+
116
+ let last = "";
117
+ let turnId = "";
118
+ for (const line of data.split(/\r?\n/)) {
119
+ if (!line.trim()) continue;
120
+ const event = parseJSON(line);
121
+ if (!event) continue;
122
+
123
+ if (source === "claude") {
124
+ if (event.role === "assistant") {
125
+ last = collectText(event.content) || last;
63
126
  }
64
- process.stdout.write(last);
65
- ' "$transcript" 2>/dev/null
66
- return
67
- fi
68
-
69
- awk '
70
- {
71
- if (match($0, /"role"[[:space:]]*:[[:space:]]*"assistant"/)) {
72
- if (match($0, /"content"[[:space:]]*:[[:space:]]*\[/)) {
73
- gsub(/[^{]*\[/, "", $0)
74
- gsub(/\].*/, "", $0)
75
- msg = ""
76
- while (match($0, /"text"[[:space:]]*:[[:space:]]*"[^"]*"/)) {
77
- t = substr($0, RSTART, RLENGTH)
78
- gsub(/"text"[[:space:]]*:[[:space:]]*"/, "", t)
79
- gsub(/"$/, "", t)
80
- if (t != "") msg = msg " " t
81
- $0 = substr($0, RSTART + RLENGTH)
82
- }
83
- if (msg != "") last = msg
84
- } else if (match($0, /"content"[[:space:]]*:[[:space:]]*"[^"]*"/)) {
85
- t = substr($0, RSTART, RLENGTH)
86
- gsub(/"content"[[:space:]]*:[[:space:]]*"/, "", t)
87
- gsub(/"$/, "", t)
88
- if (t != "") last = t
89
- }
127
+ if (event.message && event.message.role === "assistant") {
128
+ last = collectText(event.message.content) || last;
90
129
  }
91
130
  }
92
- END {
93
- gsub(/^[[:space:]]+|[[:space:]]+$/, "", last)
94
- if (last != "") print last
131
+
132
+ if (source === "codex" &&
133
+ event.type === "response_item" &&
134
+ event.payload &&
135
+ event.payload.type === "message" &&
136
+ event.payload.role === "assistant"
137
+ ) {
138
+ last = collectText(event.payload.content) || last;
139
+ turnId = turnId || extractTurnId(event) || extractTurnId(event.payload);
95
140
  }
96
- ' "$transcript" 2>/dev/null
141
+ }
142
+ return { text: last, turnId };
97
143
  }
98
144
 
99
- # stdin JSON 提取最后一条消息;没有时再从 transcript 取最后一条 assistant
100
- transcript=$(json_value "transcript_path")
101
- last_msg=$(json_value "last_assistant_message")
145
+ function extractTurnId(payload) {
146
+ return firstString(
147
+ payload.turn_id,
148
+ payload.turnId,
149
+ payload["turn-id"],
150
+ payload.session_id,
151
+ payload.sessionId,
152
+ payload["session-id"],
153
+ payload.thread_id,
154
+ payload.threadId,
155
+ payload["thread-id"]
156
+ );
157
+ }
158
+
159
+ function isDuplicateTurn(stateFile, source, turnId) {
160
+ const current = `${source}:${turnId}`;
161
+ try {
162
+ return fs.readFileSync(stateFile, "utf8").trim() === current;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ function saveTurn(stateFile, source, turnId, text) {
169
+ const current = `${source}:${turnId || textHash(text)}`;
170
+ try {
171
+ fs.mkdirSync(require("path").dirname(stateFile), { recursive: true });
172
+ fs.writeFileSync(stateFile, current, "utf8");
173
+ } catch {
174
+ // 去重失败不影响播报。
175
+ }
176
+ }
177
+
178
+ function textHash(text) {
179
+ return crypto.createHash("sha1").update(text, "utf8").digest("hex");
180
+ }
102
181
 
103
- all_text="$last_msg"
182
+ NODE
183
+ )
104
184
 
105
- # Claude Code 部分版本不提供 last_assistant_message,此时只取 transcript 最后一条 assistant。
106
- if [[ -z "$all_text" && -n "$transcript" && -f "$transcript" ]]; then
107
- extra=$(extract_last_assistant_text "$transcript")
108
- if [[ -n "$extra" ]]; then
109
- all_text="$extra"
110
- fi
185
+ if [[ "$ISPEAK_HOOK_PRINT_TEXT" == "1" ]]; then
186
+ printf "%s" "$result"
187
+ exit 0
111
188
  fi
112
189
 
113
190
  echo "=== $(date) ===" >> "$LOG"
114
191
  echo "SOURCE: $SOURCE" >> "$LOG"
115
- echo "TEXT_LEN: ${#all_text}" >> "$LOG"
116
- echo "PREVIEW: ${all_text:0:150}" >> "$LOG"
192
+ echo "TEXT_LEN: ${#result}" >> "$LOG"
193
+ echo "PREVIEW: ${result:0:150}" >> "$LOG"
117
194
 
118
- if [[ -n "$all_text" && -S "$SOCK" ]]; then
119
- printf "{source:%s}%s" "$SOURCE" "$all_text" | nc -U -w5 "$SOCK" 2>> "$LOG"
195
+ if [[ -n "$result" && -S "$SOCK" ]]; then
196
+ printf "{source:%s}%s" "$SOURCE" "$result" | nc -U -w5 "$SOCK" 2>> "$LOG"
120
197
  echo "SPOKE: OK" >> "$LOG"
121
198
  else
122
199
  echo "SPOKE: SKIP" >> "$LOG"