@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,879 @@
1
+ const http = require("http");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
5
+ const {
6
+ AUDIO_CACHE_LIMIT_MAX_MB,
7
+ READ_BEHAVIORS,
8
+ TTS_ENGINES,
9
+ createSettingsStore
10
+ } = require("./settings-store");
11
+ const { createSpeechQueueStore } = require("./speech-queue-store");
12
+ const { createSpoolStore } = require("./spool-store");
13
+ const { createAudioCacheStore } = require("./audio-cache-store");
14
+
15
+ const PORT = Number(process.env.AGENT_HOTLINE_PORT || process.env.VOICE_QUESTION_LOOP_PORT || 4777);
16
+ const HOST = "127.0.0.1";
17
+ const ROOT = path.resolve(__dirname, "..");
18
+ const DATA_DIR = path.join(ROOT, "data");
19
+ const QUESTIONS_FILE = process.env.QUESTION_FILE || path.join(DATA_DIR, "questions.json");
20
+ const REQUEST_LIMIT_BYTES = 1_000_000;
21
+ const AUDIO_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
22
+ const ALLOWED_CORS_METHODS = "GET, POST, PATCH, PUT, DELETE, OPTIONS";
23
+ const ALLOWED_CORS_HEADERS = "Content-Type";
24
+
25
+ function readJson(file, fallback) {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(file, "utf8"));
28
+ } catch {
29
+ return fallback;
30
+ }
31
+ }
32
+
33
+ function writeJson(file, value) {
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
36
+ }
37
+
38
+ function markdownEscape(value) {
39
+ return String(value || "")
40
+ .replace(/\r?\n/g, "<br>")
41
+ .replace(/\|/g, "\\|");
42
+ }
43
+
44
+ function createQuestionStore(options = {}) {
45
+ const dataDir = options.dataDir || DATA_DIR;
46
+ const questionsFile =
47
+ options.questionsFile || process.env.QUESTION_FILE || path.join(dataDir, "questions.json");
48
+ const answersFile = options.answersFile || path.join(dataDir, "answers.json");
49
+
50
+ if (options.ensureFiles !== false && !fs.existsSync(questionsFile)) {
51
+ writeJson(questionsFile, []);
52
+ }
53
+
54
+ function loadQuestions() {
55
+ const questions = readJson(questionsFile, []);
56
+ return Array.isArray(questions) ? questions : [];
57
+ }
58
+
59
+ function loadAnswers() {
60
+ const answers = readJson(answersFile, []);
61
+ return Array.isArray(answers) ? answers : [];
62
+ }
63
+
64
+ function nextQuestion() {
65
+ const questions = loadQuestions();
66
+ const answered = new Set(loadAnswers().map((answer) => answer.question_id));
67
+ return questions.find((question) => !answered.has(question.id)) || null;
68
+ }
69
+
70
+ function saveAnswer(input) {
71
+ if (!input.question_id || !input.answer_text) {
72
+ throw createHttpError(400, "invalid_request", "question_id and answer_text are required");
73
+ }
74
+
75
+ const question = loadQuestions().find((candidate) => candidate.id === input.question_id);
76
+ if (!question) {
77
+ throw createHttpError(404, "not_found", "question_id does not match a queued question");
78
+ }
79
+
80
+ const answers = loadAnswers();
81
+ const existingIndex = answers.findIndex((answer) => answer.question_id === input.question_id);
82
+ const nextAnswer = {
83
+ question_id: input.question_id,
84
+ answer_text: input.answer_text,
85
+ source: input.source || "typed",
86
+ timestamp: new Date().toISOString()
87
+ };
88
+
89
+ if (existingIndex >= 0) {
90
+ answers[existingIndex] = nextAnswer;
91
+ } else {
92
+ answers.push(nextAnswer);
93
+ }
94
+
95
+ writeJson(answersFile, answers);
96
+ }
97
+
98
+ function exportMarkdown() {
99
+ const questions = loadQuestions();
100
+ const answersByQuestion = new Map(loadAnswers().map((answer) => [answer.question_id, answer]));
101
+ const lines = [
102
+ "# Agent Hotline Decisions",
103
+ "",
104
+ `Exported: ${new Date().toISOString()}`,
105
+ "",
106
+ "| Stage | Question | Recommendation | Answer | Source | Timestamp |",
107
+ "| --- | --- | --- | --- | --- | --- |"
108
+ ];
109
+
110
+ for (const question of questions) {
111
+ const answer = answersByQuestion.get(question.id) || {};
112
+ lines.push(
113
+ [
114
+ markdownEscape(question.stage),
115
+ markdownEscape(question.question),
116
+ markdownEscape(question.recommendation),
117
+ markdownEscape(answer.answer_text),
118
+ markdownEscape(answer.source),
119
+ markdownEscape(answer.timestamp)
120
+ ]
121
+ .join(" | ")
122
+ .replace(/^/, "| ")
123
+ .replace(/$/, " |")
124
+ );
125
+ }
126
+
127
+ return `${lines.join("\n")}\n`;
128
+ }
129
+
130
+ return {
131
+ answersFile,
132
+ questionsFile,
133
+ loadQuestions,
134
+ loadAnswers,
135
+ nextQuestion,
136
+ saveAnswer,
137
+ exportMarkdown
138
+ };
139
+ }
140
+
141
+ function sendJson(res, status, body) {
142
+ const payload = JSON.stringify(body);
143
+ res.writeHead(status, {
144
+ "Content-Type": "application/json; charset=utf-8",
145
+ "Content-Length": Buffer.byteLength(payload)
146
+ });
147
+ res.end(payload);
148
+ }
149
+
150
+ function sendText(res, status, body, contentType = "text/plain; charset=utf-8") {
151
+ res.writeHead(status, {
152
+ "Content-Type": contentType,
153
+ "Content-Length": Buffer.byteLength(body)
154
+ });
155
+ res.end(body);
156
+ }
157
+
158
+ function isAllowedLocalOrigin(origin) {
159
+ if (!origin || typeof origin !== "string") return false;
160
+
161
+ try {
162
+ const parsed = new URL(origin);
163
+ return (
164
+ ["http:", "https:", "tauri:"].includes(parsed.protocol) &&
165
+ ["127.0.0.1", "localhost", "::1", "tauri.localhost"].includes(parsed.hostname)
166
+ );
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ function applyCors(req, res) {
173
+ const origin = req.headers.origin;
174
+ if (!isAllowedLocalOrigin(origin)) return false;
175
+
176
+ res.setHeader("Access-Control-Allow-Origin", origin);
177
+ res.setHeader("Vary", "Origin");
178
+ res.setHeader("Access-Control-Allow-Methods", ALLOWED_CORS_METHODS);
179
+ res.setHeader("Access-Control-Allow-Headers", ALLOWED_CORS_HEADERS);
180
+ res.setHeader("Access-Control-Max-Age", "600");
181
+ return true;
182
+ }
183
+
184
+ function sendCorsPreflight(req, res) {
185
+ if (!applyCors(req, res)) {
186
+ sendError(res, createHttpError(403, "forbidden_origin", "CORS origin is not allowed"));
187
+ return;
188
+ }
189
+
190
+ res.writeHead(204, { "Content-Length": "0" });
191
+ res.end();
192
+ }
193
+
194
+ function createHttpError(status, code, message, details) {
195
+ const error = new Error(message);
196
+ error.status = status;
197
+ error.code = code;
198
+ error.details = details;
199
+ return error;
200
+ }
201
+
202
+ function sendError(res, error) {
203
+ const status = error.status || 500;
204
+ sendJson(res, status, {
205
+ error: {
206
+ code: error.code || "internal_error",
207
+ message: status >= 500 ? "Internal server error" : error.message,
208
+ ...(error.details ? { details: error.details } : {})
209
+ }
210
+ });
211
+ }
212
+
213
+ function readBody(req) {
214
+ return new Promise((resolve, reject) => {
215
+ let body = "";
216
+ req.on("data", (chunk) => {
217
+ body += chunk;
218
+ if (Buffer.byteLength(body) > REQUEST_LIMIT_BYTES) {
219
+ reject(createHttpError(413, "body_too_large", "Request body too large"));
220
+ req.destroy();
221
+ }
222
+ });
223
+ req.on("end", () => resolve(body));
224
+ req.on("error", reject);
225
+ });
226
+ }
227
+
228
+ async function readJsonBody(req) {
229
+ const body = await readBody(req);
230
+ if (!body.trim()) {
231
+ return {};
232
+ }
233
+
234
+ try {
235
+ return JSON.parse(body);
236
+ } catch {
237
+ throw createHttpError(400, "invalid_json", "Request body must be valid JSON");
238
+ }
239
+ }
240
+
241
+ function readLargeJsonBody(req, limit) {
242
+ return new Promise((resolve, reject) => {
243
+ const chunks = [];
244
+ let size = 0;
245
+ req.on("data", (chunk) => {
246
+ size += chunk.length;
247
+ if (size > limit) {
248
+ reject(createHttpError(413, "body_too_large", "Request body too large"));
249
+ req.destroy();
250
+ return;
251
+ }
252
+ chunks.push(chunk);
253
+ });
254
+ req.on("end", () => {
255
+ const text = Buffer.concat(chunks).toString("utf8");
256
+ if (!text.trim()) {
257
+ resolve({});
258
+ return;
259
+ }
260
+ try {
261
+ resolve(JSON.parse(text));
262
+ } catch {
263
+ reject(createHttpError(400, "invalid_json", "Request body must be valid JSON"));
264
+ }
265
+ });
266
+ req.on("error", reject);
267
+ });
268
+ }
269
+
270
+ function getQuery(req) {
271
+ return new URL(req.url, "http://127.0.0.1").searchParams;
272
+ }
273
+
274
+ function sendBinary(res, status, buffer, contentType) {
275
+ res.writeHead(status, {
276
+ "Content-Type": contentType,
277
+ "Content-Length": buffer.length
278
+ });
279
+ res.end(buffer);
280
+ }
281
+
282
+ function sessionKeyForItem(item) {
283
+ return item.threadId || `app:${item.sourceApp}`;
284
+ }
285
+
286
+ // Stable project identity from a (possibly inconsistently-shaped) path. Stripping
287
+ // all separators + lowercasing collapses mixed slash styles and separator-stripped
288
+ // Windows cwds onto one key. Must stay byte-identical to canonicalProjectKey() in
289
+ // the desktop grouping module so Storage delete-by-project matches what the UI sends.
290
+ function projectKeyForItem(item) {
291
+ if (!item.projectPath) return `direct:${item.sourceApp}`;
292
+ return String(item.projectPath).replace(/[\\/]/g, "").toLowerCase();
293
+ }
294
+
295
+ function requirePlainObject(value, message = "Request body must be a JSON object") {
296
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
297
+ throw createHttpError(400, "invalid_request", message);
298
+ }
299
+ }
300
+
301
+ function validateSettingsPatch(patch) {
302
+ requirePlainObject(patch, "Settings update must be a JSON object");
303
+ const allowed = new Set([
304
+ "readBehavior",
305
+ "mute",
306
+ "engine",
307
+ "voice",
308
+ "kokoroVoice",
309
+ "rate",
310
+ "volume",
311
+ "skipRules",
312
+ "codexEnabled",
313
+ "claudeEnabled",
314
+ "antigravityEnabled",
315
+ "notifyOnNewReply",
316
+ "notificationOpens",
317
+ "highlightSpokenText",
318
+ "audioCacheLimitMb"
319
+ ]);
320
+ const allowedSkipRules = new Set([
321
+ "codeBlocks",
322
+ "diffs",
323
+ "logs",
324
+ "tables",
325
+ "json",
326
+ "longBulletLists"
327
+ ]);
328
+ const errors = [];
329
+
330
+ for (const key of Object.keys(patch)) {
331
+ if (!allowed.has(key)) errors.push(`${key} is not a supported setting`);
332
+ }
333
+
334
+ if ("readBehavior" in patch && !READ_BEHAVIORS.includes(patch.readBehavior)) {
335
+ errors.push("readBehavior must be manual or auto");
336
+ }
337
+ if ("mute" in patch && typeof patch.mute !== "boolean") errors.push("mute must be boolean");
338
+ if ("engine" in patch && !TTS_ENGINES.includes(patch.engine)) {
339
+ errors.push("engine must be webview or kokoro");
340
+ }
341
+ if ("voice" in patch && typeof patch.voice !== "string") errors.push("voice must be string");
342
+ if ("kokoroVoice" in patch && typeof patch.kokoroVoice !== "string") {
343
+ errors.push("kokoroVoice must be string");
344
+ }
345
+ if ("rate" in patch && (!Number.isFinite(patch.rate) || patch.rate < 0.1 || patch.rate > 10)) {
346
+ errors.push("rate must be a number from 0.1 to 10");
347
+ }
348
+ if (
349
+ "volume" in patch &&
350
+ (!Number.isFinite(patch.volume) || patch.volume < 0 || patch.volume > 1)
351
+ ) {
352
+ errors.push("volume must be a number from 0 to 1");
353
+ }
354
+ if ("codexEnabled" in patch && typeof patch.codexEnabled !== "boolean") {
355
+ errors.push("codexEnabled must be boolean");
356
+ }
357
+ if ("claudeEnabled" in patch && typeof patch.claudeEnabled !== "boolean") {
358
+ errors.push("claudeEnabled must be boolean");
359
+ }
360
+ if ("antigravityEnabled" in patch && typeof patch.antigravityEnabled !== "boolean") {
361
+ errors.push("antigravityEnabled must be boolean");
362
+ }
363
+ if ("notifyOnNewReply" in patch && typeof patch.notifyOnNewReply !== "boolean") {
364
+ errors.push("notifyOnNewReply must be boolean");
365
+ }
366
+ if ("notificationOpens" in patch && !["full", "mini"].includes(patch.notificationOpens)) {
367
+ errors.push("notificationOpens must be full or mini");
368
+ }
369
+ if ("highlightSpokenText" in patch && typeof patch.highlightSpokenText !== "boolean") {
370
+ errors.push("highlightSpokenText must be boolean");
371
+ }
372
+ if (
373
+ "audioCacheLimitMb" in patch &&
374
+ (!Number.isFinite(patch.audioCacheLimitMb) ||
375
+ patch.audioCacheLimitMb < 10 ||
376
+ patch.audioCacheLimitMb > AUDIO_CACHE_LIMIT_MAX_MB)
377
+ ) {
378
+ errors.push(`audioCacheLimitMb must be a number from 10 to ${AUDIO_CACHE_LIMIT_MAX_MB}`);
379
+ }
380
+ if ("skipRules" in patch) {
381
+ if (!patch.skipRules || typeof patch.skipRules !== "object" || Array.isArray(patch.skipRules)) {
382
+ errors.push("skipRules must be an object");
383
+ } else {
384
+ for (const key of Object.keys(patch.skipRules)) {
385
+ if (!allowedSkipRules.has(key)) errors.push(`skipRules.${key} is not supported`);
386
+ if (typeof patch.skipRules[key] !== "boolean")
387
+ errors.push(`skipRules.${key} must be boolean`);
388
+ }
389
+ }
390
+ }
391
+
392
+ if (errors.length > 0) {
393
+ throw createHttpError(400, "invalid_settings", "Settings update is invalid", errors);
394
+ }
395
+ }
396
+
397
+ function requireString(value, fieldName) {
398
+ if (typeof value !== "string" || value.trim() === "") {
399
+ throw createHttpError(400, "invalid_request", `${fieldName} must be a non-empty string`);
400
+ }
401
+ }
402
+
403
+ function queueState(queueStore) {
404
+ const state = queueStore.getState();
405
+ return {
406
+ ...state,
407
+ pending: queueStore.getPending(),
408
+ current: queueStore.getCurrent(),
409
+ latest: queueStore.getLatest()
410
+ };
411
+ }
412
+
413
+ function getPathname(req) {
414
+ return new URL(req.url, "http://127.0.0.1").pathname;
415
+ }
416
+
417
+ function page() {
418
+ return `<!doctype html>
419
+ <html lang="en">
420
+ <head>
421
+ <meta charset="utf-8">
422
+ <meta name="viewport" content="width=device-width, initial-scale=1">
423
+ <title>Agent Hotline</title>
424
+ <style>
425
+ :root { color-scheme: light dark; font-family: Inter, Segoe UI, system-ui, sans-serif; }
426
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #101418; color: #eef2f4; }
427
+ main { width: min(860px, calc(100vw - 32px)); }
428
+ .panel { border: 1px solid #2b343c; border-radius: 8px; padding: 24px; background: #171d22; box-shadow: 0 20px 60px rgba(0,0,0,.25); }
429
+ .stage { color: #8fb7ff; font-size: 14px; margin-bottom: 16px; }
430
+ h1 { font-size: 30px; line-height: 1.2; margin: 0 0 16px; letter-spacing: 0; }
431
+ .rec { color: #c7d0d8; border-left: 3px solid #58c48d; padding-left: 12px; margin: 18px 0; }
432
+ textarea { width: 100%; min-height: 130px; box-sizing: border-box; border-radius: 6px; border: 1px solid #34404a; background: #0f1418; color: #eef2f4; padding: 12px; font: inherit; resize: vertical; }
433
+ .row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; align-items: center; }
434
+ button { border: 1px solid #34404a; background: #202a31; color: #eef2f4; border-radius: 6px; padding: 10px 14px; cursor: pointer; font: inherit; }
435
+ button.primary { background: #2e7d5b; border-color: #39a36f; }
436
+ button:disabled { opacity: .5; cursor: not-allowed; }
437
+ a { color: #9fc4ff; text-decoration: none; }
438
+ a:hover { text-decoration: underline; }
439
+ .meta { color: #9ba8b2; font-size: 13px; margin-top: 16px; }
440
+ .done { color: #8ee6b0; }
441
+ </style>
442
+ </head>
443
+ <body>
444
+ <main class="panel">
445
+ <div id="stage" class="stage"></div>
446
+ <h1 id="question">Loading...</h1>
447
+ <div id="recommendation" class="rec"></div>
448
+ <textarea id="answer" placeholder="Answer here, or use voice if your browser supports it."></textarea>
449
+ <div class="row">
450
+ <button id="speak">Read aloud</button>
451
+ <button id="voice">Voice answer</button>
452
+ <button id="save" class="primary">Save answer</button>
453
+ <button id="refresh">Next question</button>
454
+ <a href="/api/export.md" target="_blank" rel="noreferrer">Export Markdown</a>
455
+ <a href="/api/export" target="_blank" rel="noreferrer">Export JSON</a>
456
+ </div>
457
+ <div id="meta" class="meta"></div>
458
+ </main>
459
+ <script>
460
+ let current = null;
461
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
462
+
463
+ async function load() {
464
+ const res = await fetch("/api/next");
465
+ const data = await res.json();
466
+ current = data.question;
467
+ document.getElementById("answer").value = "";
468
+ if (!current) {
469
+ document.getElementById("stage").textContent = "";
470
+ document.getElementById("question").textContent = "All questions answered.";
471
+ document.getElementById("question").className = "done";
472
+ document.getElementById("recommendation").textContent = "";
473
+ document.getElementById("meta").textContent = data.total + " total questions, " + data.answered + " answered.";
474
+ return;
475
+ }
476
+ document.getElementById("stage").textContent = current.stage || "";
477
+ document.getElementById("question").textContent = current.question;
478
+ document.getElementById("recommendation").textContent = current.recommendation ? "Recommendation: " + current.recommendation : "";
479
+ document.getElementById("meta").textContent = data.answered + " answered of " + data.total + ". Current id: " + current.id;
480
+ }
481
+
482
+ function speak() {
483
+ if (!current || !window.speechSynthesis) return;
484
+ const text = [current.stage, current.question, current.recommendation ? "Recommendation: " + current.recommendation : ""].filter(Boolean).join(". ");
485
+ const utterance = new SpeechSynthesisUtterance(text);
486
+ window.speechSynthesis.cancel();
487
+ window.speechSynthesis.speak(utterance);
488
+ }
489
+
490
+ function voice() {
491
+ if (!SpeechRecognition) {
492
+ alert("This browser does not expose speech recognition. Typed answers still work.");
493
+ return;
494
+ }
495
+ const recognition = new SpeechRecognition();
496
+ recognition.lang = "en-US";
497
+ recognition.interimResults = true;
498
+ recognition.continuous = false;
499
+ recognition.onresult = (event) => {
500
+ const text = Array.from(event.results).map((result) => result[0].transcript).join(" ");
501
+ document.getElementById("answer").value = text;
502
+ };
503
+ recognition.start();
504
+ }
505
+
506
+ async function save() {
507
+ if (!current) return;
508
+ const answer = document.getElementById("answer").value.trim();
509
+ if (!answer) return;
510
+ await fetch("/api/answer", {
511
+ method: "POST",
512
+ headers: { "Content-Type": "application/json" },
513
+ body: JSON.stringify({ question_id: current.id, answer_text: answer, source: "browser" })
514
+ });
515
+ await load();
516
+ }
517
+
518
+ document.getElementById("speak").addEventListener("click", speak);
519
+ document.getElementById("voice").addEventListener("click", voice);
520
+ document.getElementById("save").addEventListener("click", save);
521
+ document.getElementById("refresh").addEventListener("click", load);
522
+ load();
523
+ </script>
524
+ </body>
525
+ </html>`;
526
+ }
527
+
528
+ function createServer(options = {}) {
529
+ const settingsStore =
530
+ options.settingsStore ||
531
+ createSettingsStore({
532
+ dataDir: options.dataDir,
533
+ settingsPath: options.settingsPath
534
+ });
535
+ const queueStore =
536
+ options.queueStore ||
537
+ createSpeechQueueStore({
538
+ dataDir: options.dataDir,
539
+ filePath: options.queuePath
540
+ });
541
+ const questionStore =
542
+ options.questionStore ||
543
+ createQuestionStore({
544
+ dataDir: options.questionDataDir || options.dataDir,
545
+ questionsFile: options.questionsFile,
546
+ answersFile: options.answersFile,
547
+ ensureFiles: options.ensureQuestionFiles
548
+ });
549
+
550
+ const audioCacheStore =
551
+ options.audioCacheStore ||
552
+ createAudioCacheStore({
553
+ dataDir: options.dataDir,
554
+ cacheDir: options.audioCacheDir,
555
+ maxBytes: options.audioMaxBytes,
556
+ getMaxBytes: options.audioMaxBytes
557
+ ? undefined
558
+ : () => Number(settingsStore.load().audioCacheLimitMb) * 1024 * 1024
559
+ });
560
+
561
+ const spoolStore =
562
+ options.spoolStore ||
563
+ createSpoolStore({ dataDir: options.dataDir, filePath: options.spoolPath });
564
+ try {
565
+ spoolStore.drain((item) => queueStore.enqueue(item));
566
+ } catch {}
567
+
568
+ return http.createServer(async (req, res) => {
569
+ try {
570
+ applyCors(req, res);
571
+ if (req.method === "OPTIONS") {
572
+ sendCorsPreflight(req, res);
573
+ return;
574
+ }
575
+
576
+ const pathname = getPathname(req);
577
+
578
+ if (req.method === "GET" && pathname === "/") {
579
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
580
+ res.end(page());
581
+ return;
582
+ }
583
+
584
+ if (req.method === "GET" && (pathname === "/health" || pathname === "/api/health")) {
585
+ sendJson(res, 200, { ok: true, service: "agent-hotline", host: HOST });
586
+ return;
587
+ }
588
+
589
+ if (req.method === "GET" && pathname === "/api/settings") {
590
+ sendJson(res, 200, { settings: settingsStore.load() });
591
+ return;
592
+ }
593
+
594
+ if ((req.method === "PATCH" || req.method === "PUT") && pathname === "/api/settings") {
595
+ const body = await readJsonBody(req);
596
+ validateSettingsPatch(body);
597
+ const settings = settingsStore.update(body);
598
+ if ("audioCacheLimitMb" in body) audioCacheStore.enforceLimit();
599
+ sendJson(res, 200, { settings });
600
+ return;
601
+ }
602
+
603
+ if (req.method === "POST" && pathname === "/api/mute") {
604
+ sendJson(res, 200, { settings: settingsStore.update({ mute: true }) });
605
+ return;
606
+ }
607
+
608
+ if (req.method === "POST" && pathname === "/api/unmute") {
609
+ sendJson(res, 200, { settings: settingsStore.update({ mute: false }) });
610
+ return;
611
+ }
612
+
613
+ if (req.method === "GET" && pathname === "/api/queue") {
614
+ sendJson(res, 200, { queue: queueState(queueStore) });
615
+ return;
616
+ }
617
+
618
+ if (req.method === "POST" && pathname === "/api/queue") {
619
+ const body = await readJsonBody(req);
620
+ requirePlainObject(body);
621
+ requireString(body.rawSource, "rawSource");
622
+ requireString(body.speakableText, "speakableText");
623
+ requireString(body.sourceApp, "sourceApp");
624
+ const item = queueStore.enqueue({
625
+ id: body.id,
626
+ rawSource: body.rawSource,
627
+ speakableText: body.speakableText,
628
+ sourceApp: body.sourceApp,
629
+ threadId: body.threadId,
630
+ threadLabel: body.threadLabel,
631
+ sessionName: body.sessionName,
632
+ projectPath: body.projectPath,
633
+ projectName: body.projectName,
634
+ userMessages: body.userMessages
635
+ });
636
+ sendJson(res, 201, { item, queue: queueState(queueStore) });
637
+ return;
638
+ }
639
+
640
+ const playingMatch = pathname.match(/^\/api\/queue\/([^/]+)\/playing$/);
641
+ if (req.method === "POST" && playingMatch) {
642
+ const item = queueStore.markPlaying(decodeURIComponent(playingMatch[1]));
643
+ sendJson(res, 200, { item, queue: queueState(queueStore) });
644
+ return;
645
+ }
646
+
647
+ const playedMatch = pathname.match(/^\/api\/queue\/([^/]+)\/played$/);
648
+ if (req.method === "POST" && playedMatch) {
649
+ const item = queueStore.markPlayed(decodeURIComponent(playedMatch[1]));
650
+ sendJson(res, 200, { item, queue: queueState(queueStore) });
651
+ return;
652
+ }
653
+
654
+ const skippedMatch = pathname.match(/^\/api\/queue\/([^/]+)\/skipped$/);
655
+ if (req.method === "POST" && skippedMatch) {
656
+ const body = await readJsonBody(req);
657
+ requirePlainObject(body);
658
+ requireString(body.reason, "reason");
659
+ const item = queueStore.markSkipped(decodeURIComponent(skippedMatch[1]), body.reason);
660
+ sendJson(res, 200, { item, queue: queueState(queueStore) });
661
+ return;
662
+ }
663
+
664
+ if (req.method === "POST" && pathname === "/api/queue/replay-latest") {
665
+ const item = queueStore.replayLatest();
666
+ if (!item) {
667
+ throw createHttpError(404, "not_found", "No replayable queue item exists");
668
+ }
669
+ sendJson(res, 201, { item, queue: queueState(queueStore) });
670
+ return;
671
+ }
672
+
673
+ const replayMatch = pathname.match(/^\/api\/queue\/([^/]+)\/replay$/);
674
+ if (req.method === "POST" && replayMatch) {
675
+ const item = queueStore.replayItem(decodeURIComponent(replayMatch[1]));
676
+ if (!item) {
677
+ throw createHttpError(404, "not_found", "Queue item cannot be replayed");
678
+ }
679
+ sendJson(res, 201, { item, queue: queueState(queueStore) });
680
+ return;
681
+ }
682
+
683
+ const audioWavMatch = pathname.match(/^\/api\/queue\/([^/]+)\/audio\.wav$/);
684
+ if (req.method === "GET" && audioWavMatch) {
685
+ const id = decodeURIComponent(audioWavMatch[1]);
686
+ const query = getQuery(req);
687
+ const filePath = audioCacheStore.getAudioPath(id, query.get("engine"), query.get("voice"));
688
+ if (!filePath) {
689
+ throw createHttpError(404, "not_found", "No cached audio for this item");
690
+ }
691
+ sendBinary(res, 200, fs.readFileSync(filePath), "audio/wav");
692
+ return;
693
+ }
694
+
695
+ const audioMatch = pathname.match(/^\/api\/queue\/([^/]+)\/audio$/);
696
+ if (req.method === "GET" && audioMatch) {
697
+ const id = decodeURIComponent(audioMatch[1]);
698
+ const query = getQuery(req);
699
+ const manifest = audioCacheStore.getManifest(id, query.get("engine"), query.get("voice"));
700
+ sendJson(res, 200, manifest ? { cached: true, ...manifest } : { cached: false });
701
+ return;
702
+ }
703
+
704
+ if (req.method === "POST" && audioMatch) {
705
+ const id = decodeURIComponent(audioMatch[1]);
706
+ const query = getQuery(req);
707
+ const engine = query.get("engine");
708
+ const voice = query.get("voice");
709
+ requireString(engine, "engine");
710
+ requireString(voice, "voice");
711
+ const body = await readLargeJsonBody(req, AUDIO_BODY_LIMIT_BYTES);
712
+ requirePlainObject(body);
713
+ requireString(body.wav, "wav");
714
+ const wavBuffer = Buffer.from(body.wav, "base64");
715
+ if (wavBuffer.length === 0) {
716
+ throw createHttpError(400, "invalid_request", "wav must be base64-encoded audio");
717
+ }
718
+ const entry = audioCacheStore.put(id, engine, voice, {
719
+ sampleRate: body.sampleRate,
720
+ durationSec: body.durationSec,
721
+ segments: body.segments,
722
+ wordAccurate: body.wordAccurate,
723
+ wavBuffer
724
+ });
725
+ sendJson(res, 201, { stored: Boolean(entry), entry: entry || null });
726
+ return;
727
+ }
728
+
729
+ if (req.method === "GET" && pathname === "/api/audio-cache") {
730
+ const { entries, totalBytes, maxBytes } = audioCacheStore.list();
731
+ const itemsById = new Map(queueStore.getState().items.map((item) => [item.id, item]));
732
+ const enriched = entries.map((entry) => {
733
+ const item = itemsById.get(entry.itemId);
734
+ return {
735
+ itemId: entry.itemId,
736
+ engine: entry.engine,
737
+ voice: entry.voice,
738
+ bytes: entry.bytes,
739
+ durationSec: entry.durationSec,
740
+ wordAccurate: entry.wordAccurate,
741
+ createdAt: entry.createdAt,
742
+ lastAccessedAt: entry.lastAccessedAt,
743
+ sourceApp: item ? item.sourceApp : null,
744
+ threadId: item ? item.threadId || null : null,
745
+ sessionName: item ? item.sessionName || item.threadLabel || null : null,
746
+ sessionKey: item ? sessionKeyForItem(item) : "app:unknown",
747
+ projectPath: item ? item.projectPath || null : null,
748
+ projectName: item ? item.projectName || null : null,
749
+ projectKey: item ? projectKeyForItem(item) : "direct:unknown",
750
+ itemCreatedAt: item ? item.timestamps && item.timestamps.createdAt : null,
751
+ preview: item
752
+ ? String(item.speakableText || "")
753
+ .replace(/\s+/g, " ")
754
+ .trim()
755
+ .slice(0, 80)
756
+ : ""
757
+ };
758
+ });
759
+ sendJson(res, 200, { entries: enriched, totalBytes, maxBytes });
760
+ return;
761
+ }
762
+
763
+ const audioCacheItemMatch = pathname.match(/^\/api\/audio-cache\/([^/]+)$/);
764
+ if (req.method === "DELETE" && audioCacheItemMatch) {
765
+ const id = decodeURIComponent(audioCacheItemMatch[1]);
766
+ const query = getQuery(req);
767
+ const removed = audioCacheStore.removeOne(id, query.get("engine"), query.get("voice"));
768
+ sendJson(res, 200, { removed });
769
+ return;
770
+ }
771
+
772
+ if (req.method === "DELETE" && pathname === "/api/audio-cache") {
773
+ const query = getQuery(req);
774
+ if (query.get("all") === "true") {
775
+ sendJson(res, 200, { removed: audioCacheStore.clearAll() });
776
+ return;
777
+ }
778
+ const session = query.get("session");
779
+ if (session) {
780
+ const ids = queueStore
781
+ .getState()
782
+ .items.filter((item) => sessionKeyForItem(item) === session)
783
+ .map((item) => item.id);
784
+ sendJson(res, 200, { removed: audioCacheStore.removeByItemIds(ids) });
785
+ return;
786
+ }
787
+ const project = query.get("project");
788
+ if (project) {
789
+ const ids = queueStore
790
+ .getState()
791
+ .items.filter((item) => projectKeyForItem(item) === project)
792
+ .map((item) => item.id);
793
+ sendJson(res, 200, { removed: audioCacheStore.removeByItemIds(ids) });
794
+ return;
795
+ }
796
+ throw createHttpError(
797
+ 400,
798
+ "invalid_request",
799
+ "Specify ?all=true, ?project=<key>, or ?session=<key>"
800
+ );
801
+ }
802
+
803
+ if (req.method === "GET" && pathname === "/api/next") {
804
+ const questions = questionStore.loadQuestions();
805
+ const answers = questionStore.loadAnswers();
806
+ sendJson(res, 200, {
807
+ question: questionStore.nextQuestion(),
808
+ total: questions.length,
809
+ answered: answers.length
810
+ });
811
+ return;
812
+ }
813
+
814
+ if (req.method === "GET" && pathname === "/api/export") {
815
+ sendJson(res, 200, {
816
+ questions: questionStore.loadQuestions(),
817
+ answers: questionStore.loadAnswers()
818
+ });
819
+ return;
820
+ }
821
+
822
+ if (req.method === "GET" && pathname === "/api/export.md") {
823
+ sendText(res, 200, questionStore.exportMarkdown(), "text/markdown; charset=utf-8");
824
+ return;
825
+ }
826
+
827
+ if (req.method === "POST" && pathname === "/api/answer") {
828
+ questionStore.saveAnswer(await readJsonBody(req));
829
+ sendJson(res, 200, { ok: true });
830
+ return;
831
+ }
832
+
833
+ throw createHttpError(404, "not_found", "Not found");
834
+ } catch (error) {
835
+ if (!error.status && /Queue item not found/.test(error.message)) {
836
+ sendError(res, createHttpError(404, "not_found", error.message));
837
+ return;
838
+ }
839
+ if (!error.status && /(must be|already exists)/.test(error.message)) {
840
+ sendError(res, createHttpError(400, "invalid_request", error.message));
841
+ return;
842
+ }
843
+ sendError(res, error);
844
+ }
845
+ });
846
+ }
847
+
848
+ function listen(options = {}) {
849
+ const port = Number(options.port || PORT);
850
+ const server = createServer(options);
851
+
852
+ server.on("error", (error) => {
853
+ if (error && error.code === "EADDRINUSE") {
854
+ console.error(
855
+ `Agent Hotline backend already running on ${HOST}:${port}; this duplicate is exiting.`
856
+ );
857
+ process.exit(0);
858
+ }
859
+ console.error(`Agent Hotline backend failed to start: ${error.message}`);
860
+ process.exit(1);
861
+ });
862
+
863
+ server.listen(port, HOST, () => {
864
+ console.log(`Agent Hotline listening on http://${HOST}:${port}`);
865
+ console.log(`Question file: ${options.questionsFile || QUESTIONS_FILE}`);
866
+ });
867
+ return server;
868
+ }
869
+
870
+ if (require.main === module) {
871
+ listen();
872
+ }
873
+
874
+ module.exports = {
875
+ HOST,
876
+ PORT,
877
+ createServer,
878
+ listen
879
+ };