@hasna/microservices 0.0.2 → 0.0.4
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/bin/index.js +70 -0
- package/bin/mcp.js +71 -1
- package/dist/index.js +70 -0
- package/microservices/microservice-ads/package.json +27 -0
- package/microservices/microservice-ads/src/cli/index.ts +407 -0
- package/microservices/microservice-ads/src/db/campaigns.ts +493 -0
- package/microservices/microservice-ads/src/db/database.ts +93 -0
- package/microservices/microservice-ads/src/db/migrations.ts +60 -0
- package/microservices/microservice-ads/src/index.ts +39 -0
- package/microservices/microservice-ads/src/mcp/index.ts +320 -0
- package/microservices/microservice-contracts/package.json +27 -0
- package/microservices/microservice-contracts/src/cli/index.ts +383 -0
- package/microservices/microservice-contracts/src/db/contracts.ts +496 -0
- package/microservices/microservice-contracts/src/db/database.ts +93 -0
- package/microservices/microservice-contracts/src/db/migrations.ts +58 -0
- package/microservices/microservice-contracts/src/index.ts +43 -0
- package/microservices/microservice-contracts/src/mcp/index.ts +308 -0
- package/microservices/microservice-domains/package.json +27 -0
- package/microservices/microservice-domains/src/cli/index.ts +438 -0
- package/microservices/microservice-domains/src/db/database.ts +93 -0
- package/microservices/microservice-domains/src/db/domains.ts +551 -0
- package/microservices/microservice-domains/src/db/migrations.ts +60 -0
- package/microservices/microservice-domains/src/index.ts +44 -0
- package/microservices/microservice-domains/src/mcp/index.ts +368 -0
- package/microservices/microservice-hiring/package.json +27 -0
- package/microservices/microservice-hiring/src/cli/index.ts +431 -0
- package/microservices/microservice-hiring/src/db/database.ts +93 -0
- package/microservices/microservice-hiring/src/db/hiring.ts +582 -0
- package/microservices/microservice-hiring/src/db/migrations.ts +68 -0
- package/microservices/microservice-hiring/src/index.ts +51 -0
- package/microservices/microservice-hiring/src/mcp/index.ts +464 -0
- package/microservices/microservice-payments/package.json +27 -0
- package/microservices/microservice-payments/src/cli/index.ts +357 -0
- package/microservices/microservice-payments/src/db/database.ts +93 -0
- package/microservices/microservice-payments/src/db/migrations.ts +63 -0
- package/microservices/microservice-payments/src/db/payments.ts +652 -0
- package/microservices/microservice-payments/src/index.ts +51 -0
- package/microservices/microservice-payments/src/mcp/index.ts +460 -0
- package/microservices/microservice-payroll/package.json +27 -0
- package/microservices/microservice-payroll/src/cli/index.ts +374 -0
- package/microservices/microservice-payroll/src/db/database.ts +93 -0
- package/microservices/microservice-payroll/src/db/migrations.ts +69 -0
- package/microservices/microservice-payroll/src/db/payroll.ts +741 -0
- package/microservices/microservice-payroll/src/index.ts +48 -0
- package/microservices/microservice-payroll/src/mcp/index.ts +420 -0
- package/microservices/microservice-shipping/package.json +27 -0
- package/microservices/microservice-shipping/src/cli/index.ts +398 -0
- package/microservices/microservice-shipping/src/db/database.ts +93 -0
- package/microservices/microservice-shipping/src/db/migrations.ts +61 -0
- package/microservices/microservice-shipping/src/db/shipping.ts +643 -0
- package/microservices/microservice-shipping/src/index.ts +53 -0
- package/microservices/microservice-shipping/src/mcp/index.ts +385 -0
- package/microservices/microservice-social/package.json +27 -0
- package/microservices/microservice-social/src/cli/index.ts +447 -0
- package/microservices/microservice-social/src/db/database.ts +93 -0
- package/microservices/microservice-social/src/db/migrations.ts +55 -0
- package/microservices/microservice-social/src/db/social.ts +672 -0
- package/microservices/microservice-social/src/index.ts +46 -0
- package/microservices/microservice-social/src/mcp/index.ts +435 -0
- package/microservices/microservice-subscriptions/package.json +27 -0
- package/microservices/microservice-subscriptions/src/cli/index.ts +400 -0
- package/microservices/microservice-subscriptions/src/db/database.ts +93 -0
- package/microservices/microservice-subscriptions/src/db/migrations.ts +57 -0
- package/microservices/microservice-subscriptions/src/db/subscriptions.ts +692 -0
- package/microservices/microservice-subscriptions/src/index.ts +41 -0
- package/microservices/microservice-subscriptions/src/mcp/index.ts +365 -0
- package/microservices/microservice-transcriber/package.json +28 -0
- package/microservices/microservice-transcriber/src/cli/index.ts +1347 -0
- package/microservices/microservice-transcriber/src/db/annotations.ts +37 -0
- package/microservices/microservice-transcriber/src/db/database.ts +82 -0
- package/microservices/microservice-transcriber/src/db/migrations.ts +72 -0
- package/microservices/microservice-transcriber/src/db/transcripts.ts +395 -0
- package/microservices/microservice-transcriber/src/index.ts +43 -0
- package/microservices/microservice-transcriber/src/lib/config.ts +77 -0
- package/microservices/microservice-transcriber/src/lib/diff.ts +91 -0
- package/microservices/microservice-transcriber/src/lib/downloader.ts +570 -0
- package/microservices/microservice-transcriber/src/lib/feeds.ts +62 -0
- package/microservices/microservice-transcriber/src/lib/live.ts +94 -0
- package/microservices/microservice-transcriber/src/lib/notion.ts +129 -0
- package/microservices/microservice-transcriber/src/lib/providers.ts +713 -0
- package/microservices/microservice-transcriber/src/lib/summarizer.ts +147 -0
- package/microservices/microservice-transcriber/src/lib/translator.ts +75 -0
- package/microservices/microservice-transcriber/src/lib/webhook.ts +37 -0
- package/microservices/microservice-transcriber/src/mcp/index.ts +1070 -0
- package/microservices/microservice-transcriber/src/server/index.ts +199 -0
- package/package.json +1 -1
- package/microservices/microservice-invoices/dashboard/dist/assets/index-Bngq7FNM.css +0 -1
- package/microservices/microservice-invoices/dashboard/dist/assets/index-aHW4ARZR.js +0 -124
- package/microservices/microservice-invoices/dashboard/dist/index.html +0 -13
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import {
|
|
7
|
+
createTranscript,
|
|
8
|
+
getTranscript,
|
|
9
|
+
updateTranscript,
|
|
10
|
+
deleteTranscript,
|
|
11
|
+
listTranscripts,
|
|
12
|
+
searchTranscripts,
|
|
13
|
+
countTranscripts,
|
|
14
|
+
renameSpeakers,
|
|
15
|
+
findBySourceUrl,
|
|
16
|
+
searchWithContext,
|
|
17
|
+
addTags,
|
|
18
|
+
removeTags,
|
|
19
|
+
getTags,
|
|
20
|
+
listAllTags,
|
|
21
|
+
listTranscriptsByTag,
|
|
22
|
+
type TranscriptProvider,
|
|
23
|
+
type TranscriptStatus,
|
|
24
|
+
type TranscriptSourceType,
|
|
25
|
+
} from "../db/transcripts.js";
|
|
26
|
+
import { prepareAudio, detectSourceType, getVideoInfo, downloadAudio, downloadVideo, createClip, isPlaylistUrl, getPlaylistUrls, type TrimOptions } from "../lib/downloader.js";
|
|
27
|
+
import { getConfig, setConfig, resetConfig } from "../lib/config.js";
|
|
28
|
+
import { summarizeText, extractHighlights, generateMeetingNotes, getDefaultSummaryProvider } from "../lib/summarizer.js";
|
|
29
|
+
import { translateText } from "../lib/translator.js";
|
|
30
|
+
import { fetchFeedEpisodes } from "../lib/feeds.js";
|
|
31
|
+
import { createAnnotation, listAnnotations, deleteAnnotation } from "../db/annotations.js";
|
|
32
|
+
import { wordDiff, diffStats, formatDiff } from "../lib/diff.js";
|
|
33
|
+
import { transcribeFile, checkProviders, toSrt, toVtt, toAss, toMarkdown, segmentByChapters, formatWithConfidence } from "../lib/providers.js";
|
|
34
|
+
|
|
35
|
+
const server = new McpServer({
|
|
36
|
+
name: "microservice-transcriber",
|
|
37
|
+
version: "0.0.1",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// transcribe
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
server.registerTool(
|
|
45
|
+
"transcribe",
|
|
46
|
+
{
|
|
47
|
+
title: "Transcribe Audio or Video",
|
|
48
|
+
description:
|
|
49
|
+
"Transcribe a local audio/video file or a URL (YouTube, Vimeo, Wistia, or any media URL). Uses ElevenLabs by default, or OpenAI Whisper.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
source: z.string().describe("File path or URL to transcribe"),
|
|
52
|
+
provider: z
|
|
53
|
+
.enum(["elevenlabs", "openai", "deepgram"])
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("Transcription provider (default: elevenlabs)"),
|
|
56
|
+
language: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Language code e.g. 'en', 'fr'. Auto-detected if omitted."),
|
|
60
|
+
title: z.string().optional().describe("Optional title for the transcript"),
|
|
61
|
+
start: z.number().optional().describe("Start time in seconds — trim audio before transcribing"),
|
|
62
|
+
end: z.number().optional().describe("End time in seconds — trim audio before transcribing"),
|
|
63
|
+
diarize: z.boolean().optional().describe("Identify different speakers — ElevenLabs only"),
|
|
64
|
+
vocab: z.array(z.string()).optional().describe("Custom vocabulary hints for accuracy (e.g. ['Karpathy', 'MicroGPT'])"),
|
|
65
|
+
force: z.boolean().optional().describe("Re-transcribe even if URL already exists in DB"),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
async ({ source, provider = "elevenlabs", language, title, start, end, diarize, vocab, force }) => {
|
|
69
|
+
// Duplicate detection
|
|
70
|
+
if (!force) {
|
|
71
|
+
const existing = findBySourceUrl(source);
|
|
72
|
+
if (existing) {
|
|
73
|
+
return { content: [{ type: "text", text: JSON.stringify({ duplicate: true, existing_id: existing.id, title: existing.title, message: "Already transcribed. Use force=true to re-transcribe." }, null, 2) }] };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const providers = checkProviders();
|
|
78
|
+
|
|
79
|
+
if (provider === "elevenlabs" && !providers.elevenlabs) {
|
|
80
|
+
return { content: [{ type: "text", text: "ELEVENLABS_API_KEY is not set." }], isError: true };
|
|
81
|
+
}
|
|
82
|
+
if (provider === "openai" && !providers.openai) {
|
|
83
|
+
return { content: [{ type: "text", text: "OPENAI_API_KEY is not set." }], isError: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const trim: TrimOptions | undefined =
|
|
87
|
+
start !== undefined || end !== undefined ? { start, end } : undefined;
|
|
88
|
+
|
|
89
|
+
const sourceType = detectSourceType(source);
|
|
90
|
+
const record = createTranscript({
|
|
91
|
+
source_url: source,
|
|
92
|
+
source_type: sourceType,
|
|
93
|
+
provider: provider as TranscriptProvider,
|
|
94
|
+
language,
|
|
95
|
+
title,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
updateTranscript(record.id, { status: "processing" });
|
|
99
|
+
|
|
100
|
+
let audio: Awaited<ReturnType<typeof prepareAudio>> | null = null;
|
|
101
|
+
try {
|
|
102
|
+
audio = await prepareAudio(source, trim);
|
|
103
|
+
|
|
104
|
+
// Auto-title from video metadata if title not provided
|
|
105
|
+
if (!title && audio.videoTitle) {
|
|
106
|
+
updateTranscript(record.id, { title: audio.videoTitle });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = await transcribeFile(audio.filePath, {
|
|
110
|
+
provider: provider as TranscriptProvider,
|
|
111
|
+
language,
|
|
112
|
+
diarize: diarize && provider === "elevenlabs",
|
|
113
|
+
vocab: vocab && vocab.length > 0 ? vocab : undefined,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const chapterSegments = audio.chapters.length > 0 && result.metadata.words
|
|
117
|
+
? segmentByChapters(result.metadata.words, audio.chapters)
|
|
118
|
+
: undefined;
|
|
119
|
+
|
|
120
|
+
const updated = updateTranscript(record.id, {
|
|
121
|
+
status: "completed",
|
|
122
|
+
transcript_text: result.text,
|
|
123
|
+
duration_seconds: result.duration_seconds ?? undefined,
|
|
124
|
+
word_count: result.text.split(/\s+/).filter(Boolean).length,
|
|
125
|
+
metadata: {
|
|
126
|
+
...result.metadata,
|
|
127
|
+
...(trim ? { trim_start: trim.start, trim_end: trim.end } : {}),
|
|
128
|
+
...(chapterSegments ? { chapters: chapterSegments } : {}),
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
content: [{ type: "text", text: JSON.stringify(updated, null, 2) }],
|
|
134
|
+
};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
137
|
+
updateTranscript(record.id, { status: "failed", error_message: msg });
|
|
138
|
+
return { content: [{ type: "text", text: `Transcription failed: ${msg}` }], isError: true };
|
|
139
|
+
} finally {
|
|
140
|
+
audio?.cleanup();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// download_audio
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
server.registerTool(
|
|
150
|
+
"download_audio",
|
|
151
|
+
{
|
|
152
|
+
title: "Download Audio",
|
|
153
|
+
description: "Download audio from a URL (YouTube, Vimeo, Wistia, etc.) without transcribing. Saves to the audio library organized by platform.",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
url: z.string().describe("Video/audio URL"),
|
|
156
|
+
format: z.enum(["mp3", "m4a", "wav"]).optional().describe("Audio format (default: mp3)"),
|
|
157
|
+
output_path: z.string().optional().describe("Override output file path"),
|
|
158
|
+
start: z.number().optional().describe("Start time in seconds"),
|
|
159
|
+
end: z.number().optional().describe("End time in seconds"),
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
async ({ url, format, output_path, start, end }) => {
|
|
163
|
+
try {
|
|
164
|
+
const trim = start !== undefined || end !== undefined ? { start, end } : undefined;
|
|
165
|
+
const result = await downloadAudio(url, { format: format as "mp3" | "m4a" | "wav" | undefined, outputPath: output_path, trim });
|
|
166
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
169
|
+
return { content: [{ type: "text", text: `Download failed: ${msg}` }], isError: true };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// batch_transcribe
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
server.registerTool(
|
|
179
|
+
"batch_transcribe",
|
|
180
|
+
{
|
|
181
|
+
title: "Batch Transcribe",
|
|
182
|
+
description: "Transcribe multiple sources sequentially. Each gets its own transcript record. Failures don't stop remaining items.",
|
|
183
|
+
inputSchema: {
|
|
184
|
+
sources: z.array(z.string()).describe("Array of file paths or URLs to transcribe"),
|
|
185
|
+
provider: z.enum(["elevenlabs", "openai", "deepgram"]).optional().describe("Provider (default: elevenlabs)"),
|
|
186
|
+
language: z.string().optional(),
|
|
187
|
+
diarize: z.boolean().optional(),
|
|
188
|
+
start: z.number().optional().describe("Start trim in seconds (applied to all sources)"),
|
|
189
|
+
end: z.number().optional().describe("End trim in seconds (applied to all sources)"),
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
async ({ sources, provider = "elevenlabs", language, diarize, start, end }) => {
|
|
193
|
+
const available = checkProviders();
|
|
194
|
+
if (provider === "elevenlabs" && !available.elevenlabs) {
|
|
195
|
+
return { content: [{ type: "text", text: "ELEVENLABS_API_KEY is not set." }], isError: true };
|
|
196
|
+
}
|
|
197
|
+
if (provider === "openai" && !available.openai) {
|
|
198
|
+
return { content: [{ type: "text", text: "OPENAI_API_KEY is not set." }], isError: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Expand playlist URLs
|
|
202
|
+
const expanded: string[] = [];
|
|
203
|
+
for (const src of sources) {
|
|
204
|
+
if (isPlaylistUrl(src)) {
|
|
205
|
+
try {
|
|
206
|
+
const videos = await getPlaylistUrls(src);
|
|
207
|
+
expanded.push(...videos.map((v) => v.url));
|
|
208
|
+
} catch { expanded.push(src); }
|
|
209
|
+
} else {
|
|
210
|
+
expanded.push(src);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const trim = start !== undefined || end !== undefined ? { start, end } : undefined;
|
|
215
|
+
const results: Array<{ source: string; id: string; success: boolean; error?: string }> = [];
|
|
216
|
+
|
|
217
|
+
for (const source of expanded) {
|
|
218
|
+
const sourceType = detectSourceType(source);
|
|
219
|
+
const record = createTranscript({
|
|
220
|
+
source_url: source,
|
|
221
|
+
source_type: sourceType,
|
|
222
|
+
provider: provider as TranscriptProvider,
|
|
223
|
+
language,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
updateTranscript(record.id, { status: "processing" });
|
|
227
|
+
|
|
228
|
+
let audio: Awaited<ReturnType<typeof prepareAudio>> | null = null;
|
|
229
|
+
try {
|
|
230
|
+
audio = await prepareAudio(source, trim);
|
|
231
|
+
if (audio.videoTitle) updateTranscript(record.id, { title: audio.videoTitle });
|
|
232
|
+
|
|
233
|
+
const result = await transcribeFile(audio.filePath, {
|
|
234
|
+
provider: provider as TranscriptProvider,
|
|
235
|
+
language,
|
|
236
|
+
diarize: diarize && provider === "elevenlabs",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const chapterSegments = audio.chapters.length > 0 && result.metadata.words
|
|
240
|
+
? segmentByChapters(result.metadata.words, audio.chapters)
|
|
241
|
+
: undefined;
|
|
242
|
+
|
|
243
|
+
updateTranscript(record.id, {
|
|
244
|
+
status: "completed",
|
|
245
|
+
transcript_text: result.text,
|
|
246
|
+
duration_seconds: result.duration_seconds ?? undefined,
|
|
247
|
+
word_count: result.text.split(/\s+/).filter(Boolean).length,
|
|
248
|
+
metadata: {
|
|
249
|
+
...result.metadata,
|
|
250
|
+
...(trim ? { trim_start: trim.start, trim_end: trim.end } : {}),
|
|
251
|
+
...(chapterSegments ? { chapters: chapterSegments } : {}),
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
results.push({ source, id: record.id, success: true });
|
|
256
|
+
} catch (error) {
|
|
257
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
258
|
+
updateTranscript(record.id, { status: "failed", error_message: msg });
|
|
259
|
+
results.push({ source, id: record.id, success: false, error: msg });
|
|
260
|
+
} finally {
|
|
261
|
+
audio?.cleanup();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const succeeded = results.filter((r) => r.success).length;
|
|
266
|
+
const failed = results.filter((r) => !r.success).length;
|
|
267
|
+
return {
|
|
268
|
+
content: [{ type: "text", text: JSON.stringify({ results, summary: { succeeded, failed, total: expanded.length } }, null, 2) }],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// get_video_info
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
server.registerTool(
|
|
278
|
+
"get_video_info",
|
|
279
|
+
{
|
|
280
|
+
title: "Get Video Info",
|
|
281
|
+
description:
|
|
282
|
+
"Fetch video metadata (title, duration, uploader, chapters, formats) from a URL without downloading or transcribing. Works with YouTube, Vimeo, Wistia, and any yt-dlp supported URL.",
|
|
283
|
+
inputSchema: {
|
|
284
|
+
url: z.string().describe("Video URL"),
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
async ({ url }) => {
|
|
288
|
+
try {
|
|
289
|
+
const info = await getVideoInfo(url);
|
|
290
|
+
return { content: [{ type: "text", text: JSON.stringify(info, null, 2) }] };
|
|
291
|
+
} catch (error) {
|
|
292
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
293
|
+
return { content: [{ type: "text", text: `Failed to fetch video info: ${msg}` }], isError: true };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// list_transcripts
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
server.registerTool(
|
|
303
|
+
"list_transcripts",
|
|
304
|
+
{
|
|
305
|
+
title: "List Transcripts",
|
|
306
|
+
description: "List transcripts with optional filters.",
|
|
307
|
+
inputSchema: {
|
|
308
|
+
status: z
|
|
309
|
+
.enum(["pending", "processing", "completed", "failed"])
|
|
310
|
+
.optional()
|
|
311
|
+
.describe("Filter by status"),
|
|
312
|
+
provider: z
|
|
313
|
+
.enum(["elevenlabs", "openai", "deepgram"])
|
|
314
|
+
.optional()
|
|
315
|
+
.describe("Filter by provider"),
|
|
316
|
+
source_type: z
|
|
317
|
+
.enum(["file", "youtube", "vimeo", "wistia", "url"])
|
|
318
|
+
.optional()
|
|
319
|
+
.describe("Filter by source type"),
|
|
320
|
+
limit: z.number().optional().describe("Max results (default 50)"),
|
|
321
|
+
offset: z.number().optional().describe("Offset for pagination"),
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
async ({ status, provider, source_type, limit, offset }) => {
|
|
325
|
+
const results = listTranscripts({
|
|
326
|
+
status: status as TranscriptStatus | undefined,
|
|
327
|
+
provider: provider as TranscriptProvider | undefined,
|
|
328
|
+
source_type: source_type as TranscriptSourceType | undefined,
|
|
329
|
+
limit,
|
|
330
|
+
offset,
|
|
331
|
+
});
|
|
332
|
+
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
333
|
+
}
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// get_transcript
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
server.registerTool(
|
|
341
|
+
"get_transcript",
|
|
342
|
+
{
|
|
343
|
+
title: "Get Transcript",
|
|
344
|
+
description: "Get a single transcript by ID, including full text and metadata.",
|
|
345
|
+
inputSchema: {
|
|
346
|
+
id: z.string().describe("Transcript ID"),
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
async ({ id }) => {
|
|
350
|
+
const t = getTranscript(id);
|
|
351
|
+
if (!t) {
|
|
352
|
+
return { content: [{ type: "text", text: `Transcript '${id}' not found.` }], isError: true };
|
|
353
|
+
}
|
|
354
|
+
return { content: [{ type: "text", text: JSON.stringify(t, null, 2) }] };
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// search_transcripts
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
server.registerTool(
|
|
363
|
+
"search_transcripts",
|
|
364
|
+
{
|
|
365
|
+
title: "Search Transcripts",
|
|
366
|
+
description: "Full-text search across transcript text, titles, and source URLs. Use context param for excerpts with timestamps.",
|
|
367
|
+
inputSchema: {
|
|
368
|
+
query: z.string().describe("Search query"),
|
|
369
|
+
context: z.number().optional().describe("Number of surrounding sentences to include (enables contextual search with timestamps)"),
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
async ({ query, context }) => {
|
|
373
|
+
if (context !== undefined) {
|
|
374
|
+
const matches = searchWithContext(query, context);
|
|
375
|
+
return { content: [{ type: "text", text: JSON.stringify(matches, null, 2) }] };
|
|
376
|
+
}
|
|
377
|
+
const results = searchTranscripts(query);
|
|
378
|
+
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// retry_transcript
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
server.registerTool(
|
|
387
|
+
"retry_transcript",
|
|
388
|
+
{
|
|
389
|
+
title: "Retry Transcript",
|
|
390
|
+
description: "Retry a failed or pending transcription using its original source URL. Optionally switch providers.",
|
|
391
|
+
inputSchema: {
|
|
392
|
+
id: z.string().describe("Transcript ID to retry"),
|
|
393
|
+
provider: z
|
|
394
|
+
.enum(["elevenlabs", "openai", "deepgram"])
|
|
395
|
+
.optional()
|
|
396
|
+
.describe("Override provider (defaults to original)"),
|
|
397
|
+
diarize: z.boolean().optional().describe("Identify speakers — ElevenLabs only"),
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
async ({ id, provider: providerOverride, diarize }) => {
|
|
401
|
+
const t = getTranscript(id);
|
|
402
|
+
if (!t) {
|
|
403
|
+
return { content: [{ type: "text", text: `Transcript '${id}' not found.` }], isError: true };
|
|
404
|
+
}
|
|
405
|
+
if (!t.source_url) {
|
|
406
|
+
return { content: [{ type: "text", text: `Transcript '${id}' has no source URL to retry from.` }], isError: true };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const provider = (providerOverride ?? t.provider) as TranscriptProvider;
|
|
410
|
+
const available = checkProviders();
|
|
411
|
+
if (provider === "elevenlabs" && !available.elevenlabs) {
|
|
412
|
+
return { content: [{ type: "text", text: "ELEVENLABS_API_KEY is not set." }], isError: true };
|
|
413
|
+
}
|
|
414
|
+
if (provider === "openai" && !available.openai) {
|
|
415
|
+
return { content: [{ type: "text", text: "OPENAI_API_KEY is not set." }], isError: true };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
updateTranscript(id, { status: "processing", error_message: null });
|
|
419
|
+
|
|
420
|
+
let audio: Awaited<ReturnType<typeof prepareAudio>> | null = null;
|
|
421
|
+
try {
|
|
422
|
+
const trim = t.metadata?.trim_start !== undefined || t.metadata?.trim_end !== undefined
|
|
423
|
+
? { start: t.metadata.trim_start, end: t.metadata.trim_end }
|
|
424
|
+
: undefined;
|
|
425
|
+
|
|
426
|
+
audio = await prepareAudio(t.source_url, trim);
|
|
427
|
+
const result = await transcribeFile(audio.filePath, {
|
|
428
|
+
provider,
|
|
429
|
+
language: t.language,
|
|
430
|
+
diarize: diarize && provider === "elevenlabs",
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const updated = updateTranscript(id, {
|
|
434
|
+
status: "completed",
|
|
435
|
+
transcript_text: result.text,
|
|
436
|
+
duration_seconds: result.duration_seconds ?? undefined,
|
|
437
|
+
word_count: result.text.split(/\s+/).filter(Boolean).length,
|
|
438
|
+
metadata: {
|
|
439
|
+
...result.metadata,
|
|
440
|
+
...(trim ? { trim_start: trim.start, trim_end: trim.end } : {}),
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
|
|
445
|
+
} catch (error) {
|
|
446
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
447
|
+
updateTranscript(id, { status: "failed", error_message: msg });
|
|
448
|
+
return { content: [{ type: "text", text: `Retry failed: ${msg}` }], isError: true };
|
|
449
|
+
} finally {
|
|
450
|
+
audio?.cleanup();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// delete_transcript
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
server.registerTool(
|
|
460
|
+
"delete_transcript",
|
|
461
|
+
{
|
|
462
|
+
title: "Delete Transcript",
|
|
463
|
+
description: "Delete a transcript by ID.",
|
|
464
|
+
inputSchema: {
|
|
465
|
+
id: z.string().describe("Transcript ID"),
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
async ({ id }) => {
|
|
469
|
+
const deleted = deleteTranscript(id);
|
|
470
|
+
if (!deleted) {
|
|
471
|
+
return { content: [{ type: "text", text: `Transcript '${id}' not found.` }], isError: true };
|
|
472
|
+
}
|
|
473
|
+
return { content: [{ type: "text", text: JSON.stringify({ id, deleted: true }) }] };
|
|
474
|
+
}
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// export_transcript
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
server.registerTool(
|
|
482
|
+
"export_transcript",
|
|
483
|
+
{
|
|
484
|
+
title: "Export Transcript",
|
|
485
|
+
description: "Export a completed transcript as plain text, SRT subtitles, or JSON.",
|
|
486
|
+
inputSchema: {
|
|
487
|
+
id: z.string().describe("Transcript ID"),
|
|
488
|
+
format: z
|
|
489
|
+
.enum(["txt", "srt", "vtt", "ass", "md", "json"])
|
|
490
|
+
.optional()
|
|
491
|
+
.describe("Export format: txt (default), srt, vtt, ass, json"),
|
|
492
|
+
font_name: z.string().optional().describe("Font name for ASS format (default: Arial)"),
|
|
493
|
+
font_size: z.number().optional().describe("Font size for ASS format (default: 20)"),
|
|
494
|
+
color: z.string().optional().describe("Text color hex for ASS (default: FFFFFF = white)"),
|
|
495
|
+
outline: z.number().optional().describe("Outline size for ASS (default: 2)"),
|
|
496
|
+
shadow: z.number().optional().describe("Shadow size for ASS (default: 1)"),
|
|
497
|
+
show_confidence: z.boolean().optional().describe("Flag low-confidence words with [?word?] markers (ElevenLabs only, txt format)"),
|
|
498
|
+
confidence_threshold: z.number().optional().describe("Confidence threshold 0-1 (default 0.7)"),
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
async ({ id, format = "txt", font_name, font_size, color, outline, shadow, show_confidence, confidence_threshold }) => {
|
|
502
|
+
const t = getTranscript(id);
|
|
503
|
+
if (!t) {
|
|
504
|
+
return { content: [{ type: "text", text: `Transcript '${id}' not found.` }], isError: true };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (t.status !== "completed" || !t.transcript_text) {
|
|
508
|
+
return {
|
|
509
|
+
content: [{ type: "text", text: `Transcript '${id}' is not completed (status: ${t.status}).` }],
|
|
510
|
+
isError: true,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
let output: string;
|
|
515
|
+
if (format === "json") {
|
|
516
|
+
output = JSON.stringify(t, null, 2);
|
|
517
|
+
} else if (format === "md") {
|
|
518
|
+
output = toMarkdown(t);
|
|
519
|
+
} else if (format === "srt" || format === "vtt" || format === "ass") {
|
|
520
|
+
const words = t.metadata?.words ?? [];
|
|
521
|
+
if (words.length === 0) {
|
|
522
|
+
return {
|
|
523
|
+
content: [{ type: "text", text: `No word-level timestamps available for ${format.toUpperCase()} export.` }],
|
|
524
|
+
isError: true,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
if (format === "vtt") output = toVtt(words);
|
|
528
|
+
else if (format === "ass") output = toAss(words, { fontName: font_name, fontSize: font_size, color, outline, shadow });
|
|
529
|
+
else output = toSrt(words);
|
|
530
|
+
} else {
|
|
531
|
+
if (show_confidence && t.metadata?.words?.length) {
|
|
532
|
+
output = formatWithConfidence(t.metadata.words, confidence_threshold ?? 0.7);
|
|
533
|
+
} else {
|
|
534
|
+
output = t.transcript_text;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return { content: [{ type: "text", text: output }] };
|
|
539
|
+
}
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// transcript_stats
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
server.registerTool(
|
|
547
|
+
"transcript_stats",
|
|
548
|
+
{
|
|
549
|
+
title: "Transcript Stats",
|
|
550
|
+
description: "Get transcript counts grouped by status and provider.",
|
|
551
|
+
inputSchema: {},
|
|
552
|
+
},
|
|
553
|
+
async () => {
|
|
554
|
+
const counts = countTranscripts();
|
|
555
|
+
return { content: [{ type: "text", text: JSON.stringify(counts, null, 2) }] };
|
|
556
|
+
}
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
// check_providers
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
server.registerTool(
|
|
564
|
+
"check_providers",
|
|
565
|
+
{
|
|
566
|
+
title: "Check Providers",
|
|
567
|
+
description: "Check which transcription providers have API keys configured.",
|
|
568
|
+
inputSchema: {},
|
|
569
|
+
},
|
|
570
|
+
async () => {
|
|
571
|
+
const available = checkProviders();
|
|
572
|
+
return { content: [{ type: "text", text: JSON.stringify(available, null, 2) }] };
|
|
573
|
+
}
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// tag_transcript / list_tags
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
server.registerTool(
|
|
581
|
+
"tag_transcript",
|
|
582
|
+
{
|
|
583
|
+
title: "Tag Transcript",
|
|
584
|
+
description: "Add or remove tags on a transcript for organization.",
|
|
585
|
+
inputSchema: {
|
|
586
|
+
id: z.string().describe("Transcript ID"),
|
|
587
|
+
add: z.array(z.string()).optional().describe("Tags to add"),
|
|
588
|
+
remove: z.array(z.string()).optional().describe("Tags to remove"),
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
async ({ id, add, remove }) => {
|
|
592
|
+
if (add) addTags(id, add);
|
|
593
|
+
if (remove) removeTags(id, remove);
|
|
594
|
+
const tags = getTags(id);
|
|
595
|
+
return { content: [{ type: "text", text: JSON.stringify({ id, tags }, null, 2) }] };
|
|
596
|
+
}
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
server.registerTool(
|
|
600
|
+
"list_tags",
|
|
601
|
+
{
|
|
602
|
+
title: "List Tags",
|
|
603
|
+
description: "List all tags with transcript counts.",
|
|
604
|
+
inputSchema: {},
|
|
605
|
+
},
|
|
606
|
+
async () => {
|
|
607
|
+
const tags = listAllTags();
|
|
608
|
+
return { content: [{ type: "text", text: JSON.stringify(tags, null, 2) }] };
|
|
609
|
+
}
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
// rename_speakers
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
server.registerTool(
|
|
617
|
+
"rename_speakers",
|
|
618
|
+
{
|
|
619
|
+
title: "Rename Speakers",
|
|
620
|
+
description: "Rename speaker labels in a diarized transcript. Replaces in text, words, and speaker segments.",
|
|
621
|
+
inputSchema: {
|
|
622
|
+
id: z.string().describe("Transcript ID"),
|
|
623
|
+
mapping: z.record(z.string()).describe('Speaker name mapping, e.g. {"Speaker 1":"Andrej Karpathy","Speaker 2":"Sarah Guo"}'),
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
async ({ id, mapping }) => {
|
|
627
|
+
const updated = renameSpeakers(id, mapping);
|
|
628
|
+
if (!updated) return { content: [{ type: "text", text: `Transcript '${id}' not found.` }], isError: true };
|
|
629
|
+
return { content: [{ type: "text", text: JSON.stringify({ id, renamed: Object.keys(mapping).length }, null, 2) }] };
|
|
630
|
+
}
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
// translate_transcript
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
server.registerTool(
|
|
638
|
+
"translate_transcript",
|
|
639
|
+
{
|
|
640
|
+
title: "Translate Transcript",
|
|
641
|
+
description: "Translate a completed transcript to another language. Creates a new linked transcript record with source_transcript_id pointing to the original.",
|
|
642
|
+
inputSchema: {
|
|
643
|
+
id: z.string().describe("Source transcript ID"),
|
|
644
|
+
to: z.string().describe("Target language code or name (e.g. 'fr', 'de', 'Spanish')"),
|
|
645
|
+
provider: z.enum(["openai", "anthropic"]).optional().describe("AI provider (auto-detected from env)"),
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
async ({ id, to, provider }) => {
|
|
649
|
+
const t = getTranscript(id);
|
|
650
|
+
if (!t) return { content: [{ type: "text", text: `Transcript '${id}' not found.` }], isError: true };
|
|
651
|
+
if (t.status !== "completed" || !t.transcript_text) {
|
|
652
|
+
return { content: [{ type: "text", text: `Transcript '${id}' is not completed.` }], isError: true };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const resolved = provider ?? getDefaultSummaryProvider();
|
|
656
|
+
if (!resolved) {
|
|
657
|
+
return { content: [{ type: "text", text: "No AI provider configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY." }], isError: true };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
const translatedText = await translateText(t.transcript_text, to, resolved);
|
|
662
|
+
|
|
663
|
+
const newRecord = createTranscript({
|
|
664
|
+
source_url: t.source_url ?? `translated:${id}`,
|
|
665
|
+
source_type: "translated",
|
|
666
|
+
provider: t.provider,
|
|
667
|
+
language: to,
|
|
668
|
+
title: t.title ? `${t.title} [${to}]` : null,
|
|
669
|
+
source_transcript_id: id,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
updateTranscript(newRecord.id, {
|
|
673
|
+
status: "completed",
|
|
674
|
+
transcript_text: translatedText,
|
|
675
|
+
word_count: translatedText.split(/\s+/).filter(Boolean).length,
|
|
676
|
+
metadata: { model: resolved },
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
return { content: [{ type: "text", text: JSON.stringify(getTranscript(newRecord.id), null, 2) }] };
|
|
680
|
+
} catch (error) {
|
|
681
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
682
|
+
return { content: [{ type: "text", text: `Translation failed: ${msg}` }], isError: true };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
// summarize_transcript
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
server.registerTool(
|
|
692
|
+
"summarize_transcript",
|
|
693
|
+
{
|
|
694
|
+
title: "Summarize Transcript",
|
|
695
|
+
description: "Generate a 3-5 sentence AI summary of a completed transcript. Stores summary in metadata.summary. Uses OpenAI gpt-4o-mini or Anthropic claude-haiku.",
|
|
696
|
+
inputSchema: {
|
|
697
|
+
id: z.string().describe("Transcript ID"),
|
|
698
|
+
provider: z.enum(["openai", "anthropic"]).optional().describe("AI provider (auto-detected from env if omitted)"),
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
async ({ id, provider }) => {
|
|
702
|
+
const t = getTranscript(id);
|
|
703
|
+
if (!t) return { content: [{ type: "text", text: `Transcript '${id}' not found.` }], isError: true };
|
|
704
|
+
if (t.status !== "completed" || !t.transcript_text) {
|
|
705
|
+
return { content: [{ type: "text", text: `Transcript '${id}' is not completed.` }], isError: true };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const resolved = provider ?? getDefaultSummaryProvider();
|
|
709
|
+
if (!resolved) {
|
|
710
|
+
return { content: [{ type: "text", text: "No AI provider configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY." }], isError: true };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
const summary = await summarizeText(t.transcript_text, resolved);
|
|
715
|
+
updateTranscript(id, { metadata: { ...t.metadata, summary } });
|
|
716
|
+
return { content: [{ type: "text", text: JSON.stringify({ id, summary }, null, 2) }] };
|
|
717
|
+
} catch (error) {
|
|
718
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
719
|
+
return { content: [{ type: "text", text: `Summarization failed: ${msg}` }], isError: true };
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
// diff_transcripts
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
server.registerTool(
|
|
729
|
+
"diff_transcripts",
|
|
730
|
+
{
|
|
731
|
+
title: "Diff Transcripts",
|
|
732
|
+
description: "Compare two transcripts word-by-word. Returns similarity percentage and diff entries.",
|
|
733
|
+
inputSchema: {
|
|
734
|
+
id1: z.string().describe("First transcript ID"),
|
|
735
|
+
id2: z.string().describe("Second transcript ID"),
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
async ({ id1, id2 }) => {
|
|
739
|
+
const t1 = getTranscript(id1);
|
|
740
|
+
const t2 = getTranscript(id2);
|
|
741
|
+
if (!t1) return { content: [{ type: "text", text: `Transcript '${id1}' not found.` }], isError: true };
|
|
742
|
+
if (!t2) return { content: [{ type: "text", text: `Transcript '${id2}' not found.` }], isError: true };
|
|
743
|
+
if (!t1.transcript_text || !t2.transcript_text) {
|
|
744
|
+
return { content: [{ type: "text", text: "Both transcripts must be completed." }], isError: true };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const entries = wordDiff(t1.transcript_text, t2.transcript_text);
|
|
748
|
+
const stats = diffStats(entries);
|
|
749
|
+
return { content: [{ type: "text", text: JSON.stringify({ id1, id2, stats, formatted: formatDiff(entries).slice(0, 5000) }, null, 2) }] };
|
|
750
|
+
}
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// create_clip
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
server.registerTool(
|
|
758
|
+
"create_clip",
|
|
759
|
+
{
|
|
760
|
+
title: "Create Clip",
|
|
761
|
+
description: "Extract a video clip with burned-in subtitles from a transcribed URL source.",
|
|
762
|
+
inputSchema: {
|
|
763
|
+
id: z.string().describe("Transcript ID"),
|
|
764
|
+
start: z.number().describe("Start time in seconds"),
|
|
765
|
+
end: z.number().describe("End time in seconds"),
|
|
766
|
+
output_path: z.string().optional().describe("Output file path"),
|
|
767
|
+
subtitles: z.boolean().optional().describe("Burn in subtitles (default: true)"),
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
async ({ id, start, end, output_path, subtitles = true }) => {
|
|
771
|
+
const t = getTranscript(id);
|
|
772
|
+
if (!t) return { content: [{ type: "text", text: `Transcript '${id}' not found.` }], isError: true };
|
|
773
|
+
if (!t.source_url || t.source_type === "file") {
|
|
774
|
+
return { content: [{ type: "text", text: "Clip extraction requires a URL source." }], isError: true };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const outputPath = output_path ?? `/tmp/clip-${id.slice(0, 8)}.mp4`;
|
|
778
|
+
let video: Awaited<ReturnType<typeof downloadVideo>> | null = null;
|
|
779
|
+
let subsFile: string | null = null;
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
video = await downloadVideo(t.source_url);
|
|
783
|
+
|
|
784
|
+
if (subtitles && t.metadata?.words?.length) {
|
|
785
|
+
const rangeWords = t.metadata.words.filter((w) => w.start >= start && w.end <= end);
|
|
786
|
+
if (rangeWords.length > 0) {
|
|
787
|
+
const offsetWords = rangeWords.map((w) => ({ ...w, start: w.start - start, end: w.end - start }));
|
|
788
|
+
const assContent = toAss(offsetWords);
|
|
789
|
+
subsFile = `/tmp/transcriber-clip-subs-${crypto.randomUUID()}.ass`;
|
|
790
|
+
const { writeFileSync } = await import("node:fs");
|
|
791
|
+
writeFileSync(subsFile, assContent, "utf8");
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
await createClip({ videoPath: video.path, start, end, subtitlePath: subsFile ?? undefined, outputPath });
|
|
796
|
+
return { content: [{ type: "text", text: JSON.stringify({ id, outputPath, start, end }, null, 2) }] };
|
|
797
|
+
} catch (error) {
|
|
798
|
+
return { content: [{ type: "text", text: `Clip failed: ${error instanceof Error ? error.message : error}` }], isError: true };
|
|
799
|
+
} finally {
|
|
800
|
+
video?.cleanup();
|
|
801
|
+
if (subsFile) { try { const { unlinkSync } = await import("node:fs"); unlinkSync(subsFile); } catch {} }
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
// meeting_notes
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
|
|
810
|
+
server.registerTool(
|
|
811
|
+
"meeting_notes",
|
|
812
|
+
{
|
|
813
|
+
title: "Generate Meeting Notes",
|
|
814
|
+
description: "Restructure a transcript into formatted meeting notes: attendees, agenda, key decisions, action items, summary.",
|
|
815
|
+
inputSchema: {
|
|
816
|
+
id: z.string().describe("Transcript ID"),
|
|
817
|
+
provider: z.enum(["openai", "anthropic"]).optional(),
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
async ({ id, provider }) => {
|
|
821
|
+
const t = getTranscript(id);
|
|
822
|
+
if (!t) return { content: [{ type: "text", text: `Transcript '${id}' not found.` }], isError: true };
|
|
823
|
+
if (t.status !== "completed" || !t.transcript_text) {
|
|
824
|
+
return { content: [{ type: "text", text: `Transcript '${id}' is not completed.` }], isError: true };
|
|
825
|
+
}
|
|
826
|
+
const resolved = provider ?? getDefaultSummaryProvider();
|
|
827
|
+
if (!resolved) return { content: [{ type: "text", text: "No AI provider configured." }], isError: true };
|
|
828
|
+
try {
|
|
829
|
+
const notes = await generateMeetingNotes(t.transcript_text, resolved);
|
|
830
|
+
updateTranscript(id, { metadata: { ...t.metadata, meeting_notes: notes } });
|
|
831
|
+
return { content: [{ type: "text", text: notes }] };
|
|
832
|
+
} catch (error) {
|
|
833
|
+
return { content: [{ type: "text", text: `Failed: ${error instanceof Error ? error.message : error}` }], isError: true };
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
// ---------------------------------------------------------------------------
|
|
839
|
+
// highlights_transcript
|
|
840
|
+
// ---------------------------------------------------------------------------
|
|
841
|
+
|
|
842
|
+
server.registerTool(
|
|
843
|
+
"highlights_transcript",
|
|
844
|
+
{
|
|
845
|
+
title: "Extract Highlights",
|
|
846
|
+
description: "Extract 5-10 key moments/quotes from a completed transcript using AI.",
|
|
847
|
+
inputSchema: {
|
|
848
|
+
id: z.string().describe("Transcript ID"),
|
|
849
|
+
provider: z.enum(["openai", "anthropic"]).optional(),
|
|
850
|
+
},
|
|
851
|
+
},
|
|
852
|
+
async ({ id, provider }) => {
|
|
853
|
+
const t = getTranscript(id);
|
|
854
|
+
if (!t) return { content: [{ type: "text", text: `Transcript '${id}' not found.` }], isError: true };
|
|
855
|
+
if (t.status !== "completed" || !t.transcript_text) {
|
|
856
|
+
return { content: [{ type: "text", text: `Transcript '${id}' is not completed.` }], isError: true };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const resolved = provider ?? getDefaultSummaryProvider();
|
|
860
|
+
if (!resolved) return { content: [{ type: "text", text: "No AI provider configured." }], isError: true };
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
const highlights = await extractHighlights(t.transcript_text, resolved);
|
|
864
|
+
updateTranscript(id, { metadata: { ...t.metadata, highlights } });
|
|
865
|
+
return { content: [{ type: "text", text: JSON.stringify({ id, highlights }, null, 2) }] };
|
|
866
|
+
} catch (error) {
|
|
867
|
+
return { content: [{ type: "text", text: `Highlights extraction failed: ${error instanceof Error ? error.message : error}` }], isError: true };
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
// ---------------------------------------------------------------------------
|
|
873
|
+
// annotations
|
|
874
|
+
// ---------------------------------------------------------------------------
|
|
875
|
+
|
|
876
|
+
server.registerTool(
|
|
877
|
+
"add_annotation",
|
|
878
|
+
{
|
|
879
|
+
title: "Add Annotation",
|
|
880
|
+
description: "Add a timestamped annotation/bookmark to a transcript.",
|
|
881
|
+
inputSchema: {
|
|
882
|
+
transcript_id: z.string(), timestamp_sec: z.number(), note: z.string(),
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
async ({ transcript_id, timestamp_sec, note }) => {
|
|
886
|
+
const anno = createAnnotation(transcript_id, timestamp_sec, note);
|
|
887
|
+
return { content: [{ type: "text", text: JSON.stringify(anno, null, 2) }] };
|
|
888
|
+
}
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
server.registerTool(
|
|
892
|
+
"list_annotations",
|
|
893
|
+
{
|
|
894
|
+
title: "List Annotations",
|
|
895
|
+
description: "List all annotations for a transcript.",
|
|
896
|
+
inputSchema: { transcript_id: z.string() },
|
|
897
|
+
},
|
|
898
|
+
async ({ transcript_id }) => {
|
|
899
|
+
return { content: [{ type: "text", text: JSON.stringify(listAnnotations(transcript_id), null, 2) }] };
|
|
900
|
+
}
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
server.registerTool(
|
|
904
|
+
"delete_annotation",
|
|
905
|
+
{
|
|
906
|
+
title: "Delete Annotation",
|
|
907
|
+
description: "Delete an annotation by ID.",
|
|
908
|
+
inputSchema: { id: z.string() },
|
|
909
|
+
},
|
|
910
|
+
async ({ id }) => {
|
|
911
|
+
const ok = deleteAnnotation(id);
|
|
912
|
+
if (!ok) return { content: [{ type: "text", text: "Annotation not found." }], isError: true };
|
|
913
|
+
return { content: [{ type: "text", text: JSON.stringify({ id, deleted: true }) }] };
|
|
914
|
+
}
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
// check_feeds
|
|
919
|
+
// ---------------------------------------------------------------------------
|
|
920
|
+
|
|
921
|
+
server.registerTool(
|
|
922
|
+
"check_feeds",
|
|
923
|
+
{
|
|
924
|
+
title: "Check Podcast Feeds",
|
|
925
|
+
description: "Check all registered RSS feeds for new episodes. Returns new episode URLs. Use with batch_transcribe to transcribe them.",
|
|
926
|
+
inputSchema: {},
|
|
927
|
+
},
|
|
928
|
+
async () => {
|
|
929
|
+
const cfg = getConfig();
|
|
930
|
+
if (cfg.feeds.length === 0) return { content: [{ type: "text", text: "No feeds configured." }] };
|
|
931
|
+
|
|
932
|
+
const allNew: Array<{ feed: string; episodes: Array<{ url: string; title: string | null }> }> = [];
|
|
933
|
+
for (const feed of cfg.feeds) {
|
|
934
|
+
try {
|
|
935
|
+
const { episodes } = await fetchFeedEpisodes(feed.url);
|
|
936
|
+
const newEps = episodes.filter((ep) => !findBySourceUrl(ep.url));
|
|
937
|
+
if (newEps.length > 0) allNew.push({ feed: feed.title ?? feed.url, episodes: newEps.map((e) => ({ url: e.url, title: e.title })) });
|
|
938
|
+
feed.lastChecked = new Date().toISOString();
|
|
939
|
+
} catch {}
|
|
940
|
+
}
|
|
941
|
+
setConfig({ feeds: cfg.feeds });
|
|
942
|
+
return { content: [{ type: "text", text: JSON.stringify(allNew, null, 2) }] };
|
|
943
|
+
}
|
|
944
|
+
);
|
|
945
|
+
|
|
946
|
+
// ---------------------------------------------------------------------------
|
|
947
|
+
// get_config / set_config
|
|
948
|
+
// ---------------------------------------------------------------------------
|
|
949
|
+
|
|
950
|
+
server.registerTool(
|
|
951
|
+
"get_config",
|
|
952
|
+
{
|
|
953
|
+
title: "Get Config",
|
|
954
|
+
description: "Get current transcriber configuration defaults.",
|
|
955
|
+
inputSchema: {},
|
|
956
|
+
},
|
|
957
|
+
async () => {
|
|
958
|
+
const cfg = getConfig();
|
|
959
|
+
return { content: [{ type: "text", text: JSON.stringify(cfg, null, 2) }] };
|
|
960
|
+
}
|
|
961
|
+
);
|
|
962
|
+
|
|
963
|
+
server.registerTool(
|
|
964
|
+
"set_config",
|
|
965
|
+
{
|
|
966
|
+
title: "Set Config",
|
|
967
|
+
description: "Update transcriber configuration defaults.",
|
|
968
|
+
inputSchema: {
|
|
969
|
+
defaultProvider: z.enum(["elevenlabs", "openai", "deepgram"]).optional(),
|
|
970
|
+
defaultLanguage: z.string().optional(),
|
|
971
|
+
defaultFormat: z.enum(["txt", "srt", "vtt", "json"]).optional(),
|
|
972
|
+
diarize: z.boolean().optional(),
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
async (updates) => {
|
|
976
|
+
const cfg = setConfig(updates);
|
|
977
|
+
return { content: [{ type: "text", text: JSON.stringify(cfg, null, 2) }] };
|
|
978
|
+
}
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
server.registerTool(
|
|
982
|
+
"reset_config",
|
|
983
|
+
{
|
|
984
|
+
title: "Reset Config",
|
|
985
|
+
description: "Reset all transcriber configuration to defaults.",
|
|
986
|
+
inputSchema: {},
|
|
987
|
+
},
|
|
988
|
+
async () => {
|
|
989
|
+
const cfg = resetConfig();
|
|
990
|
+
return { content: [{ type: "text", text: JSON.stringify(cfg, null, 2) }] };
|
|
991
|
+
}
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
// ---------------------------------------------------------------------------
|
|
995
|
+
// search_tools / describe_tools
|
|
996
|
+
// ---------------------------------------------------------------------------
|
|
997
|
+
|
|
998
|
+
server.registerTool(
|
|
999
|
+
"search_tools",
|
|
1000
|
+
{
|
|
1001
|
+
title: "Search Tools",
|
|
1002
|
+
description: "List tool names, optionally filtered by keyword.",
|
|
1003
|
+
inputSchema: { query: z.string().optional() },
|
|
1004
|
+
},
|
|
1005
|
+
async ({ query }) => {
|
|
1006
|
+
const all = [
|
|
1007
|
+
"transcribe",
|
|
1008
|
+
"batch_transcribe",
|
|
1009
|
+
"download_audio",
|
|
1010
|
+
"get_video_info",
|
|
1011
|
+
"list_transcripts",
|
|
1012
|
+
"get_transcript",
|
|
1013
|
+
"search_transcripts",
|
|
1014
|
+
"retry_transcript",
|
|
1015
|
+
"delete_transcript",
|
|
1016
|
+
"export_transcript",
|
|
1017
|
+
"transcript_stats",
|
|
1018
|
+
"check_providers",
|
|
1019
|
+
"translate_transcript",
|
|
1020
|
+
"summarize_transcript",
|
|
1021
|
+
"get_config",
|
|
1022
|
+
"set_config",
|
|
1023
|
+
"reset_config",
|
|
1024
|
+
"search_tools",
|
|
1025
|
+
"describe_tools",
|
|
1026
|
+
];
|
|
1027
|
+
const matches = query ? all.filter((n) => n.includes(query.toLowerCase())) : all;
|
|
1028
|
+
return { content: [{ type: "text" as const, text: matches.join(", ") }] };
|
|
1029
|
+
}
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
server.registerTool(
|
|
1033
|
+
"describe_tools",
|
|
1034
|
+
{
|
|
1035
|
+
title: "Describe Tools",
|
|
1036
|
+
description: "Get full descriptions for specific tools.",
|
|
1037
|
+
inputSchema: { names: z.array(z.string()) },
|
|
1038
|
+
},
|
|
1039
|
+
async ({ names }) => {
|
|
1040
|
+
const descriptions: Record<string, string> = {
|
|
1041
|
+
transcribe: "Transcribe a file or URL. Params: source, provider? (elevenlabs|openai), language?, title?, start?, end?",
|
|
1042
|
+
get_video_info: "Fetch video metadata without downloading. Params: url",
|
|
1043
|
+
list_transcripts: "List transcripts. Params: status?, provider?, source_type?, limit?, offset?",
|
|
1044
|
+
get_transcript: "Get a transcript by ID. Params: id",
|
|
1045
|
+
search_transcripts: "Full-text search. Params: query",
|
|
1046
|
+
retry_transcript: "Retry a failed transcript. Params: id, provider?, diarize?",
|
|
1047
|
+
delete_transcript: "Delete a transcript. Params: id",
|
|
1048
|
+
export_transcript: "Export as txt/srt/json. Params: id, format?",
|
|
1049
|
+
transcript_stats: "Counts by status and provider.",
|
|
1050
|
+
check_providers: "Check which API keys are configured.",
|
|
1051
|
+
};
|
|
1052
|
+
const result = names.map((n) => `${n}: ${descriptions[n] || "See tool schema"}`).join("\n");
|
|
1053
|
+
return { content: [{ type: "text" as const, text: result }] };
|
|
1054
|
+
}
|
|
1055
|
+
);
|
|
1056
|
+
|
|
1057
|
+
// ---------------------------------------------------------------------------
|
|
1058
|
+
// Start
|
|
1059
|
+
// ---------------------------------------------------------------------------
|
|
1060
|
+
|
|
1061
|
+
async function main() {
|
|
1062
|
+
const transport = new StdioServerTransport();
|
|
1063
|
+
await server.connect(transport);
|
|
1064
|
+
console.error("Transcriber MCP server running on stdio");
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
main().catch((error) => {
|
|
1068
|
+
console.error("Fatal error:", error);
|
|
1069
|
+
process.exit(1);
|
|
1070
|
+
});
|