@micsushi/agent-hotline 0.5.2 → 1.0.0

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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@micsushi/agent-hotline",
3
- "version": "0.5.2",
3
+ "version": "1.0.0",
4
4
  "description": "Local read-aloud hooks and tray app for AI coding agents.",
5
5
  "bin": {
6
6
  "ah": "packages/backend/bin/agent-hotline.js",
7
7
  "agent-hotline": "packages/backend/bin/agent-hotline.js",
8
- "agent-hotline-hook": "packages/backend/bin/agent-hotline-hook.js"
8
+ "agent-hotline-hook": "packages/backend/bin/agent-hotline-hook.js",
9
+ "agent-hotline-mcp": "packages/backend/bin/agent-hotline-mcp.js"
9
10
  },
10
11
  "files": [
11
12
  "README.md",
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import("../src/mcp-server.mjs").then(({ runStdioServer }) => runStdioServer());
@@ -7,14 +7,19 @@
7
7
  "bin": {
8
8
  "ah": "bin/agent-hotline.js",
9
9
  "agent-hotline": "bin/agent-hotline.js",
10
- "agent-hotline-hook": "bin/agent-hotline-hook.js"
10
+ "agent-hotline-hook": "bin/agent-hotline-hook.js",
11
+ "agent-hotline-mcp": "bin/agent-hotline-mcp.js"
11
12
  },
12
13
  "scripts": {
13
14
  "start": "node src/server.js",
14
- "test": "node --test test/*.test.js",
15
- "check": "node --check src/server.js && node --check src/settings-store.js && node --check src/speech-queue-store.js && node --check src/audio-cache-store.js && node --check src/installer.js && node --check src/hook-input-parser.js && node --check src/hook-command.js && node --check src/run-command.js && node --check src/speakable-filter.js && node --check bin/agent-hotline.js && node --check bin/agent-hotline-hook.js && node --check test/settings-store.test.js && node --check test/speech-queue-store.test.js && node --check test/audio-cache-store.test.js && node --check test/audio-endpoints.test.js && node --check test/installer.test.js && node --check test/run-command.test.js && node --check test/speakable-filter.test.js && node --check test/hook-input-parser.test.js && node --check test/hook-command.test.js && node --check test/server-endpoints.test.js"
15
+ "test": "node --test test/*.test.js test/*.test.mjs",
16
+ "check": "node --check src/server.js && node --check src/settings-store.js && node --check src/speech-queue-store.js && node --check src/audio-cache-store.js && node --check src/installer.js && node --check src/hook-input-parser.js && node --check src/hook-command.js && node --check src/run-command.js && node --check src/speakable-filter.js && node --check src/mcp-server.mjs && node --check bin/agent-hotline.js && node --check bin/agent-hotline-hook.js && node --check bin/agent-hotline-mcp.js && node --check test/settings-store.test.js && node --check test/speech-queue-store.test.js && node --check test/audio-cache-store.test.js && node --check test/audio-endpoints.test.js && node --check test/installer.test.js && node --check test/run-command.test.js && node --check test/speakable-filter.test.js && node --check test/hook-input-parser.test.js && node --check test/hook-command.test.js && node --check test/server-endpoints.test.js && node --check test/mcp-server.test.mjs"
16
17
  },
17
18
  "engines": {
18
19
  "node": ">=18"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.29.0",
23
+ "zod": "^4.4.3"
19
24
  }
20
25
  }
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+
6
+ import { createRequire } from "node:module";
7
+ import { pathToFileURL } from "node:url";
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const { createAudioCacheStore } = require("./audio-cache-store.js");
11
+ const { createSettingsStore } = require("./settings-store.js");
12
+ const { createSpeechQueueStore } = require("./speech-queue-store.js");
13
+
14
+ function textResult(value) {
15
+ return {
16
+ content: [
17
+ {
18
+ type: "text",
19
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
20
+ }
21
+ ]
22
+ };
23
+ }
24
+
25
+ function sessionKeyOf(item) {
26
+ return item.sessionKey || item.threadId || `app:${item.sourceApp}`;
27
+ }
28
+
29
+ function projectKeyOf(item) {
30
+ if (!item.projectPath) return `direct:${item.sourceApp}`;
31
+ return String(item.projectPath).replace(/[\\/]/g, "").toLowerCase();
32
+ }
33
+
34
+ function projectLabelOf(item) {
35
+ if (!item.projectPath) return item.sourceApp || "Direct";
36
+ const raw = item.projectName || item.projectPath || "";
37
+ return (
38
+ String(raw)
39
+ .replace(/[\\/]+$/, "")
40
+ .split(/[\\/]/)
41
+ .pop() ||
42
+ item.sourceApp ||
43
+ "Direct"
44
+ );
45
+ }
46
+
47
+ function sessionLabelOf(item) {
48
+ const id = item.threadId ? item.threadId.slice(0, 8) : "";
49
+ if (item.sessionName && id) return `${item.sessionName} - ${id}`;
50
+ if (item.sessionName) return item.sessionName;
51
+ if (id) return id;
52
+ return `${item.sourceApp} - direct`;
53
+ }
54
+
55
+ function visibleItems(queueStore) {
56
+ return queueStore.getState().items.filter((item) => item.speakableText && !item.trashedAt);
57
+ }
58
+
59
+ function trashedItems(queueStore) {
60
+ return queueStore.getState().items.filter((item) => item.speakableText && item.trashedAt);
61
+ }
62
+
63
+ function compactItem(item) {
64
+ return {
65
+ id: item.id,
66
+ sourceApp: item.sourceApp,
67
+ projectKey: projectKeyOf(item),
68
+ projectName: projectLabelOf(item),
69
+ sessionKey: sessionKeyOf(item),
70
+ sessionName: sessionLabelOf(item),
71
+ threadId: item.threadId || null,
72
+ createdAt: item.timestamps?.createdAt || null,
73
+ status: item.status,
74
+ trashedAt: item.trashedAt || null,
75
+ preview: String(item.speakableText || "")
76
+ .replace(/\s+/g, " ")
77
+ .trim()
78
+ .slice(0, 220)
79
+ };
80
+ }
81
+
82
+ function groupItems(items) {
83
+ const projects = new Map();
84
+ for (const item of items) {
85
+ const projectKey = projectKeyOf(item);
86
+ if (!projects.has(projectKey)) {
87
+ projects.set(projectKey, {
88
+ key: projectKey,
89
+ label: projectLabelOf(item),
90
+ sessions: new Map(),
91
+ itemCount: 0
92
+ });
93
+ }
94
+ const project = projects.get(projectKey);
95
+ project.itemCount += 1;
96
+ const sessionKey = sessionKeyOf(item);
97
+ if (!project.sessions.has(sessionKey)) {
98
+ project.sessions.set(sessionKey, {
99
+ key: sessionKey,
100
+ label: sessionLabelOf(item),
101
+ itemCount: 0,
102
+ latestAt: ""
103
+ });
104
+ }
105
+ const session = project.sessions.get(sessionKey);
106
+ session.itemCount += 1;
107
+ session.latestAt = item.timestamps?.createdAt || session.latestAt;
108
+ }
109
+ return [...projects.values()].map((project) => ({
110
+ ...project,
111
+ sessions: [...project.sessions.values()]
112
+ }));
113
+ }
114
+
115
+ function normalize(value) {
116
+ return String(value || "")
117
+ .replace(/\s+/g, " ")
118
+ .toLowerCase();
119
+ }
120
+
121
+ function searchItems(items, query) {
122
+ const terms = normalize(query).split(/\s+/).filter(Boolean);
123
+ if (terms.length === 0) return [];
124
+ return items
125
+ .filter((item) => {
126
+ const haystack = normalize(
127
+ [
128
+ item.id,
129
+ item.threadId,
130
+ item.threadLabel,
131
+ item.sessionName,
132
+ projectLabelOf(item),
133
+ sessionLabelOf(item),
134
+ item.speakableText,
135
+ ...(Array.isArray(item.userMessages) ? item.userMessages : [])
136
+ ].join(" ")
137
+ );
138
+ return terms.every((term) => haystack.includes(term));
139
+ })
140
+ .map(compactItem);
141
+ }
142
+
143
+ function queueTargetSchema() {
144
+ return {
145
+ itemIds: z.array(z.string()).optional(),
146
+ projectKey: z.string().optional(),
147
+ sessionKey: z.string().optional(),
148
+ all: z.boolean().optional()
149
+ };
150
+ }
151
+
152
+ function applyTarget(queueStore, args, mode) {
153
+ if (Array.isArray(args.itemIds)) {
154
+ return mode === "trash"
155
+ ? queueStore.trashItems(args.itemIds)
156
+ : queueStore.restoreItems(args.itemIds);
157
+ }
158
+ if (args.projectKey) {
159
+ return mode === "trash"
160
+ ? queueStore.trashByProject(args.projectKey)
161
+ : queueStore.restoreByProject(args.projectKey);
162
+ }
163
+ if (args.sessionKey) {
164
+ return mode === "trash"
165
+ ? queueStore.trashBySession(args.sessionKey)
166
+ : queueStore.restoreBySession(args.sessionKey);
167
+ }
168
+ if (args.all === true) {
169
+ return mode === "trash" ? queueStore.trashAll() : queueStore.restoreAll();
170
+ }
171
+ throw new Error("Provide itemIds, projectKey, sessionKey, or all=true.");
172
+ }
173
+
174
+ function idsByProject(queueStore, projectKey) {
175
+ return visibleItems(queueStore)
176
+ .filter((item) => projectKeyOf(item) === projectKey)
177
+ .map((item) => item.id);
178
+ }
179
+
180
+ function idsBySession(queueStore, sessionKey) {
181
+ return visibleItems(queueStore)
182
+ .filter((item) => sessionKeyOf(item) === sessionKey)
183
+ .map((item) => item.id);
184
+ }
185
+
186
+ function registerAgentHotlineTools(server, { queueStore, settingsStore, audioCacheStore }) {
187
+ server.registerTool(
188
+ "get_state",
189
+ {
190
+ title: "Get Agent Hotline State",
191
+ description: "Return settings plus compact visible and trashed chat counts."
192
+ },
193
+ async () =>
194
+ textResult({
195
+ settings: settingsStore.load(),
196
+ visibleCount: visibleItems(queueStore).length,
197
+ trashedCount: trashedItems(queueStore).length,
198
+ pending: queueStore.getPending().map(compactItem),
199
+ current: queueStore.getCurrent() ? compactItem(queueStore.getCurrent()) : null,
200
+ latest: queueStore.getLatest() ? compactItem(queueStore.getLatest()) : null
201
+ })
202
+ );
203
+
204
+ server.registerTool(
205
+ "list_projects",
206
+ {
207
+ title: "List Projects",
208
+ description: "List visible Agent Hotline projects and sessions."
209
+ },
210
+ async () => textResult(groupItems(visibleItems(queueStore)))
211
+ );
212
+
213
+ server.registerTool(
214
+ "list_messages",
215
+ {
216
+ title: "List Messages",
217
+ description: "List visible messages, optionally scoped to a project/session.",
218
+ inputSchema: {
219
+ projectKey: z.string().optional(),
220
+ sessionKey: z.string().optional(),
221
+ limit: z.number().int().min(1).max(200).default(50)
222
+ }
223
+ },
224
+ async ({ projectKey, sessionKey, limit }) => {
225
+ let items = visibleItems(queueStore);
226
+ if (projectKey) items = items.filter((item) => projectKeyOf(item) === projectKey);
227
+ if (sessionKey) items = items.filter((item) => sessionKeyOf(item) === sessionKey);
228
+ return textResult(items.slice(-limit).map(compactItem));
229
+ }
230
+ );
231
+
232
+ server.registerTool(
233
+ "read_message",
234
+ {
235
+ title: "Read Message",
236
+ description: "Read one full visible or trashed message by id.",
237
+ inputSchema: { itemId: z.string() }
238
+ },
239
+ async ({ itemId }) => {
240
+ const item = queueStore.getState().items.find((entry) => entry.id === itemId);
241
+ if (!item) throw new Error(`Message not found: ${itemId}`);
242
+ return textResult({
243
+ ...compactItem(item),
244
+ speakableText: item.speakableText,
245
+ userMessages: item.userMessages || []
246
+ });
247
+ }
248
+ );
249
+
250
+ server.registerTool(
251
+ "search_messages",
252
+ {
253
+ title: "Search Messages",
254
+ description:
255
+ "Search visible messages by keyword, project, session, id, prompt, or spoken text.",
256
+ inputSchema: { query: z.string().min(1), limit: z.number().int().min(1).max(100).default(25) }
257
+ },
258
+ async ({ query, limit }) =>
259
+ textResult(searchItems(visibleItems(queueStore), query).slice(0, limit))
260
+ );
261
+
262
+ server.registerTool(
263
+ "list_trash",
264
+ {
265
+ title: "List Trash",
266
+ description: "List trashed Agent Hotline projects, sessions, and messages."
267
+ },
268
+ async () =>
269
+ textResult({
270
+ groups: groupItems(trashedItems(queueStore)),
271
+ messages: trashedItems(queueStore).map(compactItem)
272
+ })
273
+ );
274
+
275
+ server.registerTool(
276
+ "move_to_trash",
277
+ {
278
+ title: "Move Chats To Trash",
279
+ description: "Move messages/projects/sessions to trash and delete their saved audio.",
280
+ inputSchema: queueTargetSchema()
281
+ },
282
+ async (args) => {
283
+ const ids = applyTarget(queueStore, args, "trash");
284
+ const removedAudio = audioCacheStore.removeByItemIds(ids);
285
+ return textResult({ trashed: ids, removedAudio });
286
+ }
287
+ );
288
+
289
+ server.registerTool(
290
+ "restore_from_trash",
291
+ {
292
+ title: "Restore Chats From Trash",
293
+ description: "Restore trashed messages/projects/sessions.",
294
+ inputSchema: queueTargetSchema()
295
+ },
296
+ async (args) => textResult({ restored: applyTarget(queueStore, args, "restore") })
297
+ );
298
+
299
+ server.registerTool(
300
+ "list_audio_cache",
301
+ {
302
+ title: "List Audio Cache",
303
+ description: "List saved audio recordings."
304
+ },
305
+ async () => textResult(audioCacheStore.list())
306
+ );
307
+
308
+ server.registerTool(
309
+ "delete_audio_cache",
310
+ {
311
+ title: "Delete Audio Cache",
312
+ description: "Delete saved audio only, leaving chats visible.",
313
+ inputSchema: {
314
+ itemId: z.string().optional(),
315
+ projectKey: z.string().optional(),
316
+ sessionKey: z.string().optional(),
317
+ all: z.boolean().optional()
318
+ }
319
+ },
320
+ async ({ itemId, projectKey, sessionKey, all }) => {
321
+ let removed;
322
+ if (itemId) removed = audioCacheStore.removeOne(itemId);
323
+ else if (projectKey)
324
+ removed = audioCacheStore.removeByItemIds(idsByProject(queueStore, projectKey));
325
+ else if (sessionKey)
326
+ removed = audioCacheStore.removeByItemIds(idsBySession(queueStore, sessionKey));
327
+ else if (all === true) removed = audioCacheStore.clearAll();
328
+ else throw new Error("Provide itemId, projectKey, sessionKey, or all=true.");
329
+ return textResult({ removed });
330
+ }
331
+ );
332
+
333
+ server.registerTool(
334
+ "get_settings",
335
+ {
336
+ title: "Get Settings",
337
+ description: "Read Agent Hotline settings."
338
+ },
339
+ async () => textResult(settingsStore.load())
340
+ );
341
+
342
+ server.registerTool(
343
+ "update_settings",
344
+ {
345
+ title: "Update Settings",
346
+ description: "Update safe Agent Hotline settings.",
347
+ inputSchema: {
348
+ readBehavior: z.enum(["manual", "auto"]).optional(),
349
+ mute: z.boolean().optional(),
350
+ engine: z.enum(["webview", "kokoro", "kokoro-ts"]).optional(),
351
+ voice: z.string().optional(),
352
+ audioOutputDeviceId: z.string().optional(),
353
+ kokoroVoice: z.string().optional(),
354
+ rate: z.number().min(0.1).max(10).optional(),
355
+ volume: z.number().min(0).max(1).optional(),
356
+ notifyOnNewReply: z.boolean().optional(),
357
+ notificationOpens: z.enum(["full", "mini"]).optional(),
358
+ highlightSpokenText: z.boolean().optional(),
359
+ audioCacheLimitMb: z.number().min(10).max(100000).optional()
360
+ }
361
+ },
362
+ async (args) => textResult(settingsStore.update(args))
363
+ );
364
+ }
365
+
366
+ export async function createAgentHotlineMcpServer(options = {}) {
367
+ const settingsStore = options.settingsStore || createSettingsStore({ dataDir: options.dataDir });
368
+ const queueStore = options.queueStore || createSpeechQueueStore({ dataDir: options.dataDir });
369
+ const audioCacheStore =
370
+ options.audioCacheStore ||
371
+ createAudioCacheStore({
372
+ dataDir: options.dataDir,
373
+ getMaxBytes: () => Number(settingsStore.load().audioCacheLimitMb) * 1024 * 1024
374
+ });
375
+ const server = new McpServer({ name: "agent-hotline", version: "0.1.0" });
376
+ registerAgentHotlineTools(server, { queueStore, settingsStore, audioCacheStore });
377
+ return server;
378
+ }
379
+
380
+ export async function runStdioServer() {
381
+ const server = await createAgentHotlineMcpServer();
382
+ await server.connect(new StdioServerTransport());
383
+ }
384
+
385
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
386
+ runStdioServer().catch((error) => {
387
+ console.error(error);
388
+ process.exit(1);
389
+ });
390
+ }
@@ -280,7 +280,7 @@ function sendBinary(res, status, buffer, contentType) {
280
280
  }
