@micsushi/agent-hotline 0.5.3 → 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 +3 -2
- package/packages/backend/bin/agent-hotline-mcp.js +2 -0
- package/packages/backend/package.json +8 -3
- package/packages/backend/src/mcp-server.mjs +390 -0
- package/packages/backend/src/server.js +110 -39
- package/packages/backend/src/settings-store.js +2 -0
- package/packages/backend/src/speech-queue-store.js +93 -6
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@micsushi/agent-hotline",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -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 {
|
|
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:
|
|
430
|
-
h1 { font-size:
|
|
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:
|
|
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
|
|
732
|
-
const enriched = entries.
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|