@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.
- package/Docs/ARCHITECTURE.md +1 -1
- package/Docs/HOOK_TEXT_EXTRACTION.md +241 -0
- package/README.md +17 -14
- package/clean_text.go +153 -0
- package/configs/config.example.json +1 -1
- package/configs/hook-speak.sh +173 -96
- package/main.go +70 -379
- package/npm/postinstall.js +41 -2
- package/package.json +4 -2
- package/scripts/ispeak +1 -1
package/configs/hook-speak.sh
CHANGED
|
@@ -1,122 +1,199 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
#
|
|
3
|
-
#
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
141
|
+
}
|
|
142
|
+
return { text: last, turnId };
|
|
97
143
|
}
|
|
98
144
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
182
|
+
NODE
|
|
183
|
+
)
|
|
104
184
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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: ${#
|
|
116
|
-
echo "PREVIEW: ${
|
|
192
|
+
echo "TEXT_LEN: ${#result}" >> "$LOG"
|
|
193
|
+
echo "PREVIEW: ${result:0:150}" >> "$LOG"
|
|
117
194
|
|
|
118
|
-
if [[ -n "$
|
|
119
|
-
printf "{source:%s}%s" "$SOURCE" "$
|
|
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"
|