281
281
 
282
282
  function sessionKeyForItem(item) {
283
- return item.threadId || `app:${item.sourceApp}`;
283
+ return item.sessionKey || item.threadId || `app:${item.sourceApp}`;
284
284
  }
285
285
 
286
286
  // Stable project identity from a (possibly inconsistently-shaped) path. Stripping
@@ -305,6 +305,7 @@ function validateSettingsPatch(patch) {
305
305
  "mute",
306
306
  "engine",
307
307
  "voice",
308
+ "audioOutputDeviceId",
308
309
  "kokoroVoice",
309
310
  "rate",
310
311
  "volume",
@@ -339,6 +340,9 @@ function validateSettingsPatch(patch) {
339
340
  errors.push("engine must be webview or kokoro");
340
341
  }
341
342
  if ("voice" in patch && typeof patch.voice !== "string") errors.push("voice must be string");
343
+ if ("audioOutputDeviceId" in patch && typeof patch.audioOutputDeviceId !== "string") {
344
+ errors.push("audioOutputDeviceId must be string");
345
+ }
342
346
  if ("kokoroVoice" in patch && typeof patch.kokoroVoice !== "string") {
343
347
  errors.push("kokoroVoice must be string");
344
348
  }
@@ -410,6 +414,51 @@ function queueState(queueStore) {
410
414
  };
411
415
  }
