poddrill 0.0.0-stage → 0.1.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/dist/owner.js ADDED
@@ -0,0 +1,770 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createServer, createConnection } from "node:net";
3
+ import { rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { appendAudit, auditRecord, readAudit } from "./audit.js";
7
+ import { errorCode } from "./disk.js";
8
+ import { IDLE_FLEET_MS, watchIdleFleet } from "./idle-fleet.js";
9
+ import { embedWeightsPath, openEmbedHost } from "./embed.js";
10
+ import { readPackageIdentity } from "./package-identity.js";
11
+ import { JobQueue } from "./jobs.js";
12
+ import { openWhistleCall } from "./provider.js";
13
+ import { readConfig } from "./workspace.js";
14
+ /**
15
+ * macOS rejects a unix socket path longer than 104 bytes.
16
+ * A workspace under /var/folders exceeds that, so the socket lives in the temp dir.
17
+ */
18
+ export function socketPath(root) {
19
+ const digest = createHash("sha256").update(root).digest("hex").slice(0, 16);
20
+ return join(tmpdir(), `pw-${digest}.sock`);
21
+ }
22
+ export function render(value) {
23
+ return `${JSON.stringify(value)}\n`;
24
+ }
25
+ export function callOwner(root, request) {
26
+ return new Promise((resolve, reject) => {
27
+ const socket = createConnection(socketPath(root));
28
+ const chunks = [];
29
+ socket.setTimeout(10_000);
30
+ socket.on("data", (chunk) => {
31
+ chunks.push(chunk);
32
+ });
33
+ socket.on("timeout", () => {
34
+ socket.destroy(new Error("poddrill serve timed out"));
35
+ });
36
+ socket.on("error", reject);
37
+ socket.on("end", () => {
38
+ resolve(Buffer.concat(chunks).toString("utf8"));
39
+ });
40
+ socket.write(`${JSON.stringify(request)}\n`);
41
+ });
42
+ }
43
+ export async function ownerAlive(root) {
44
+ try {
45
+ const text = await callOwner(root, { op: "ping" });
46
+ return text === "{\"op\":\"pong\"}\n";
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ export async function serve(root, mcpOnStdio) {
53
+ const sock = socketPath(root);
54
+ const waiting = [];
55
+ let queue;
56
+ // allowHalfOpen so a client FIN cannot drop the reply while start is still reading audio.
57
+ const server = createServer({ allowHalfOpen: true }, (connection) => {
58
+ const opened = queue;
59
+ if (opened === undefined) {
60
+ // Answer after the queue opens. A refused connect would let another owner unlink this socket.
61
+ connection.pause();
62
+ waiting.push(connection);
63
+ return;
64
+ }
65
+ accept(connection, opened, root);
66
+ });
67
+ let claimed = false;
68
+ try {
69
+ claimed = await takeSocket(server, root);
70
+ }
71
+ catch (error) {
72
+ await closeIdle(server);
73
+ throw error;
74
+ }
75
+ if (!claimed) {
76
+ await closeIdle(server);
77
+ return;
78
+ }
79
+ const config = await readConfig(root);
80
+ const hosts = {
81
+ whistle: openWhistleCall(config.provider, {
82
+ fleetLimit: config.fleetLimit,
83
+ modelFleet: config.modelFleet,
84
+ modelWeights: config.modelWeights,
85
+ }),
86
+ embed: openEmbedHost(config.embeddingLimit, embedWeightsPath(process.env)),
87
+ };
88
+ let opened;
89
+ try {
90
+ opened = await JobQueue.open(root, config, hosts);
91
+ }
92
+ catch (error) {
93
+ await closeHosts(hosts);
94
+ throw error;
95
+ }
96
+ queue = opened;
97
+ const watchers = [
98
+ watchIdleFleet({
99
+ idle: () => opened.idle("transcript"),
100
+ release: () => hosts.whistle.release(),
101
+ delayMs: IDLE_FLEET_MS,
102
+ pollMs: 1_000,
103
+ }),
104
+ watchIdleFleet({
105
+ idle: () => opened.idle("embedding"),
106
+ release: () => hosts.embed.release(),
107
+ delayMs: IDLE_FLEET_MS,
108
+ pollMs: 1_000,
109
+ }),
110
+ ];
111
+ const stopIdle = () => {
112
+ for (const watcher of watchers) {
113
+ watcher.stop();
114
+ }
115
+ };
116
+ for (const connection of waiting.splice(0, waiting.length)) {
117
+ accept(connection, opened, root);
118
+ }
119
+ const version = mcpOnStdio ? (await readPackageIdentity(import.meta.url)).version : undefined;
120
+ await shutdownOnSignal(server, sock, opened, hosts, stopIdle, version === undefined
121
+ ? undefined
122
+ : speakMcp(version, async (name, args) => callTool(opened, root, name, args)));
123
+ }
124
+ export async function proxyMcp(root) {
125
+ const identity = await readPackageIdentity(import.meta.url);
126
+ return speakMcp(identity.version, async (name, args) => {
127
+ if (name === "start" && typeof args["path"] === "string") {
128
+ const language = args["language"];
129
+ if (language !== undefined && typeof language !== "string") {
130
+ return render({ kind: "error", message: "language must be a string" });
131
+ }
132
+ return callOwner(root, {
133
+ op: "start",
134
+ audioPath: args["path"],
135
+ ...(typeof language === "string" ? { language } : {}),
136
+ });
137
+ }
138
+ if (name === "episode" && typeof args["episodeId"] === "string") {
139
+ return callOwner(root, { op: "episode", episodeId: args["episodeId"] });
140
+ }
141
+ if (name === "audit" && typeof args["episodeId"] === "string") {
142
+ return callOwner(root, { op: "audit", episodeId: args["episodeId"] });
143
+ }
144
+ if (name === "cues" && typeof args["episodeId"] === "string") {
145
+ const cues = cuesRequest(args["episodeId"], args);
146
+ return cues === undefined
147
+ ? render({ kind: "error", message: "invalid cues arguments" })
148
+ : callOwner(root, cues);
149
+ }
150
+ if (name === "labelGroups" && typeof args["episodeId"] === "string") {
151
+ return callOwner(root, { op: "labelGroups", episodeId: args["episodeId"], groups: args["groups"] });
152
+ }
153
+ const summary = summaryRequest(name, args);
154
+ if (summary !== undefined) {
155
+ return callOwner(root, summary);
156
+ }
157
+ return render({ kind: "error", message: `unknown tool ${name}` });
158
+ });
159
+ }
160
+ function accept(socket, queue, root) {
161
+ let buffer = "";
162
+ socket.setEncoding("utf8");
163
+ socket.on("data", (chunk) => {
164
+ buffer = `${buffer}${chunk}`;
165
+ const newline = buffer.indexOf("\n");
166
+ if (newline === -1) {
167
+ return;
168
+ }
169
+ const line = buffer.slice(0, newline);
170
+ buffer = "";
171
+ void handleLine(line, queue, root)
172
+ .then((response) => {
173
+ socket.end(response);
174
+ })
175
+ .catch((error) => {
176
+ const message = error instanceof Error ? error.message : String(error);
177
+ socket.end(render({ kind: "error", message }));
178
+ });
179
+ });
180
+ socket.resume();
181
+ }
182
+ async function handleLine(line, queue, root) {
183
+ let parsed;
184
+ try {
185
+ parsed = JSON.parse(line);
186
+ }
187
+ catch {
188
+ return render({ kind: "error", message: "malformed request" });
189
+ }
190
+ const request = parseRequest(parsed);
191
+ if (request === undefined) {
192
+ return render({ kind: "error", message: "unknown request" });
193
+ }
194
+ switch (request.op) {
195
+ case "ping":
196
+ return "{\"op\":\"pong\"}\n";
197
+ case "audit":
198
+ return render(await readAudit(root, request.episodeId));
199
+ case "start":
200
+ return finish(root, request, () => queue.start(request.audioPath, request.language ?? "en"));
201
+ case "episode":
202
+ return finish(root, request, () => queue.episode(request.episodeId));
203
+ case "cues":
204
+ return finish(root, request, () => queue.cues(request.episodeId, {
205
+ ...(request.limit === undefined ? {} : { limit: request.limit }),
206
+ ...(request.after === undefined ? {} : { after: request.after }),
207
+ ...(request.groupId === undefined ? {} : { groupId: request.groupId }),
208
+ ...(request.hosts === undefined ? {} : { hosts: request.hosts }),
209
+ }));
210
+ case "labelGroups":
211
+ return finish(root, request, () => queue.labelGroups(request.episodeId, request.groups));
212
+ case "startSummary":
213
+ return finish(root, request, () => queue.startSummary(request.episodeId));
214
+ case "setSummary":
215
+ return finish(root, request, () => queue.setSummary(request.episodeId, request.summaryId, request.summary));
216
+ case "putParagraph":
217
+ return finish(root, request, () => queue.putParagraph(request.episodeId, request.summaryId, request.body));
218
+ case "postParagraph":
219
+ return finish(root, request, () => queue.postParagraph(request.episodeId, request.summaryId, request.body));
220
+ case "summary":
221
+ return finish(root, request, () => queue.summary(request.episodeId, request.summaryId));
222
+ default: {
223
+ const leftover = request;
224
+ return render({ kind: "error", message: `unexpected ${JSON.stringify(leftover)}` });
225
+ }
226
+ }
227
+ }
228
+ async function finish(root, request, run) {
229
+ let response;
230
+ try {
231
+ response = await run();
232
+ }
233
+ catch (error) {
234
+ const message = error instanceof Error ? error.message : String(error);
235
+ response = { kind: "error", message };
236
+ }
237
+ const text = render(response);
238
+ try {
239
+ const record = auditRecord(request, response, new Date().toISOString());
240
+ const episodeId = record === undefined ? undefined : auditEpisodeId(request, response);
241
+ if (record !== undefined && episodeId !== undefined) {
242
+ await appendAudit(root, episodeId, record);
243
+ }
244
+ }
245
+ catch {
246
+ return text;
247
+ }
248
+ return text;
249
+ }
250
+ function auditEpisodeId(request, response) {
251
+ switch (request.op) {
252
+ case "ping":
253
+ case "audit":
254
+ return undefined;
255
+ case "start":
256
+ return startedEpisodeId(response);
257
+ case "episode":
258
+ case "cues":
259
+ case "labelGroups":
260
+ case "startSummary":
261
+ case "setSummary":
262
+ case "putParagraph":
263
+ case "postParagraph":
264
+ case "summary":
265
+ return request.episodeId;
266
+ default: {
267
+ const leftover = request;
268
+ throw new Error(`unexpected ${JSON.stringify(leftover)}`);
269
+ }
270
+ }
271
+ }
272
+ function startedEpisodeId(response) {
273
+ const record = asRecord(response);
274
+ const episode = record === undefined ? undefined : asRecord(record["episode"]);
275
+ const episodeId = episode === undefined ? undefined : episode["episodeId"];
276
+ return typeof episodeId === "string" ? episodeId : undefined;
277
+ }
278
+ async function callTool(queue, root, name, args) {
279
+ if (name === "audit" && typeof args["episodeId"] === "string") {
280
+ return render(await readAudit(root, args["episodeId"]));
281
+ }
282
+ if (name === "start" && typeof args["path"] === "string") {
283
+ const language = args["language"];
284
+ if (language !== undefined && typeof language !== "string") {
285
+ return render({ kind: "error", message: "language must be a string" });
286
+ }
287
+ const request = {
288
+ op: "start",
289
+ audioPath: args["path"],
290
+ ...(typeof language === "string" ? { language } : {}),
291
+ };
292
+ return finish(root, request, () => queue.start(request.audioPath, request.language ?? "en"));
293
+ }
294
+ if (name === "episode" && typeof args["episodeId"] === "string") {
295
+ const episodeId = args["episodeId"];
296
+ return finish(root, { op: "episode", episodeId }, () => queue.episode(episodeId));
297
+ }
298
+ if (name === "cues" && typeof args["episodeId"] === "string") {
299
+ const cues = cuesRequest(args["episodeId"], args);
300
+ return cues === undefined
301
+ ? render({ kind: "error", message: "invalid cues arguments" })
302
+ : finish(root, cues, () => queue.cues(cues.episodeId, {
303
+ ...(cues.limit === undefined ? {} : { limit: cues.limit }),
304
+ ...(cues.after === undefined ? {} : { after: cues.after }),
305
+ ...(cues.groupId === undefined ? {} : { groupId: cues.groupId }),
306
+ ...(cues.hosts === undefined ? {} : { hosts: cues.hosts }),
307
+ }));
308
+ }
309
+ if (name === "labelGroups" && typeof args["episodeId"] === "string") {
310
+ const episodeId = args["episodeId"];
311
+ const groups = args["groups"];
312
+ return finish(root, { op: "labelGroups", episodeId, groups }, () => queue.labelGroups(episodeId, groups));
313
+ }
314
+ const summary = summaryRequest(name, args);
315
+ if (summary !== undefined) {
316
+ return finish(root, summary, () => runSummary(queue, summary));
317
+ }
318
+ return render({ kind: "error", message: `unknown tool ${name}` });
319
+ }
320
+ function runSummary(queue, request) {
321
+ switch (request.op) {
322
+ case "startSummary":
323
+ return queue.startSummary(request.episodeId);
324
+ case "setSummary":
325
+ return queue.setSummary(request.episodeId, request.summaryId, request.summary);
326
+ case "putParagraph":
327
+ return queue.putParagraph(request.episodeId, request.summaryId, request.body);
328
+ case "postParagraph":
329
+ return queue.postParagraph(request.episodeId, request.summaryId, request.body);
330
+ case "summary":
331
+ return queue.summary(request.episodeId, request.summaryId);
332
+ default: {
333
+ const leftover = request;
334
+ return Promise.reject(new Error(`unexpected ${JSON.stringify(leftover)}`));
335
+ }
336
+ }
337
+ }
338
+ function parseRequest(value) {
339
+ const record = asRecord(value);
340
+ if (record === undefined) {
341
+ return undefined;
342
+ }
343
+ const op = record["op"];
344
+ if (op === "ping") {
345
+ return { op: "ping" };
346
+ }
347
+ if (op === "start" && typeof record["audioPath"] === "string") {
348
+ const language = record["language"];
349
+ if (language !== undefined && typeof language !== "string") {
350
+ return undefined;
351
+ }
352
+ return {
353
+ op: "start",
354
+ audioPath: record["audioPath"],
355
+ ...(typeof language === "string" ? { language } : {}),
356
+ };
357
+ }
358
+ if (op === "episode" && typeof record["episodeId"] === "string") {
359
+ return { op: "episode", episodeId: record["episodeId"] };
360
+ }
361
+ if (op === "audit" && typeof record["episodeId"] === "string") {
362
+ return { op: "audit", episodeId: record["episodeId"] };
363
+ }
364
+ if (op === "cues" && typeof record["episodeId"] === "string") {
365
+ return cuesRequest(record["episodeId"], record);
366
+ }
367
+ if (op === "labelGroups" && typeof record["episodeId"] === "string") {
368
+ return { op: "labelGroups", episodeId: record["episodeId"], groups: record["groups"] };
369
+ }
370
+ if (typeof op === "string") {
371
+ return summaryRequest(op, record);
372
+ }
373
+ return undefined;
374
+ }
375
+ function summaryRequest(name, args) {
376
+ const episodeId = args["episodeId"];
377
+ if (typeof episodeId !== "string") {
378
+ return undefined;
379
+ }
380
+ const summaryId = args["summaryId"];
381
+ switch (name) {
382
+ case "startSummary":
383
+ return { op: "startSummary", episodeId };
384
+ case "setSummary":
385
+ return typeof summaryId === "string"
386
+ ? { op: "setSummary", episodeId, summaryId, summary: args["summary"] }
387
+ : undefined;
388
+ case "putParagraph":
389
+ return typeof summaryId === "string"
390
+ ? { op: "putParagraph", episodeId, summaryId, body: toolBody(args) }
391
+ : undefined;
392
+ case "postParagraph":
393
+ return typeof summaryId === "string"
394
+ ? { op: "postParagraph", episodeId, summaryId, body: toolBody(args) }
395
+ : undefined;
396
+ case "summary":
397
+ return typeof summaryId === "string" ? { op: "summary", episodeId, summaryId } : undefined;
398
+ default:
399
+ return undefined;
400
+ }
401
+ }
402
+ function toolBody(args) {
403
+ const body = {};
404
+ for (const [key, value] of Object.entries(args)) {
405
+ if (key === "episodeId" || key === "summaryId") {
406
+ continue;
407
+ }
408
+ body[key] = value;
409
+ }
410
+ return body;
411
+ }
412
+ function cuesRequest(episodeId, args) {
413
+ const limit = args["limit"];
414
+ const after = args["after"];
415
+ const groupId = args["groupId"];
416
+ const hosts = args["hosts"];
417
+ if (limit !== undefined && typeof limit !== "number") {
418
+ return undefined;
419
+ }
420
+ if (after !== undefined && typeof after !== "string") {
421
+ return undefined;
422
+ }
423
+ if (groupId !== undefined && typeof groupId !== "string") {
424
+ return undefined;
425
+ }
426
+ if (hosts !== undefined && typeof hosts !== "boolean") {
427
+ return undefined;
428
+ }
429
+ return {
430
+ op: "cues",
431
+ episodeId,
432
+ ...(typeof limit === "number" ? { limit } : {}),
433
+ ...(typeof after === "string" ? { after } : {}),
434
+ ...(typeof groupId === "string" ? { groupId } : {}),
435
+ ...(typeof hosts === "boolean" ? { hosts } : {}),
436
+ };
437
+ }
438
+ async function takeSocket(server, root) {
439
+ const sock = socketPath(root);
440
+ try {
441
+ await listen(server, sock);
442
+ return true;
443
+ }
444
+ catch (error) {
445
+ if (errorCode(error) !== "EADDRINUSE") {
446
+ throw error;
447
+ }
448
+ }
449
+ if (await ownerAlive(root)) {
450
+ return false;
451
+ }
452
+ // A leftover socket file makes bind return EADDRINUSE after the listener is gone.
453
+ await rm(sock, { force: true });
454
+ try {
455
+ await listen(server, sock);
456
+ return true;
457
+ }
458
+ catch (error) {
459
+ if (errorCode(error) === "EADDRINUSE") {
460
+ return false;
461
+ }
462
+ throw error;
463
+ }
464
+ }
465
+ function listen(server, sock) {
466
+ return new Promise((resolve, reject) => {
467
+ const fail = (error) => {
468
+ reject(error);
469
+ };
470
+ server.once("error", fail);
471
+ server.listen(sock, () => {
472
+ server.off("error", fail);
473
+ resolve();
474
+ });
475
+ });
476
+ }
477
+ function shutdownOnSignal(server, sock, queue, hosts, stopIdle, mcp) {
478
+ return new Promise((resolve) => {
479
+ let stopping = false;
480
+ const stop = () => {
481
+ if (stopping) {
482
+ return;
483
+ }
484
+ stopping = true;
485
+ void closeOwner(server, sock, queue, hosts, stopIdle).then(() => {
486
+ resolve();
487
+ });
488
+ };
489
+ process.on("SIGTERM", stop);
490
+ process.on("SIGINT", stop);
491
+ if (mcp !== undefined) {
492
+ void mcp.then(stop);
493
+ }
494
+ });
495
+ }
496
+ /** Stop the idle watchers, then the queue, so no lane takes new work. Then the hosts, so worker pipes close before exit. */
497
+ async function closeOwner(server, sock, queue, hosts, stopIdle) {
498
+ stopIdle();
499
+ await queue.close();
500
+ await closeHosts(hosts);
501
+ await new Promise((resolve) => {
502
+ server.close(() => {
503
+ resolve();
504
+ });
505
+ });
506
+ await rm(sock, { force: true });
507
+ process.exit(0);
508
+ }
509
+ async function closeHosts(hosts) {
510
+ await hosts.whistle.close();
511
+ await hosts.embed.close();
512
+ }
513
+ function closeIdle(server) {
514
+ // Node emits error on close when listen never succeeded, unless a callback is set.
515
+ return new Promise((resolve) => {
516
+ server.close(() => {
517
+ resolve();
518
+ });
519
+ });
520
+ }
521
+ function speakMcp(version, callTool) {
522
+ return new Promise((resolve) => {
523
+ let buffer = Buffer.alloc(0);
524
+ let chain = Promise.resolve();
525
+ const onData = (chunk) => {
526
+ buffer = Buffer.concat([buffer, chunk]);
527
+ const messages = takeMessages(buffer);
528
+ buffer = messages.rest;
529
+ for (const message of messages.bodies) {
530
+ chain = chain.then(() => respond(message, callTool, version)).catch((error) => {
531
+ const messageText = error instanceof Error ? error.message : String(error);
532
+ process.stderr.write(`${messageText}\n`);
533
+ });
534
+ }
535
+ };
536
+ process.stdin.on("data", onData);
537
+ process.stdin.on("end", () => {
538
+ process.stdin.off("data", onData);
539
+ resolve();
540
+ });
541
+ process.stdin.resume();
542
+ });
543
+ }
544
+ function takeMessages(buffer) {
545
+ const bodies = [];
546
+ let rest = buffer;
547
+ for (;;) {
548
+ const headerEnd = rest.indexOf("\r\n\r\n");
549
+ if (headerEnd === -1) {
550
+ return { bodies, rest };
551
+ }
552
+ const header = rest.subarray(0, headerEnd).toString("utf8");
553
+ const match = /Content-Length: (\d+)/i.exec(header);
554
+ if (match === null) {
555
+ return { bodies, rest: Buffer.alloc(0) };
556
+ }
557
+ const lengthText = match[1];
558
+ if (lengthText === undefined) {
559
+ return { bodies, rest: Buffer.alloc(0) };
560
+ }
561
+ const length = Number(lengthText);
562
+ const start = headerEnd + 4;
563
+ if (!Number.isInteger(length) || rest.length < start + length) {
564
+ return { bodies, rest };
565
+ }
566
+ const body = rest.subarray(start, start + length).toString("utf8");
567
+ rest = Buffer.from(rest.subarray(start + length));
568
+ try {
569
+ bodies.push(JSON.parse(body));
570
+ }
571
+ catch {
572
+ bodies.push(undefined);
573
+ }
574
+ }
575
+ }
576
+ async function respond(message, callTool, version) {
577
+ const record = asRecord(message);
578
+ if (record === undefined || record["jsonrpc"] !== "2.0") {
579
+ return;
580
+ }
581
+ const id = record["id"];
582
+ const method = record["method"];
583
+ if (typeof method !== "string" || (typeof id !== "number" && typeof id !== "string")) {
584
+ return;
585
+ }
586
+ if (method === "initialize") {
587
+ writeMessage({
588
+ jsonrpc: "2.0",
589
+ id,
590
+ result: {
591
+ protocolVersion: "2024-11-05",
592
+ capabilities: { tools: {} },
593
+ serverInfo: { name: "poddrill", version },
594
+ },
595
+ });
596
+ return;
597
+ }
598
+ if (method === "tools/list") {
599
+ writeMessage({
600
+ jsonrpc: "2.0",
601
+ id,
602
+ result: { tools: toolList() },
603
+ });
604
+ return;
605
+ }
606
+ if (method === "tools/call") {
607
+ const params = asRecord(record["params"]);
608
+ const name = params === undefined ? undefined : params["name"];
609
+ const args = params === undefined ? undefined : asRecord(params["arguments"]);
610
+ const text = typeof name === "string"
611
+ ? await callTool(name, args ?? {})
612
+ : render({ kind: "error", message: "missing tool name" });
613
+ writeMessage({
614
+ jsonrpc: "2.0",
615
+ id,
616
+ result: {
617
+ content: [{ type: "text", text }],
618
+ isError: text.startsWith("{\"kind\":\"error\""),
619
+ },
620
+ });
621
+ }
622
+ }
623
+ function toolList() {
624
+ return [
625
+ {
626
+ name: "start",
627
+ description: "Start an episode for one audio file under audio/.",
628
+ inputSchema: {
629
+ type: "object",
630
+ properties: { path: { type: "string" }, language: { type: "string" } },
631
+ required: ["path"],
632
+ additionalProperties: false,
633
+ },
634
+ },
635
+ {
636
+ name: "episode",
637
+ description: "Read one episode and its artifacts.",
638
+ inputSchema: {
639
+ type: "object",
640
+ properties: { episodeId: { type: "string" } },
641
+ required: ["episodeId"],
642
+ additionalProperties: false,
643
+ },
644
+ },
645
+ {
646
+ name: "cues",
647
+ description: "Read a page of transcript cues and their speaker groups.",
648
+ inputSchema: {
649
+ type: "object",
650
+ properties: {
651
+ episodeId: { type: "string" },
652
+ limit: { type: "number" },
653
+ after: { type: "string" },
654
+ groupId: { type: "string" },
655
+ hosts: { type: "boolean" },
656
+ },
657
+ required: ["episodeId"],
658
+ additionalProperties: false,
659
+ },
660
+ },
661
+ {
662
+ name: "labelGroups",
663
+ description: "Replace labels on the listed speaker groups.",
664
+ inputSchema: {
665
+ type: "object",
666
+ properties: {
667
+ episodeId: { type: "string" },
668
+ groups: { type: "array" },
669
+ },
670
+ required: ["episodeId", "groups"],
671
+ additionalProperties: false,
672
+ },
673
+ },
674
+ {
675
+ name: "startSummary",
676
+ description: "Start a summary for an episode and return a new id.",
677
+ inputSchema: {
678
+ type: "object",
679
+ properties: {
680
+ episodeId: { type: "string" },
681
+ },
682
+ required: ["episodeId"],
683
+ additionalProperties: false,
684
+ },
685
+ },
686
+ {
687
+ name: "setSummary",
688
+ description: "Replace the summary text.",
689
+ inputSchema: {
690
+ type: "object",
691
+ properties: {
692
+ episodeId: { type: "string" },
693
+ summaryId: { type: "string" },
694
+ summary: { type: "string" },
695
+ },
696
+ required: ["episodeId", "summaryId", "summary"],
697
+ additionalProperties: false,
698
+ },
699
+ },
700
+ {
701
+ name: "putParagraph",
702
+ description: "Replace one summary paragraph by index.",
703
+ inputSchema: {
704
+ type: "object",
705
+ properties: {
706
+ episodeId: { type: "string" },
707
+ summaryId: { type: "string" },
708
+ index: { type: "number" },
709
+ text: { type: "string" },
710
+ cues: { type: "array", items: { type: "string" } },
711
+ startMs: { type: "number" },
712
+ endMs: { type: "number" },
713
+ },
714
+ required: ["episodeId", "summaryId", "index", "text", "cues"],
715
+ additionalProperties: false,
716
+ },
717
+ },
718
+ {
719
+ name: "postParagraph",
720
+ description: "Append one summary paragraph.",
721
+ inputSchema: {
722
+ type: "object",
723
+ properties: {
724
+ episodeId: { type: "string" },
725
+ summaryId: { type: "string" },
726
+ text: { type: "string" },
727
+ cues: { type: "array", items: { type: "string" } },
728
+ startMs: { type: "number" },
729
+ endMs: { type: "number" },
730
+ },
731
+ required: ["episodeId", "summaryId", "text", "cues"],
732
+ additionalProperties: false,
733
+ },
734
+ },
735
+ {
736
+ name: "summary",
737
+ description: "Read one episode summary.",
738
+ inputSchema: {
739
+ type: "object",
740
+ properties: {
741
+ episodeId: { type: "string" },
742
+ summaryId: { type: "string" },
743
+ },
744
+ required: ["episodeId", "summaryId"],
745
+ additionalProperties: false,
746
+ },
747
+ },
748
+ {
749
+ name: "audit",
750
+ description: "Read the episode audit trail.",
751
+ inputSchema: {
752
+ type: "object",
753
+ properties: { episodeId: { type: "string" } },
754
+ required: ["episodeId"],
755
+ additionalProperties: false,
756
+ },
757
+ },
758
+ ];
759
+ }
760
+ function writeMessage(message) {
761
+ const body = Buffer.from(JSON.stringify(message), "utf8");
762
+ const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "utf8");
763
+ process.stdout.write(Buffer.concat([header, body]));
764
+ }
765
+ function asRecord(value) {
766
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
767
+ return undefined;
768
+ }
769
+ return Object.fromEntries(Object.entries(value));
770
+ }