@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.
@@ -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
+ };