412
416
 
417
+ function visibleQueueItems(queueStore) {
418
+ return queueStore.getState().items.filter((item) => !item.trashedAt);
419
+ }
420
+
421
+ function trashedQueueItems(queueStore) {
422
+ return queueStore.getState().items.filter((item) => item.trashedAt);
423
+ }
424
+
425
+ function queueItemIdsByProject(queueStore, projectKey, { trashed = false } = {}) {
426
+ const items = trashed ? trashedQueueItems(queueStore) : visibleQueueItems(queueStore);
427
+ return items.filter((item) => projectKeyForItem(item) === projectKey).map((item) => item.id);
428
+ }
429
+
430
+ function queueItemIdsBySession(queueStore, sessionKey, { trashed = false } = {}) {
431
+ const items = trashed ? trashedQueueItems(queueStore) : visibleQueueItems(queueStore);
432
+ return items.filter((item) => sessionKeyForItem(item) === sessionKey).map((item) => item.id);
433
+ }
434
+
435
+ function applyQueueTarget(queueStore, body, mode) {
436
+ requirePlainObject(body, "Queue target must be a JSON object");
437
+ if (Array.isArray(body.itemIds)) {
438
+ return mode === "trash"
439
+ ? queueStore.trashItems(body.itemIds)
440
+ : queueStore.restoreItems(body.itemIds);
441
+ }
442
+ if (typeof body.projectKey === "string" && body.projectKey.trim()) {
443
+ return mode === "trash"
444
+ ? queueStore.trashByProject(body.projectKey)
445
+ : queueStore.restoreByProject(body.projectKey);
446
+ }
447
+ if (typeof body.sessionKey === "string" && body.sessionKey.trim()) {
448
+ return mode === "trash"
449
+ ? queueStore.trashBySession(body.sessionKey)
450
+ : queueStore.restoreBySession(body.sessionKey);
451
+ }
452
+ if (body.all === true) {
453
+ return mode === "trash" ? queueStore.trashAll() : queueStore.restoreAll();
454
+ }
455
+ throw createHttpError(
456
+ 400,
457
+ "invalid_request",
458
+ "Provide itemIds, projectKey, sessionKey, or all=true"
459
+ );
460
+ }
461
+
413
462
  function getPathname(req) {
414
463
  return new URL(req.url, "http://127.0.0.1").pathname;
415
464
  }
