@micsushi/agent-hotline 0.5.1
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 +167 -0
- package/docs/README.md +30 -0
- package/docs/integrations/antigravity.md +62 -0
- package/docs/integrations/claude-code.md +48 -0
- package/docs/integrations/codex.md +48 -0
- package/docs/integrations/spoken-output.md +36 -0
- package/package.json +62 -0
- package/packages/backend/README.md +40 -0
- package/packages/backend/bin/agent-hotline-hook.js +11 -0
- package/packages/backend/bin/agent-hotline.js +138 -0
- package/packages/backend/package.json +20 -0
- package/packages/backend/skills/agent-hotline-spoken/SKILL.md +45 -0
- package/packages/backend/src/audio-cache-store.js +246 -0
- package/packages/backend/src/hook-command.js +320 -0
- package/packages/backend/src/hook-input-parser.js +671 -0
- package/packages/backend/src/installer.js +402 -0
- package/packages/backend/src/run-command.js +40 -0
- package/packages/backend/src/server.js +879 -0
- package/packages/backend/src/settings-store.js +191 -0
- package/packages/backend/src/speakable-filter.js +251 -0
- package/packages/backend/src/speech-queue-store.js +324 -0
- package/packages/backend/src/spool-store.js +80 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
const SOURCE_APPS = Object.freeze({
|
|
2
|
+
CODEX: "Codex",
|
|
3
|
+
CLAUDE: "Claude",
|
|
4
|
+
ANTIGRAVITY: "Antigravity",
|
|
5
|
+
UNKNOWN: "Unknown"
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const MAX_CODEX_SESSION_FILES = 3000;
|
|
9
|
+
|
|
10
|
+
function parseHookInput(text, deps = {}) {
|
|
11
|
+
if (typeof text !== "string") {
|
|
12
|
+
return skipResult("invalid_input", "Hook input must be a string.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const cleaned = text.replace(/^\uFEFF/, "").trim();
|
|
16
|
+
|
|
17
|
+
let payload;
|
|
18
|
+
try {
|
|
19
|
+
payload = JSON.parse(cleaned);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return skipResult("malformed_json", "Hook input is not valid JSON.", {
|
|
22
|
+
error: error.message
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const sourceApp = detectSourceApp(payload);
|
|
28
|
+
let extracted = extractAssistantText(payload);
|
|
29
|
+
|
|
30
|
+
// Claude Code's Stop hook carries no inline reply text, only transcript_path;
|
|
31
|
+
// fall back to the last assistant turn in the transcript so its responses can
|
|
32
|
+
// still be spoken.
|
|
33
|
+
if (!extracted.text) {
|
|
34
|
+
const fromTranscript = extractAssistantFromTranscript(payload);
|
|
35
|
+
if (fromTranscript) extracted = { text: fromTranscript, schema: "transcript" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!extracted.text) {
|
|
39
|
+
return skipResult("missing_assistant_text", "No assistant response text was found.", {
|
|
40
|
+
sourceApp,
|
|
41
|
+
payload
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const thread = extractThread(payload, sourceApp, deps);
|
|
46
|
+
const project = extractProject(payload, deps);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
action: "accept",
|
|
51
|
+
sourceApp,
|
|
52
|
+
assistantText: extracted.text,
|
|
53
|
+
schema: extracted.schema,
|
|
54
|
+
threadId: thread.threadId,
|
|
55
|
+
threadLabel: thread.threadLabel,
|
|
56
|
+
sessionName: extractSessionName(payload),
|
|
57
|
+
projectPath: project.projectPath,
|
|
58
|
+
projectName: project.projectName,
|
|
59
|
+
userMessages: extractUserMessages(payload, deps, extracted.text),
|
|
60
|
+
payload
|
|
61
|
+
};
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return skipResult("parser_error", "Hook input could not be normalized.", {
|
|
64
|
+
error: error.message,
|
|
65
|
+
sourceApp: detectSourceApp(payload),
|
|
66
|
+
payload
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function extractThread(payload, sourceApp, deps = {}) {
|
|
72
|
+
if (!isPlainObject(payload)) {
|
|
73
|
+
return { threadId: undefined, threadLabel: undefined };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const threadId =
|
|
77
|
+
firstString(
|
|
78
|
+
payload.session_id,
|
|
79
|
+
payload.sessionId,
|
|
80
|
+
payload.conversation_id,
|
|
81
|
+
payload.conversationId,
|
|
82
|
+
payload.thread_id,
|
|
83
|
+
payload.threadId
|
|
84
|
+
) || undefined;
|
|
85
|
+
|
|
86
|
+
const cwd = resolveCwd(payload, deps);
|
|
87
|
+
const folder = cwd ? pathBasename(resolveProjectRoot(cwd, deps)) : "";
|
|
88
|
+
const shortId = threadId ? threadId.slice(0, 8) : "";
|
|
89
|
+
const labelParts = [folder || sourceApp];
|
|
90
|
+
if (shortId) labelParts.push(shortId);
|
|
91
|
+
const threadLabel = labelParts.filter(Boolean).join(" - ") || undefined;
|
|
92
|
+
|
|
93
|
+
return { threadId, threadLabel };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Repo-root markers. The hook only reports the shell cwd, which drifts into
|
|
97
|
+
// subdirectories during a session (e.g. running a build from packages/desktop).
|
|
98
|
+
// Naively using the cwd basename then splinters one repo into several phantom
|
|
99
|
+
// "projects". Resolving up to the enclosing repo root keeps subdir work grouped
|
|
100
|
+
// under the real project.
|
|
101
|
+
const PROJECT_ROOT_MARKERS = [".git"];
|
|
102
|
+
|
|
103
|
+
function pathBasename(value) {
|
|
104
|
+
return (
|
|
105
|
+
String(value)
|
|
106
|
+
.replace(/[\\/]+$/, "")
|
|
107
|
+
.split(/[\\/]/)
|
|
108
|
+
.pop() || undefined
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveProjectRoot(cwd, deps = {}) {
|
|
113
|
+
const existsSync = deps.existsSync || require("fs").existsSync;
|
|
114
|
+
const path = require("path");
|
|
115
|
+
const normalized = String(cwd).replace(/[\\/]+$/, "");
|
|
116
|
+
if (!normalized) return normalized;
|
|
117
|
+
|
|
118
|
+
let current = normalized;
|
|
119
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
120
|
+
for (const marker of PROJECT_ROOT_MARKERS) {
|
|
121
|
+
let hit = false;
|
|
122
|
+
try {
|
|
123
|
+
hit = existsSync(path.join(current, marker));
|
|
124
|
+
} catch {
|
|
125
|
+
hit = false;
|
|
126
|
+
}
|
|
127
|
+
if (hit) return current;
|
|
128
|
+
}
|
|
129
|
+
const parent = path.dirname(current);
|
|
130
|
+
if (!parent || parent === current) break;
|
|
131
|
+
current = parent;
|
|
132
|
+
}
|
|
133
|
+
// No marker found (path missing, or not under a repo): keep the cwd as-is.
|
|
134
|
+
return normalized;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolve the working directory used for project/session grouping. Order:
|
|
138
|
+
// 1. inline payload field (normal Codex/Claude CLI Stop hooks ship this)
|
|
139
|
+
// 2. the transcript's own per-line `cwd` (Claude Code writes the real cwd on
|
|
140
|
+
// every JSONL entry, so SDK/editor-extension hooks that omit the top-level
|
|
141
|
+
// cwd can still be grouped -- and because this is the exact path, it merges
|
|
142
|
+
// with that project's CLI items instead of spawning a separate bucket)
|
|
143
|
+
// 3. the encoded ".../projects/<dir>/<sid>.jsonl" segment as a last-resort
|
|
144
|
+
// stable identity when the transcript can't be read.
|
|
145
|
+
// Returning "" here is what makes grouping.js fall back to `direct:<sourceApp>`
|
|
146
|
+
// (the "Claude" catch-all bucket), so we exhaust every signal before that.
|
|
147
|
+
function resolveCwd(payload, deps = {}) {
|
|
148
|
+
const direct = firstString(payload.cwd, payload.workspace, payload.project_dir);
|
|
149
|
+
if (direct) return direct;
|
|
150
|
+
const fromTranscript = cwdFromTranscript(payload, deps);
|
|
151
|
+
if (fromTranscript) return fromTranscript;
|
|
152
|
+
return projectDirFromTranscriptPath(payload);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function cwdFromTranscript(payload, deps = {}) {
|
|
156
|
+
const transcriptPath = firstString(payload.transcript_path, payload.transcriptPath);
|
|
157
|
+
if (!transcriptPath) return "";
|
|
158
|
+
const readFileSync = deps.readFileSync || require("fs").readFileSync;
|
|
159
|
+
let raw;
|
|
160
|
+
try {
|
|
161
|
+
raw = readFileSync(transcriptPath, "utf8");
|
|
162
|
+
} catch {
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
166
|
+
if (!line.trim()) continue;
|
|
167
|
+
try {
|
|
168
|
+
const obj = JSON.parse(line);
|
|
169
|
+
const cwd = firstString(obj.cwd, obj.workspace);
|
|
170
|
+
if (cwd) return cwd;
|
|
171
|
+
} catch {
|
|
172
|
+
// Skip non-JSON lines; keep scanning for the first entry that carries cwd.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return "";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Claude Code stores transcripts at "<home>/.claude/projects/<encoded>/<sid>.jsonl"
|
|
179
|
+
// where <encoded> is the cwd with separators/colon replaced by '-'. The encoding
|
|
180
|
+
// is lossy (a literal '-' in a folder name is indistinguishable from a separator),
|
|
181
|
+
// so we can't reconstruct the exact path -- but the encoded segment is stable, so
|
|
182
|
+
// using it verbatim still groups every item from that project together under one
|
|
183
|
+
// bucket instead of scattering into the generic harness catch-all.
|
|
184
|
+
function projectDirFromTranscriptPath(payload) {
|
|
185
|
+
const transcriptPath = firstString(payload.transcript_path, payload.transcriptPath);
|
|
186
|
+
if (!transcriptPath) return "";
|
|
187
|
+
const normalized = String(transcriptPath).replace(/\\/g, "/");
|
|
188
|
+
const match = normalized.match(/\/projects\/([^/]+)\/[^/]*$/i);
|
|
189
|
+
return match ? match[1] : "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function extractProject(payload, deps = {}) {
|
|
193
|
+
if (!isPlainObject(payload)) {
|
|
194
|
+
return { projectPath: undefined, projectName: undefined };
|
|
195
|
+
}
|
|
196
|
+
const cwd = resolveCwd(payload, deps);
|
|
197
|
+
if (!cwd) {
|
|
198
|
+
return { projectPath: undefined, projectName: undefined };
|
|
199
|
+
}
|
|
200
|
+
const root = resolveProjectRoot(cwd, deps);
|
|
201
|
+
return { projectPath: root, projectName: pathBasename(root) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function extractSessionName(payload) {
|
|
205
|
+
if (!isPlainObject(payload)) return undefined;
|
|
206
|
+
return firstString(payload.sessionName, payload.session_name, payload.thread_name) || undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// User-side prompt text, captured for display only (never spoken). Codex hands us
|
|
210
|
+
// the prompts inline as "input-messages"; Claude's Stop hook only points at a
|
|
211
|
+
// transcript file, so we read the trailing run of user turns from it. Best-effort:
|
|
212
|
+
// any failure yields [] so a missing/locked transcript never blocks playback.
|
|
213
|
+
function extractUserMessages(payload, deps = {}, assistantText = "") {
|
|
214
|
+
try {
|
|
215
|
+
if (!isPlainObject(payload)) return [];
|
|
216
|
+
|
|
217
|
+
const inline = payload["input-messages"] || payload.input_messages || payload.inputMessages;
|
|
218
|
+
if (Array.isArray(inline)) {
|
|
219
|
+
const out = inline
|
|
220
|
+
.map((entry) => (typeof entry === "string" ? entry.trim() : extractTextFromValue(entry)))
|
|
221
|
+
.filter(Boolean);
|
|
222
|
+
if (out.length) return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const prompt = firstString(
|
|
226
|
+
payload.prompt,
|
|
227
|
+
payload.user_prompt,
|
|
228
|
+
payload.userPrompt,
|
|
229
|
+
payload.user_message,
|
|
230
|
+
payload.userMessage,
|
|
231
|
+
payload.last_user_message,
|
|
232
|
+
payload.lastUserMessage,
|
|
233
|
+
payload.input
|
|
234
|
+
);
|
|
235
|
+
if (prompt) return [prompt];
|
|
236
|
+
|
|
237
|
+
const transcriptPath = firstString(payload.transcript_path, payload.transcriptPath);
|
|
238
|
+
if (transcriptPath) {
|
|
239
|
+
return readUserMessagesFromTranscript(transcriptPath, deps);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (detectSourceApp(payload) === SOURCE_APPS.CODEX) {
|
|
243
|
+
return readUserMessagesFromCodexSession(payload, assistantText, deps);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return [];
|
|
247
|
+
} catch {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readUserMessagesFromCodexSession(payload, assistantText, deps = {}) {
|
|
253
|
+
const threadId = firstString(
|
|
254
|
+
payload.session_id,
|
|
255
|
+
payload.sessionId,
|
|
256
|
+
payload.conversation_id,
|
|
257
|
+
payload.conversationId,
|
|
258
|
+
payload.thread_id,
|
|
259
|
+
payload.threadId
|
|
260
|
+
);
|
|
261
|
+
if (!threadId) return [];
|
|
262
|
+
|
|
263
|
+
const filePath = findCodexSessionFile(threadId, deps);
|
|
264
|
+
if (!filePath) return [];
|
|
265
|
+
|
|
266
|
+
const readFileSync = deps.readFileSync || require("fs").readFileSync;
|
|
267
|
+
let raw;
|
|
268
|
+
try {
|
|
269
|
+
raw = readFileSync(filePath, "utf8");
|
|
270
|
+
} catch {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const turns = [];
|
|
275
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
276
|
+
if (!line.trim()) continue;
|
|
277
|
+
let obj;
|
|
278
|
+
try {
|
|
279
|
+
obj = JSON.parse(line);
|
|
280
|
+
} catch {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const turn = codexMessageTurn(obj);
|
|
284
|
+
if (turn) turns.push(turn);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const assistantIndex = findAssistantTurnIndex(turns, assistantText);
|
|
288
|
+
if (assistantIndex <= 0) return [];
|
|
289
|
+
|
|
290
|
+
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
|
|
291
|
+
if (turns[i].role === "user" && turns[i].text) return [turns[i].text];
|
|
292
|
+
}
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function findAssistantTurnIndex(turns, assistantText) {
|
|
297
|
+
const target = normalizeForMatch(assistantText);
|
|
298
|
+
if (target) {
|
|
299
|
+
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
|
300
|
+
if (turns[i].role !== "assistant") continue;
|
|
301
|
+
const candidate = normalizeForMatch(turns[i].text);
|
|
302
|
+
if (candidate === target || candidate.includes(target) || target.includes(candidate)) {
|
|
303
|
+
return i;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return turns.map((turn) => turn.role).lastIndexOf("assistant");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function codexMessageTurn(obj) {
|
|
311
|
+
if (!isPlainObject(obj) || obj.type !== "response_item" || !isPlainObject(obj.payload)) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
const payload = obj.payload;
|
|
315
|
+
if (payload.type !== "message" || !["user", "assistant"].includes(payload.role)) return null;
|
|
316
|
+
const text = extractCodexMessageText(payload.content);
|
|
317
|
+
return text ? { role: payload.role, text } : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function extractCodexMessageText(content) {
|
|
321
|
+
if (typeof content === "string") return content.trim();
|
|
322
|
+
if (!Array.isArray(content)) return "";
|
|
323
|
+
const parts = [];
|
|
324
|
+
for (const part of content) {
|
|
325
|
+
if (typeof part === "string") {
|
|
326
|
+
parts.push(part.trim());
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (!isPlainObject(part)) continue;
|
|
330
|
+
if (typeof part.text === "string") parts.push(part.text.trim());
|
|
331
|
+
if (typeof part.output_text === "string") parts.push(part.output_text.trim());
|
|
332
|
+
}
|
|
333
|
+
return parts.filter(Boolean).join("\n\n").trim();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function findCodexSessionFile(threadId, deps = {}) {
|
|
337
|
+
if (deps.codexSessionFile) return deps.codexSessionFile;
|
|
338
|
+
|
|
339
|
+
const fs = deps.fs || require("fs");
|
|
340
|
+
const path = deps.path || require("path");
|
|
341
|
+
const roots = [];
|
|
342
|
+
if (deps.codexSessionsDir) roots.push(deps.codexSessionsDir);
|
|
343
|
+
const codexHome =
|
|
344
|
+
deps.codexHome ||
|
|
345
|
+
process.env.CODEX_HOME ||
|
|
346
|
+
path.join(process.env.USERPROFILE || require("os").homedir(), ".codex");
|
|
347
|
+
roots.push(path.join(codexHome, "sessions"));
|
|
348
|
+
|
|
349
|
+
for (const root of roots) {
|
|
350
|
+
let found = "";
|
|
351
|
+
let seen = 0;
|
|
352
|
+
const visit = (dir) => {
|
|
353
|
+
if (found || seen > MAX_CODEX_SESSION_FILES) return;
|
|
354
|
+
let entries;
|
|
355
|
+
try {
|
|
356
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
357
|
+
} catch {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
for (const entry of entries) {
|
|
361
|
+
if (found || seen > MAX_CODEX_SESSION_FILES) return;
|
|
362
|
+
const full = path.join(dir, entry.name);
|
|
363
|
+
if (entry.isDirectory()) {
|
|
364
|
+
visit(full);
|
|
365
|
+
} else if (entry.isFile()) {
|
|
366
|
+
seen += 1;
|
|
367
|
+
if (entry.name.includes(threadId) && entry.name.endsWith(".jsonl")) {
|
|
368
|
+
found = full;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
visit(root);
|
|
375
|
+
if (found) return found;
|
|
376
|
+
}
|
|
377
|
+
return "";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function readUserMessagesFromTranscript(transcriptPath, deps = {}) {
|
|
381
|
+
const readFileSync = deps.readFileSync || require("fs").readFileSync;
|
|
382
|
+
let raw;
|
|
383
|
+
try {
|
|
384
|
+
raw = readFileSync(transcriptPath, "utf8");
|
|
385
|
+
} catch {
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Reduce the transcript to meaningful events, collapsing tool roundtrips: a
|
|
390
|
+
// tool_result-only user entry yields no text (skipped), and tool-use assistant
|
|
391
|
+
// blocks carry no prose. A real turn looks like one human prompt followed by the
|
|
392
|
+
// assistant working (often several prose + tool entries) before its final reply.
|
|
393
|
+
const seq = [];
|
|
394
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
395
|
+
if (!line.trim()) continue;
|
|
396
|
+
let obj;
|
|
397
|
+
try {
|
|
398
|
+
obj = JSON.parse(line);
|
|
399
|
+
} catch {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const role = obj.type || (isPlainObject(obj.message) ? obj.message.role : undefined);
|
|
403
|
+
if (role === "assistant") {
|
|
404
|
+
seq.push({ role: "assistant" });
|
|
405
|
+
} else if (role === "user") {
|
|
406
|
+
if (obj.isMeta || obj.isVisibleInTranscript === false) continue;
|
|
407
|
+
const text = extractUserTextFromTranscriptEntry(obj);
|
|
408
|
+
if (text) seq.push({ role: "user", text });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Skip every trailing assistant entry (the just-finished reply plus any
|
|
413
|
+
// intermediate prose/tool turns), then take the contiguous run of real user
|
|
414
|
+
// prompts that started this turn. Using the whole trailing assistant block --
|
|
415
|
+
// not just the last entry -- keeps the prompt attached even when the assistant
|
|
416
|
+
// wrote prose or ran tools mid-turn.
|
|
417
|
+
let i = seq.length - 1;
|
|
418
|
+
while (i >= 0 && seq[i].role === "assistant") i -= 1;
|
|
419
|
+
const out = [];
|
|
420
|
+
for (; i >= 0; i -= 1) {
|
|
421
|
+
if (seq[i].role !== "user") break;
|
|
422
|
+
out.unshift(seq[i].text);
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Pull the most recent assistant reply text out of a Claude Code transcript
|
|
428
|
+
// (JSONL). The Stop hook fires right after the turn is written, so the last
|
|
429
|
+
// assistant entry with text is the reply we want to speak. Tool-use/result blocks
|
|
430
|
+
// are ignored; only the prose is kept. Best-effort: any failure yields "".
|
|
431
|
+
function extractAssistantFromTranscript(payload, deps = {}) {
|
|
432
|
+
try {
|
|
433
|
+
if (!isPlainObject(payload)) return "";
|
|
434
|
+
const transcriptPath = firstString(payload.transcript_path, payload.transcriptPath);
|
|
435
|
+
if (!transcriptPath) return "";
|
|
436
|
+
|
|
437
|
+
const readFileSync = deps.readFileSync || require("fs").readFileSync;
|
|
438
|
+
let raw;
|
|
439
|
+
try {
|
|
440
|
+
raw = readFileSync(transcriptPath, "utf8");
|
|
441
|
+
} catch {
|
|
442
|
+
return "";
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let lastText = "";
|
|
446
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
447
|
+
if (!line.trim()) continue;
|
|
448
|
+
let obj;
|
|
449
|
+
try {
|
|
450
|
+
obj = JSON.parse(line);
|
|
451
|
+
} catch {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const role = obj.type || (isPlainObject(obj.message) ? obj.message.role : undefined);
|
|
455
|
+
if (role !== "assistant") continue;
|
|
456
|
+
const message = isPlainObject(obj.message) ? obj.message : obj;
|
|
457
|
+
const text = extractAssistantTextFromTranscriptEntry(message.content);
|
|
458
|
+
if (text) lastText = text;
|
|
459
|
+
}
|
|
460
|
+
return lastText;
|
|
461
|
+
} catch {
|
|
462
|
+
return "";
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function extractAssistantTextFromTranscriptEntry(content) {
|
|
467
|
+
if (typeof content === "string") return content.trim();
|
|
468
|
+
if (!Array.isArray(content)) return "";
|
|
469
|
+
|
|
470
|
+
const parts = [];
|
|
471
|
+
for (const part of content) {
|
|
472
|
+
if (typeof part === "string") {
|
|
473
|
+
parts.push(part.trim());
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (!isPlainObject(part)) continue;
|
|
477
|
+
if (part.type === "tool_use" || part.type === "tool_result" || part.type === "thinking")
|
|
478
|
+
continue;
|
|
479
|
+
if (typeof part.text === "string") parts.push(part.text.trim());
|
|
480
|
+
}
|
|
481
|
+
return parts.filter(Boolean).join("\n\n").trim();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function extractUserTextFromTranscriptEntry(obj) {
|
|
485
|
+
const message = isPlainObject(obj.message) ? obj.message : obj;
|
|
486
|
+
const content = message.content;
|
|
487
|
+
if (typeof content === "string") return content.trim();
|
|
488
|
+
if (!Array.isArray(content)) return "";
|
|
489
|
+
|
|
490
|
+
const parts = [];
|
|
491
|
+
for (const part of content) {
|
|
492
|
+
if (typeof part === "string") {
|
|
493
|
+
parts.push(part.trim());
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (!isPlainObject(part)) continue;
|
|
497
|
+
if (part.type === "tool_result") continue;
|
|
498
|
+
if (typeof part.text === "string") parts.push(part.text.trim());
|
|
499
|
+
}
|
|
500
|
+
return parts.filter(Boolean).join("\n\n").trim();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function firstString(...values) {
|
|
504
|
+
for (const value of values) {
|
|
505
|
+
if (typeof value === "string" && value.trim() !== "") return value.trim();
|
|
506
|
+
}
|
|
507
|
+
return "";
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function normalizeForMatch(value) {
|
|
511
|
+
return String(value || "")
|
|
512
|
+
.replace(/\s+/g, " ")
|
|
513
|
+
.trim();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function detectSourceApp(payload) {
|
|
517
|
+
if (!isPlainObject(payload)) {
|
|
518
|
+
return SOURCE_APPS.UNKNOWN;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const source = lowerString(
|
|
522
|
+
payload.source || payload.source_app || payload.app || payload.provider
|
|
523
|
+
);
|
|
524
|
+
if (source.includes("codex")) {
|
|
525
|
+
return SOURCE_APPS.CODEX;
|
|
526
|
+
}
|
|
527
|
+
if (source.includes("claude")) {
|
|
528
|
+
return SOURCE_APPS.CLAUDE;
|
|
529
|
+
}
|
|
530
|
+
if (source.includes("antigravity")) {
|
|
531
|
+
return SOURCE_APPS.ANTIGRAVITY;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const eventName = lowerString(payload.event || payload.hook_event_name || payload.event_name);
|
|
535
|
+
if (eventName.includes("codex")) {
|
|
536
|
+
return SOURCE_APPS.CODEX;
|
|
537
|
+
}
|
|
538
|
+
if (eventName === "stop" || eventName.includes("claude")) {
|
|
539
|
+
return SOURCE_APPS.CLAUDE;
|
|
540
|
+
}
|
|
541
|
+
if (eventName.includes("antigravity")) {
|
|
542
|
+
return SOURCE_APPS.ANTIGRAVITY;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (isPlainObject(payload.assistant_response)) {
|
|
546
|
+
return SOURCE_APPS.CLAUDE;
|
|
547
|
+
}
|
|
548
|
+
if (isPlainObject(payload.response)) {
|
|
549
|
+
return SOURCE_APPS.CODEX;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return SOURCE_APPS.UNKNOWN;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function extractAssistantText(payload) {
|
|
556
|
+
const candidates = [
|
|
557
|
+
["response", payload && payload.response],
|
|
558
|
+
["assistant_response", payload && payload.assistant_response],
|
|
559
|
+
["message", payload && payload.message],
|
|
560
|
+
["assistant", payload && payload.assistant],
|
|
561
|
+
["result", payload && payload.result]
|
|
562
|
+
];
|
|
563
|
+
|
|
564
|
+
for (const [schema, value] of candidates) {
|
|
565
|
+
const text = extractTextFromValue(value);
|
|
566
|
+
if (text) {
|
|
567
|
+
return { text, schema };
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const directText = extractTextFromValue(payload);
|
|
572
|
+
return directText ? { text: directText, schema: "root" } : { text: "", schema: "unknown" };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function extractTextFromValue(value) {
|
|
576
|
+
if (typeof value === "string") {
|
|
577
|
+
return value.trim();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (Array.isArray(value)) {
|
|
581
|
+
return joinText(value.map(extractTextFromValue));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!isPlainObject(value)) {
|
|
585
|
+
return "";
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (typeof value.text === "string") {
|
|
589
|
+
return value.text.trim();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (typeof value.output_text === "string") {
|
|
593
|
+
return value.output_text.trim();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (typeof value.content === "string") {
|
|
597
|
+
return value.content.trim();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (Array.isArray(value.content)) {
|
|
601
|
+
return joinText(value.content.map(extractTextFromContentPart));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (Array.isArray(value.output)) {
|
|
605
|
+
return joinText(value.output.map(extractTextFromValue));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return "";
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function extractTextFromContentPart(part) {
|
|
612
|
+
if (typeof part === "string") {
|
|
613
|
+
return part.trim();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!isPlainObject(part)) {
|
|
617
|
+
return "";
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (typeof part.text === "string") {
|
|
621
|
+
return part.text.trim();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (typeof part.output_text === "string") {
|
|
625
|
+
return part.output_text.trim();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (Array.isArray(part.content)) {
|
|
629
|
+
return joinText(part.content.map(extractTextFromContentPart));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return "";
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function joinText(parts) {
|
|
636
|
+
return parts
|
|
637
|
+
.map((part) => (typeof part === "string" ? part.trim() : ""))
|
|
638
|
+
.filter(Boolean)
|
|
639
|
+
.join("\n\n")
|
|
640
|
+
.trim();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function skipResult(reason, message, extra = {}) {
|
|
644
|
+
return {
|
|
645
|
+
ok: false,
|
|
646
|
+
action: "skip",
|
|
647
|
+
sourceApp: extra.sourceApp || SOURCE_APPS.UNKNOWN,
|
|
648
|
+
assistantText: "",
|
|
649
|
+
reason,
|
|
650
|
+
message,
|
|
651
|
+
...extra
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function isPlainObject(value) {
|
|
656
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function lowerString(value) {
|
|
660
|
+
return typeof value === "string" ? value.toLowerCase() : "";
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
module.exports = {
|
|
664
|
+
SOURCE_APPS,
|
|
665
|
+
detectSourceApp,
|
|
666
|
+
extractAssistantText,
|
|
667
|
+
extractAssistantFromTranscript,
|
|
668
|
+
extractUserMessages,
|
|
669
|
+
resolveProjectRoot,
|
|
670
|
+
parseHookInput
|
|
671
|
+
};
|