@@ -422,12 +471,21 @@ function page() {
422
471
  <meta name="viewport" content="width=device-width, initial-scale=1">
423
472
  <title>Agent Hotline</title>
424
473
  <style>
425
- :root { color-scheme: light dark; font-family: Inter, Segoe UI, system-ui, sans-serif; }
474
+ :root {
475
+ color-scheme: light dark;
476
+ --font-ui: "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
477
+ --type-body: 0.8125rem;
478
+ --type-ui: 0.875rem;
479
+ --type-title: 1.875rem;
480
+ --line-copy: 1.45;
481
+ font-family: var(--font-ui);
482
+ }
483
+ body { font-size: var(--type-body); }
426
484
  body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #101418; color: #eef2f4; }
427
485
  main { width: min(860px, calc(100vw - 32px)); }
428
486
  .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; }
487
+ .stage { color: #8fb7ff; font-size: var(--type-ui); margin-bottom: 16px; }
488
+ h1 { font-size: var(--type-title); line-height: 1.2; margin: 0 0 16px; letter-spacing: 0; }
431
489
  .rec { color: #c7d0d8; border-left: 3px solid #58c48d; padding-left: 12px; margin: 18px 0; }
432
490
  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
491
  .row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; align-items: center; }
@@ -436,7 +494,7 @@ function page() {
436
494
  button:disabled { opacity: .5; cursor: not-allowed; }
437
495
  a { color: #9fc4ff; text-decoration: none; }
438
496
  a:hover { text-decoration: underline; }
439
- .meta { color: #9ba8b2; font-size: 13px; margin-top: 16px; }
497
+ .meta { color: #9ba8b2; font-size: var(--type-body); margin-top: 16px; }
440
498
  .done { color: #8ee6b0; }
441
499
  </style>
442
500
  </head>
@@ -626,6 +684,7 @@ function createServer(options = {}) {
626
684
  rawSource: body.rawSource,
627
685
  speakableText: body.speakableText,
628
686
  sourceApp: body.sourceApp,
687
+ sessionKey: body.sessionKey,
629
688
  threadId: body.threadId,
630
689
  threadLabel: body.threadLabel,
631
690
  sessionName: body.sessionName,
@@ -670,6 +729,21 @@ function createServer(options = {}) {
670
729
  return;
671
730
  }
672
731
 
732
+ if (req.method === "POST" && pathname === "/api/queue/trash") {
733
+ const body = await readJsonBody(req);
734
+ const ids = applyQueueTarget(queueStore, body, "trash");
735
+ const removedAudio = audioCacheStore.removeByItemIds(ids);
736
+ sendJson(res, 200, { trashed: ids, removedAudio, queue: queueState(queueStore) });
737
+ return;
738
+ }
739
+
740
+ if (req.method === "POST" && pathname === "/api/queue/restore") {
741
+ const body = await readJsonBody(req);
742
+ const ids = applyQueueTarget(queueStore, body, "restore");
743
+ sendJson(res, 200, { restored: ids, queue: queueState(queueStore) });
744
+ return;
745
+ }
746
+
673
747
  const replayMatch = pathname.match(/^\/api\/queue\/([^/]+)\/replay$/);
674
748
  if (req.method === "POST" && replayMatch) {
675
749
  const item = queueStore.replayItem(decodeURIComponent(replayMatch[1]));
@@ -728,33 +802,36 @@ function createServer(options = {}) {
728
802
 
729
803
  if (req.method === "GET" && pathname === "/api/audio-cache") {
730
804
  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) => {
805
+ const itemsById = new Map(visibleQueueItems(queueStore).map((item) => [item.id, item]));
806
+ const enriched = entries.flatMap((entry) => {
733
807
  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
- };
808
+ if (!item) return [];
809
+ return [
810
+ {
811
+ itemId: entry.itemId,
812
+ engine: entry.engine,
813
+ voice: entry.voice,
814
+ bytes: entry.bytes,
815
+ durationSec: entry.durationSec,
816
+ wordAccurate: entry.wordAccurate,
817
+ createdAt: entry.createdAt,
818
+ lastAccessedAt: entry.lastAccessedAt,
819
+ sourceApp: item.sourceApp,
820
+ threadId: item.threadId || null,
821
+ sessionName: item.sessionName || item.threadLabel || null,
822
+ sessionKey: sessionKeyForItem(item),
823
+ projectPath: item.projectPath || null,
824
+ projectName: item.projectName || null,
825
+ projectKey: projectKeyForItem(item),
826
+ itemCreatedAt: item.timestamps && item.timestamps.createdAt,
827
+ preview: item
828
+ ? String(item.speakableText || "")
829
+ .replace(/\s+/g, " ")
830
+ .trim()
831
+ .slice(0, 80)
832
+ : ""
833
+ }
834
+ ];
758
835
  });
759
836
  sendJson(res, 200, { entries: enriched, totalBytes, maxBytes });
760
837
  return;
@@ -777,19 +854,13 @@ function createServer(options = {}) {
777
854
  }
778
855
  const session = query.get("session");
779
856
  if (session) {
780
- const ids = queueStore
781
- .getState()
782
- .items.filter((item) => sessionKeyForItem(item) === session)
783
- .map((item) => item.id);
857
+ const ids = queueItemIdsBySession(queueStore, session);
784
858
  sendJson(res, 200, { removed: audioCacheStore.removeByItemIds(ids) });
785
859
  return;
786
860
  }
787
861
  const project = query.get("project");
788
862
  if (project) {
789
- const ids = queueStore
790
- .getState()
791
- .items.filter((item) => projectKeyForItem(item) === project)
792
- .map((item) => item.id);
863
+ const ids = queueItemIdsByProject(queueStore, project);
793
864
  sendJson(res, 200, { removed: audioCacheStore.removeByItemIds(ids) });
794
865
  return;
795
866
  }
@@ -14,6 +14,7 @@ const DEFAULT_SETTINGS = Object.freeze({
14
14
  mute: false,
15
15
  engine: "webview",
16
16
  voice: "",
17
+ audioOutputDeviceId: "",
17
18
  kokoroVoice: "af_heart",
18
19
  rate: 0.92,
19
20
  volume: 1,
@@ -90,6 +91,7 @@ function normalizeSettings(input) {
90
91
  mute: booleanOrDefault(source.mute, defaults.mute),
91
92
  engine: TTS_ENGINES.has(source.engine) ? source.engine : defaults.engine,
92
93
  voice: stringOrDefault(source.voice, defaults.voice),
94
+ audioOutputDeviceId: stringOrDefault(source.audioOutputDeviceId, defaults.audioOutputDeviceId),
93
95
  kokoroVoice: stringOrDefault(source.kokoroVoice, defaults.kokoroVoice),
94
96
  rate: numberInRangeOrDefault(source.rate, defaults.rate, 0.1, 10),
95
97
  volume: numberInRangeOrDefault(source.volume, defaults.volume, 0, 1),
@@ -144,6 +144,46 @@ function createSpeechQueueStore(options = {}) {
144
144
  return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
145
145
  }
146
146
 
147
+ function isVisible(item) {
148
+ return !item.trashedAt;
149
+ }
150
+
151
+ function sessionKeyForItem(item) {
152
+ return item.sessionKey || item.threadId || `app:${item.sourceApp}`;
153
+ }
154
+
155
+ function projectKeyForItem(item) {
156
+ if (!item.projectPath) return `direct:${item.sourceApp}`;
157
+ return String(item.projectPath).replace(/[\\/]/g, "").toLowerCase();
158
+ }
159
+
160
+ function trashMatching(predicate) {
161
+ const timestamp = now();
162
+ const ids = [];
163
+ for (const item of state.items) {
164
+ if (item.trashedAt || !predicate(item)) continue;
165
+ item.trashedAt = timestamp;
166
+ touch(item, timestamp);
167
+ ids.push(item.id);
168
+ }
169
+ if (ids.length) persist();
170
+ return ids;
171
+ }
172
+
173
+ function restoreMatching(predicate) {
174
+ const timestamp = now();
175
+ const ids = [];
176
+ for (const item of state.items) {
177
+ if (!item.trashedAt || !predicate(item)) continue;
178
+ delete item.trashedAt;
179
+ item.timestamps.restoredAt = timestamp;
180
+ touch(item, timestamp);
181
+ ids.push(item.id);
182
+ }
183
+ if (ids.length) persist();
184
+ return ids;
185
+ }
186
+
147
187
  function enqueue(input) {
148
188
  const timestamp = now();
149
189
  const item = {
@@ -160,6 +200,8 @@ function createSpeechQueueStore(options = {}) {
160
200
 
161
201
  const threadId = optionalString(input.threadId);
162
202
  if (threadId) item.threadId = threadId;
203
+ const sessionKey = optionalString(input.sessionKey);
204
+ if (sessionKey) item.sessionKey = sessionKey;
163
205
  const threadLabel = optionalString(input.threadLabel);
164
206
  if (threadLabel) item.threadLabel = threadLabel;
165
207
  const sessionName = optionalString(input.sessionName);
@@ -181,16 +223,17 @@ function createSpeechQueueStore(options = {}) {
181
223
  }
182
224
 
183
225
  function getPending() {
184
- return clone(state.items.filter((item) => item.status === "pending"));
226
+ return clone(state.items.filter((item) => isVisible(item) && item.status === "pending"));
185
227
  }
186
228
 
187
229
  function getCurrent() {
188
- const current = state.items.find((item) => item.status === "playing") || null;
230
+ const current =
231
+ state.items.find((item) => isVisible(item) && item.status === "playing") || null;
189
232
  return current ? clone(current) : null;
190
233
  }
191
234
 
192
235
  function getLatest() {
193
- const latest = state.items[state.items.length - 1] || null;
236
+ const latest = [...state.items].reverse().find(isVisible) || null;
194
237
  return latest ? clone(latest) : null;
195
238
  }
196
239
 
@@ -249,6 +292,7 @@ function createSpeechQueueStore(options = {}) {
249
292
  }
250
293
  };
251
294
  if (source.threadId) item.threadId = source.threadId;
295
+ if (source.sessionKey) item.sessionKey = source.sessionKey;
252
296
  if (source.threadLabel) item.threadLabel = source.threadLabel;
253
297
  if (source.sessionName) item.sessionName = source.sessionName;
254
298
  if (source.projectPath) item.projectPath = source.projectPath;
@@ -265,18 +309,52 @@ function createSpeechQueueStore(options = {}) {
265
309
  function replayLatest() {
266
310
  const latest = [...state.items]
267
311
  .reverse()
268
- .find((item) => item.speakableText && item.status !== "skipped");
312
+ .find((item) => isVisible(item) && item.speakableText && item.status !== "skipped");
269
313
  return latest ? pushReplay(latest) : null;
270
314
  }
271
315
 
272
316
  function replayItem(id) {
273
317
  const source = findItem(id);
274
- if (!source || !source.speakableText) {
318
+ if (!source || source.trashedAt || !source.speakableText) {
275
319
  return null;
276
320
  }
277
321
  return pushReplay(source);
278
322
  }
279
323
 
324
+ function trashItems(ids) {
325
+ const idSet = new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === "string"));
326
+ return trashMatching((item) => idSet.has(item.id));
327
+ }
328
+
329
+ function trashByProject(projectKey) {
330
+ return trashMatching((item) => projectKeyForItem(item) === projectKey);
331
+ }
332
+
333
+ function trashBySession(sessionKey) {
334
+ return trashMatching((item) => sessionKeyForItem(item) === sessionKey);
335
+ }
336
+
337
+ function trashAll() {
338
+ return trashMatching(() => true);
339
+ }
340
+
341
+ function restoreItems(ids) {
342
+ const idSet = new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === "string"));
343
+ return restoreMatching((item) => idSet.has(item.id));
344
+ }
345
+
346
+ function restoreByProject(projectKey) {
347
+ return restoreMatching((item) => projectKeyForItem(item) === projectKey);
348
+ }
349
+
350
+ function restoreBySession(sessionKey) {
351
+ return restoreMatching((item) => sessionKeyForItem(item) === sessionKey);
352
+ }
353
+
354
+ function restoreAll() {
355
+ return restoreMatching(() => true);
356
+ }
357
+
280
358
  function clearQueue() {
281
359
  state = { items: [] };
282
360
  persist();
@@ -298,6 +376,14 @@ function createSpeechQueueStore(options = {}) {
298
376
  markSkipped,
299
377
  replayLatest,
300
378
  replayItem,
379
+ trashItems,
380
+ trashByProject,
381
+ trashBySession,
382
+ trashAll,
383
+ restoreItems,
384
+ restoreByProject,
385
+ restoreBySession,
386
+ restoreAll,
301
387
  clearQueue,
302
388
  getState
303
389
  };
@@ -313,7 +399,8 @@ function isValidPersistedItem(item) {
313
399
  STATUSES.has(item.status) &&
314
400
  item.timestamps &&
315
401
  typeof item.timestamps.createdAt === "string" &&
316
- typeof item.timestamps.updatedAt === "string"
402
+ typeof item.timestamps.updatedAt === "string" &&
403
+ (!("trashedAt" in item) || typeof item.trashedAt === "string")
317
404
  );
318
405
  }
319
406