@agentproto/corpus-cli 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1129 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { stat, readFile, mkdir, writeFile, rename, appendFile, readdir, rm, mkdtemp } from 'fs/promises';
3
+ import path, { join, basename, extname } from 'path';
4
+ import { existsSync } from 'fs';
5
+ import os, { tmpdir } from 'os';
6
+ import { z } from 'zod';
7
+ import { execFile } from 'child_process';
8
+ import { promisify } from 'util';
9
+ import { normalizeLanguageTag, buildDistillPrompt, parseItems } from '@agentproto/corpus';
10
+ export { buildDistillPrompt, parseItems } from '@agentproto/corpus';
11
+ import { parseClaudeJsonOutput, spawnWithStdin } from '@agentproto/cli-exec';
12
+
13
+ /**
14
+ * @agentproto/corpus-cli v0.1.0-alpha
15
+ * The `corpus` binary — local-topology host for AIP-10 corpora.
16
+ */
17
+
18
+ var NodeFsAdapter = class {
19
+ root;
20
+ constructor(opts) {
21
+ this.root = opts.root;
22
+ }
23
+ async exists(p) {
24
+ try {
25
+ await stat(this.resolve(p));
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+ async readFile(p) {
32
+ return await readFile(this.resolve(p), "utf8");
33
+ }
34
+ /**
35
+ * Atomic write: write to `${target}.tmp-{nonce}`, fsync, rename.
36
+ * Rename is atomic on the same filesystem. Creates parent dirs as
37
+ * needed (most callers expect mkdir -p semantics).
38
+ */
39
+ async writeFile(p, content) {
40
+ const target = this.resolve(p);
41
+ await mkdir(path.dirname(target), { recursive: true });
42
+ const tmp = `${target}.tmp-${randomBytes(8).toString("hex")}`;
43
+ await writeFile(tmp, content, "utf8");
44
+ await rename(tmp, target);
45
+ }
46
+ async appendFile(p, content) {
47
+ const target = this.resolve(p);
48
+ await mkdir(path.dirname(target), { recursive: true });
49
+ await appendFile(target, content, "utf8");
50
+ }
51
+ async readdir(p) {
52
+ const target = this.resolve(p);
53
+ const entries = await readdir(target);
54
+ return entries;
55
+ }
56
+ async walk(p) {
57
+ const out = [];
58
+ const start = this.resolve(p);
59
+ const rootForRelative = start;
60
+ const visit = async (dir) => {
61
+ let entries = [];
62
+ try {
63
+ entries = await readdir(dir);
64
+ } catch {
65
+ return;
66
+ }
67
+ for (const ent of entries) {
68
+ if (ent.startsWith(".")) continue;
69
+ const full = path.join(dir, ent);
70
+ const s = await stat(full);
71
+ if (s.isDirectory()) {
72
+ await visit(full);
73
+ } else if (s.isFile()) {
74
+ const rel = path.relative(rootForRelative, full).split(path.sep).join("/");
75
+ out.push(rel);
76
+ }
77
+ }
78
+ };
79
+ await visit(start);
80
+ return out;
81
+ }
82
+ async stat(p) {
83
+ try {
84
+ const s = await stat(this.resolve(p));
85
+ return {
86
+ kind: s.isDirectory() ? "directory" : "file",
87
+ bytes: s.isFile() ? s.size : void 0,
88
+ modifiedAt: s.mtime
89
+ };
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+ /**
95
+ * Advisory lock via a lockfile. Spins waiting for the lockfile to
96
+ * disappear, then creates it with exclusive flag. Released by
97
+ * deleting it. Single-process safe; multi-process semi-safe (the
98
+ * write is best-effort exclusive — adequate for the corpus's
99
+ * promote-entry transaction which is rare and short-lived).
100
+ */
101
+ async lock(p) {
102
+ const target = this.resolve(p);
103
+ const lockDir = path.join(path.dirname(target), ".corpus-locks");
104
+ await mkdir(lockDir, { recursive: true });
105
+ const lockFile = path.join(lockDir, path.basename(target) + ".lock");
106
+ const myToken = `${process.pid}-${randomBytes(4).toString("hex")}`;
107
+ while (true) {
108
+ if (!existsSync(lockFile)) {
109
+ try {
110
+ await writeFile(lockFile, myToken, { encoding: "utf8", flag: "wx" });
111
+ const written = await readFile(lockFile, "utf8").catch(() => "");
112
+ if (written === myToken) break;
113
+ } catch {
114
+ }
115
+ }
116
+ await new Promise((r) => setTimeout(r, 25));
117
+ }
118
+ return {
119
+ release: async () => {
120
+ try {
121
+ await rm(lockFile, { force: true });
122
+ } catch {
123
+ }
124
+ }
125
+ };
126
+ }
127
+ // ── Helpers ────────────────────────────────────────────────────
128
+ /**
129
+ * Resolve a workspace-relative path to an absolute host path.
130
+ * Refuses to climb out of the workspace root (defense against
131
+ * `../../etc/passwd`-style abuse) — the corpus kit's paths must
132
+ * stay inside the workspace.
133
+ */
134
+ resolve(p) {
135
+ const normalized = path.posix.normalize("/" + p.replace(/^\/+/, ""));
136
+ const rel = normalized.slice(1);
137
+ if (rel.startsWith("..")) {
138
+ throw new Error(
139
+ `NodeFsAdapter: path "${p}" escapes the workspace root "${this.root}"`
140
+ );
141
+ }
142
+ return path.join(this.root, rel);
143
+ }
144
+ };
145
+ var OsIdentityAdapter = class {
146
+ cached;
147
+ constructor(opts) {
148
+ const user = opts.userOverride ?? process.env.USER ?? process.env.USERNAME ?? safeGetUser() ?? "anonymous";
149
+ const wsName = path.basename(opts.workspaceRoot) || "default";
150
+ this.cached = Object.freeze({
151
+ principal: `ws://users/${slugify(user)}`,
152
+ identityTree: Object.freeze([
153
+ `ws://users/${slugify(user)}`,
154
+ `ws://workspaces/${slugify(wsName)}`
155
+ ]),
156
+ displayName: user
157
+ });
158
+ }
159
+ async resolve() {
160
+ return this.cached;
161
+ }
162
+ };
163
+ function safeGetUser() {
164
+ try {
165
+ return os.userInfo().username;
166
+ } catch {
167
+ return void 0;
168
+ }
169
+ }
170
+ function slugify(s) {
171
+ return s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 64) || "anonymous";
172
+ }
173
+
174
+ // src/ports/video-hosts.ts
175
+ var VIDEO_HOSTS = /* @__PURE__ */ new Set([
176
+ "youtube.com",
177
+ "www.youtube.com",
178
+ "m.youtube.com",
179
+ "music.youtube.com",
180
+ "youtu.be",
181
+ "vimeo.com"
182
+ ]);
183
+ function isVideoUrl(url, extraHosts) {
184
+ let host;
185
+ try {
186
+ host = new URL(url).hostname;
187
+ } catch {
188
+ return false;
189
+ }
190
+ const bare = host.replace(/^www\./, "");
191
+ return VIDEO_HOSTS.has(host) || VIDEO_HOSTS.has(bare) || (extraHosts?.has(host) ?? false) || (extraHosts?.has(bare) ?? false);
192
+ }
193
+
194
+ // src/ports/browser-fetcher.adapter.ts
195
+ var FETCHED_PAYLOAD = z.object({
196
+ title: z.string(),
197
+ text: z.string(),
198
+ kind: z.enum(["video", "article", "page", "unknown"]).catch("unknown"),
199
+ language: z.string().optional().catch(void 0),
200
+ via: z.string().optional().catch(void 0)
201
+ }).loose();
202
+ var MCP_TEXT_BLOCK = z.object({ type: z.literal("text"), text: z.string() }).loose();
203
+ var READABILITY_FN = `() => {
204
+ const title = document.title || "";
205
+ const article = document.querySelector("article") || document.querySelector("main");
206
+ const root = article || document.body;
207
+ const text = (root?.innerText || "").replace(/\\n{3,}/g, "\\n\\n").trim().slice(0, 200000);
208
+ const lang = document.documentElement.getAttribute("lang") || undefined;
209
+ return { title, text, kind: "article", language: lang, via: "readability" };
210
+ }`;
211
+ var BrowserMcpFetcher = class {
212
+ browser;
213
+ navigateTool;
214
+ evaluateTool;
215
+ constructor(opts) {
216
+ this.browser = opts.browser;
217
+ this.navigateTool = opts.navigateToolName ?? "navigate_page";
218
+ this.evaluateTool = opts.evaluateToolName ?? "evaluate_script";
219
+ }
220
+ async fetch(url) {
221
+ if (isVideoUrl(url)) return null;
222
+ await this.browser.callTool({
223
+ name: this.navigateTool,
224
+ arguments: { url }
225
+ });
226
+ const result = await this.browser.callTool({
227
+ name: this.evaluateTool,
228
+ arguments: { function: READABILITY_FN }
229
+ });
230
+ const extracted = parseEvaluateResult(result);
231
+ if (!extracted || !extracted.text.trim()) return null;
232
+ return extracted;
233
+ }
234
+ };
235
+ function parseEvaluateResult(result) {
236
+ return coerceFetched(result.structuredContent) ?? coerceFetched(extractText(result.content));
237
+ }
238
+ function coerceFetched(value) {
239
+ let obj = value;
240
+ if (typeof obj === "string") {
241
+ try {
242
+ obj = JSON.parse(obj);
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+ const parsed = FETCHED_PAYLOAD.safeParse(obj);
248
+ if (!parsed.success) return null;
249
+ const { title, text, kind, language, via } = parsed.data;
250
+ return {
251
+ title,
252
+ text,
253
+ kind,
254
+ ...language ? { language } : {},
255
+ ...via ? { via } : {}
256
+ };
257
+ }
258
+ function extractText(content) {
259
+ if (typeof content === "string") return content;
260
+ if (Array.isArray(content)) {
261
+ for (const block of content) {
262
+ const parsed = MCP_TEXT_BLOCK.safeParse(block);
263
+ if (parsed.success) return parsed.data.text;
264
+ }
265
+ }
266
+ return null;
267
+ }
268
+ var SCRAPE_PAYLOAD = z.object({
269
+ title: z.string().optional().catch(void 0),
270
+ markdown: z.string().optional().catch(void 0),
271
+ html: z.string().optional().catch(void 0),
272
+ language: z.string().optional().catch(void 0),
273
+ tierUsed: z.union([z.string(), z.number()]).optional().catch(void 0)
274
+ }).loose();
275
+ var MCP_TEXT_BLOCK2 = z.object({ type: z.literal("text"), text: z.string() }).loose();
276
+ var ScrapeMcpFetcher = class {
277
+ client;
278
+ toolName;
279
+ constructor(opts) {
280
+ this.client = opts.client;
281
+ this.toolName = opts.scrapeToolName ?? "scrape";
282
+ }
283
+ async fetch(url) {
284
+ if (isVideoUrl(url)) return null;
285
+ let res;
286
+ try {
287
+ res = await this.client.callTool({
288
+ name: this.toolName,
289
+ arguments: { url }
290
+ });
291
+ } catch {
292
+ return null;
293
+ }
294
+ if (res.isError) return null;
295
+ const payload = extractPayload(res);
296
+ if (!payload) return null;
297
+ const text = (payload.markdown?.trim() || stripTags(payload.html)).trim();
298
+ if (!text) return null;
299
+ return {
300
+ title: payload.title?.trim() || url,
301
+ text,
302
+ kind: "article",
303
+ ...payload.language ? { language: payload.language } : {},
304
+ via: payload.tierUsed ? `scrape:${payload.tierUsed}` : "scrape"
305
+ };
306
+ }
307
+ };
308
+ function extractPayload(res) {
309
+ if (res.structuredContent && typeof res.structuredContent === "object") {
310
+ const parsed = SCRAPE_PAYLOAD.safeParse(res.structuredContent);
311
+ if (parsed.success) return parsed.data;
312
+ }
313
+ if (Array.isArray(res.content)) {
314
+ for (const block of res.content) {
315
+ const text = MCP_TEXT_BLOCK2.safeParse(block);
316
+ if (!text.success) continue;
317
+ try {
318
+ const parsed = SCRAPE_PAYLOAD.safeParse(JSON.parse(text.data.text));
319
+ if (parsed.success) return parsed.data;
320
+ } catch {
321
+ return { markdown: text.data.text };
322
+ }
323
+ }
324
+ }
325
+ return null;
326
+ }
327
+ function stripTags(html) {
328
+ if (!html) return "";
329
+ return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
330
+ }
331
+ var execFileAsync = promisify(execFile);
332
+ var YTDLP_INFO = z.object({ title: z.string().optional(), language: z.string().optional() }).loose();
333
+ var YtDlpWhisperFetcher = class {
334
+ stt;
335
+ download;
336
+ extraHosts;
337
+ constructor(opts) {
338
+ this.stt = opts.stt;
339
+ this.download = opts.download ?? defaultYtDlpDownloader(opts.ytDlpBin ?? "yt-dlp", {
340
+ maxDurationSec: opts.maxDurationSec,
341
+ cookiesFromBrowser: opts.cookiesFromBrowser,
342
+ cookiesFile: opts.cookiesFile
343
+ });
344
+ this.extraHosts = opts.extraVideoHosts ? new Set(opts.extraVideoHosts) : void 0;
345
+ }
346
+ async fetch(url) {
347
+ if (!isVideoUrl(url, this.extraHosts)) return null;
348
+ let dl;
349
+ try {
350
+ dl = await this.download(url);
351
+ } catch (e) {
352
+ process.stderr.write(`corpus: download failed for ${url} \u2014 skipped (${msg(e)})
353
+ `);
354
+ return null;
355
+ }
356
+ try {
357
+ const t = await this.stt.transcribe(dl.audioPath);
358
+ if (!t.text.trim()) return null;
359
+ return {
360
+ title: dl.title || url,
361
+ text: t.text,
362
+ kind: "video",
363
+ ...t.language ?? dl.language ? { language: t.language ?? dl.language } : {},
364
+ via: "transcription"
365
+ };
366
+ } catch (e) {
367
+ if (isAuthError(e)) throw e;
368
+ process.stderr.write(
369
+ `corpus: transcription failed for ${url} \u2014 skipped (${msg(e)})
370
+ `
371
+ );
372
+ return null;
373
+ } finally {
374
+ await dl.cleanup().catch(() => {
375
+ });
376
+ }
377
+ }
378
+ };
379
+ function msg(e) {
380
+ return e instanceof Error ? e.message : String(e);
381
+ }
382
+ function isAuthError(e) {
383
+ const m = msg(e).toLowerCase();
384
+ return m.includes(" 401") || m.includes(" 403") || m.includes("unauthorized") || m.includes("forbidden") || m.includes("api key") || m.includes("api_key");
385
+ }
386
+ function defaultYtDlpDownloader(bin, opts) {
387
+ return async (url) => {
388
+ const dir = await mkdtemp(join(tmpdir(), "corpus-ytdlp-"));
389
+ const cleanup = () => rm(dir, { recursive: true, force: true });
390
+ try {
391
+ const durationGuard = opts?.maxDurationSec && opts.maxDurationSec > 0 ? ["--match-filter", `duration <= ${Math.floor(opts.maxDurationSec)}`] : [];
392
+ const cookieArgs = opts?.cookiesFromBrowser ? ["--cookies-from-browser", opts.cookiesFromBrowser] : opts?.cookiesFile ? ["--cookies", opts.cookiesFile] : [];
393
+ await execFileAsync(
394
+ bin,
395
+ [
396
+ "-f",
397
+ "bestaudio",
398
+ "-x",
399
+ "--audio-format",
400
+ "mp3",
401
+ "--audio-quality",
402
+ "64K",
403
+ "--no-playlist",
404
+ "--no-progress",
405
+ ...durationGuard,
406
+ ...cookieArgs,
407
+ "--write-info-json",
408
+ "-o",
409
+ join(dir, "track.%(ext)s"),
410
+ url
411
+ ],
412
+ { maxBuffer: 16 * 1024 * 1024 }
413
+ );
414
+ const files = await readdir(dir);
415
+ const audio = files.find((f) => f === "track.mp3") ?? files.find((f) => /\.(mp3|m4a|opus|webm|ogg|wav)$/i.test(f));
416
+ if (!audio) throw new Error("yt-dlp produced no audio file");
417
+ let title = url;
418
+ let language;
419
+ const infoName = files.find((f) => f.endsWith(".info.json"));
420
+ if (infoName) {
421
+ try {
422
+ const info = YTDLP_INFO.parse(
423
+ JSON.parse(await readFile(join(dir, infoName), "utf-8"))
424
+ );
425
+ if (info.title) title = info.title;
426
+ if (info.language) language = info.language;
427
+ } catch {
428
+ }
429
+ }
430
+ return {
431
+ audioPath: join(dir, audio),
432
+ title,
433
+ ...language ? { language } : {},
434
+ cleanup
435
+ };
436
+ } catch (e) {
437
+ await cleanup().catch(() => {
438
+ });
439
+ throw e;
440
+ }
441
+ };
442
+ }
443
+ var WHISPER_RESPONSE = z.object({ text: z.string().optional(), language: z.string().optional() }).loose();
444
+ var WHISPER_MAX_BYTES = 25 * 1024 * 1024;
445
+ var OpenAiWhisperStt = class {
446
+ apiKey;
447
+ model;
448
+ baseUrl;
449
+ constructor(opts) {
450
+ this.apiKey = opts.apiKey;
451
+ this.model = opts.model ?? "whisper-1";
452
+ this.baseUrl = (opts.baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "");
453
+ }
454
+ async transcribe(audioPath) {
455
+ const bytes = await readFile(audioPath);
456
+ if (bytes.byteLength > WHISPER_MAX_BYTES) {
457
+ throw new Error(
458
+ `audio ${basename(audioPath)} is ${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB \u2014 over Whisper's 25 MB cap. Lower the bitrate or chunk the media.`
459
+ );
460
+ }
461
+ const form = new FormData();
462
+ form.append("file", new Blob([bytes]), basename(audioPath));
463
+ form.append("model", this.model);
464
+ form.append("response_format", "verbose_json");
465
+ const res = await this.postWithRetry(form);
466
+ if (!res.ok) {
467
+ const body = await res.text().catch(() => "");
468
+ throw new Error(`Whisper STT ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
469
+ }
470
+ const data = WHISPER_RESPONSE.parse(await res.json());
471
+ const language = normalizeLanguageTag(data.language);
472
+ return {
473
+ text: (data.text ?? "").trim(),
474
+ ...language ? { language } : {}
475
+ };
476
+ }
477
+ /**
478
+ * POST the audio with bounded retry. Transcribing a long talk fans out
479
+ * into many segment uploads (see ChunkedStt), so a single transient
480
+ * network blip ("fetch failed") or a 429 / 5xx must not abort the whole
481
+ * batch. Network errors and retryable statuses back off and retry;
482
+ * client errors (401 auth, 400 bad request) return immediately so a real
483
+ * misconfiguration still surfaces loudly.
484
+ */
485
+ async postWithRetry(form, attempts = 4) {
486
+ const url = `${this.baseUrl}/audio/transcriptions`;
487
+ let lastErr;
488
+ for (let i = 0; i < attempts; i++) {
489
+ try {
490
+ const res = await fetch(url, {
491
+ method: "POST",
492
+ headers: { authorization: `Bearer ${this.apiKey}` },
493
+ body: form
494
+ });
495
+ if (res.status !== 429 && res.status < 500) return res;
496
+ lastErr = new Error(`Whisper STT ${res.status} ${res.statusText}`);
497
+ } catch (e) {
498
+ lastErr = e;
499
+ }
500
+ if (i < attempts - 1) await delay(500 * 2 ** i);
501
+ }
502
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
503
+ }
504
+ };
505
+ function delay(ms) {
506
+ return new Promise((resolve) => setTimeout(resolve, ms));
507
+ }
508
+ var UPLOAD_RESPONSE = z.object({ upload_url: z.string() });
509
+ var AAI_TRANSCRIPT = z.object({
510
+ id: z.string(),
511
+ status: z.enum(["queued", "processing", "completed", "error"]),
512
+ text: z.string().optional(),
513
+ language_code: z.string().optional(),
514
+ error: z.string().optional(),
515
+ utterances: z.array(z.object({ speaker: z.string(), text: z.string() })).nullish()
516
+ }).loose();
517
+ var AssemblyAiStt = class {
518
+ apiKey;
519
+ baseUrl;
520
+ pollIntervalMs;
521
+ maxWaitMs;
522
+ sleep;
523
+ constructor(opts) {
524
+ this.apiKey = opts.apiKey;
525
+ this.baseUrl = (opts.baseUrl ?? "https://api.assemblyai.com/v2").replace(/\/+$/, "");
526
+ this.pollIntervalMs = opts.pollIntervalMs ?? 3e3;
527
+ this.maxWaitMs = opts.maxWaitMs ?? 15 * 60 * 1e3;
528
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
529
+ }
530
+ async transcribe(audioPath) {
531
+ const bytes = await readFile(audioPath);
532
+ const up = await fetch(`${this.baseUrl}/upload`, {
533
+ method: "POST",
534
+ headers: { authorization: this.apiKey, "content-type": "application/octet-stream" },
535
+ body: bytes
536
+ });
537
+ if (!up.ok) throw new Error(`AssemblyAI upload ${up.status}: ${(await up.text()).slice(0, 200)}`);
538
+ const { upload_url } = UPLOAD_RESPONSE.parse(await up.json());
539
+ const created = await this.post("/transcript", {
540
+ audio_url: upload_url,
541
+ speaker_labels: true,
542
+ language_detection: true
543
+ });
544
+ const id = created.id;
545
+ const deadline = this.maxWaitMs;
546
+ let waited = 0;
547
+ let job = created;
548
+ while (job.status !== "completed" && job.status !== "error") {
549
+ if (waited >= deadline) throw new Error(`AssemblyAI transcript ${id} timed out after ${deadline} ms`);
550
+ await this.sleep(this.pollIntervalMs);
551
+ waited += this.pollIntervalMs;
552
+ job = await this.get(`/transcript/${id}`);
553
+ }
554
+ if (job.status === "error") throw new Error(`AssemblyAI transcript failed: ${job.error ?? "unknown"}`);
555
+ return {
556
+ text: formatDiarized(job),
557
+ ...job.language_code ? { language: job.language_code } : {}
558
+ };
559
+ }
560
+ async post(path3, body) {
561
+ const r = await fetch(`${this.baseUrl}${path3}`, {
562
+ method: "POST",
563
+ headers: { authorization: this.apiKey, "content-type": "application/json" },
564
+ body: JSON.stringify(body)
565
+ });
566
+ if (!r.ok) throw new Error(`AssemblyAI POST ${path3} ${r.status}: ${(await r.text()).slice(0, 200)}`);
567
+ return AAI_TRANSCRIPT.parse(await r.json());
568
+ }
569
+ async get(path3) {
570
+ const r = await fetch(`${this.baseUrl}${path3}`, { headers: { authorization: this.apiKey } });
571
+ if (!r.ok) throw new Error(`AssemblyAI GET ${path3} ${r.status}: ${(await r.text()).slice(0, 200)}`);
572
+ return AAI_TRANSCRIPT.parse(await r.json());
573
+ }
574
+ };
575
+ function formatDiarized(job) {
576
+ if (job.utterances && job.utterances.length > 0) {
577
+ return job.utterances.map((u) => `Speaker ${u.speaker}: ${u.text.trim()}`).join("\n\n").trim();
578
+ }
579
+ return (job.text ?? "").trim();
580
+ }
581
+ var execFileAsync2 = promisify(execFile);
582
+ var DEFAULT_MAX_BYTES = 24 * 1024 * 1024;
583
+ var DEFAULT_SEGMENT_SECONDS = 1200;
584
+ var ChunkedStt = class {
585
+ base;
586
+ maxBytes;
587
+ segmentSeconds;
588
+ split;
589
+ constructor(opts) {
590
+ this.base = opts.base;
591
+ this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
592
+ this.segmentSeconds = opts.segmentSeconds ?? DEFAULT_SEGMENT_SECONDS;
593
+ this.split = opts.split ?? defaultFfmpegSplitter(opts.ffmpegBin ?? "ffmpeg");
594
+ }
595
+ async transcribe(audioPath) {
596
+ const { size } = await stat(audioPath);
597
+ if (size <= this.maxBytes) return this.base.transcribe(audioPath);
598
+ const dir = await mkdtemp(join(tmpdir(), "corpus-stt-chunk-"));
599
+ try {
600
+ const parts = await this.split(audioPath, this.segmentSeconds, dir);
601
+ if (parts.length === 0) {
602
+ return this.base.transcribe(audioPath);
603
+ }
604
+ const texts = [];
605
+ let language;
606
+ let failed = 0;
607
+ let firstErr;
608
+ for (const part of parts) {
609
+ try {
610
+ const t = await this.base.transcribe(part);
611
+ if (t.text.trim()) texts.push(t.text.trim());
612
+ language ??= t.language;
613
+ } catch (e) {
614
+ failed++;
615
+ firstErr ??= e;
616
+ }
617
+ }
618
+ if (texts.length === 0) {
619
+ throw firstErr ?? new Error("all audio segments failed transcription");
620
+ }
621
+ if (failed > 0) {
622
+ process.stderr.write(
623
+ `corpus: ${failed}/${parts.length} audio segments failed \u2014 partial transcript
624
+ `
625
+ );
626
+ }
627
+ return {
628
+ text: texts.join("\n\n"),
629
+ ...language ? { language } : {}
630
+ };
631
+ } finally {
632
+ await rm(dir, { recursive: true, force: true }).catch(() => {
633
+ });
634
+ }
635
+ }
636
+ };
637
+ function defaultFfmpegSplitter(bin) {
638
+ return async (audioPath, segmentSeconds, outDir) => {
639
+ const ext = extname(audioPath) || ".mp3";
640
+ await execFileAsync2(
641
+ bin,
642
+ [
643
+ "-hide_banner",
644
+ "-loglevel",
645
+ "error",
646
+ "-i",
647
+ audioPath,
648
+ "-f",
649
+ "segment",
650
+ "-segment_time",
651
+ String(segmentSeconds),
652
+ "-c",
653
+ "copy",
654
+ join(outDir, `part%03d${ext}`)
655
+ ],
656
+ { maxBuffer: 16 * 1024 * 1024 }
657
+ );
658
+ const files = (await readdir(outDir)).filter((f) => f.startsWith("part") && f.endsWith(ext)).sort();
659
+ return files.map((f) => join(outDir, f));
660
+ };
661
+ }
662
+
663
+ // src/ports/composite-fetcher.ts
664
+ var CompositeFetcher = class {
665
+ children;
666
+ constructor(children) {
667
+ this.children = children;
668
+ }
669
+ async fetch(url) {
670
+ for (const child of this.children) {
671
+ const result = await child.fetch(url);
672
+ if (result) return result;
673
+ }
674
+ return null;
675
+ }
676
+ };
677
+
678
+ // src/ports/throttle-fetcher.adapter.ts
679
+ var ThrottleFetcher = class {
680
+ inner;
681
+ minIntervalMs;
682
+ now;
683
+ sleep;
684
+ lastStart = 0;
685
+ started = false;
686
+ constructor(inner, opts) {
687
+ this.inner = inner;
688
+ this.minIntervalMs = Math.max(0, opts.minIntervalMs);
689
+ this.now = opts.now ?? (() => Date.now());
690
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
691
+ }
692
+ async fetch(url) {
693
+ if (this.minIntervalMs > 0 && this.started) {
694
+ const wait = this.minIntervalMs - (this.now() - this.lastStart);
695
+ if (wait > 0) await this.sleep(wait);
696
+ }
697
+ this.started = true;
698
+ this.lastStart = this.now();
699
+ return this.inner.fetch(url);
700
+ }
701
+ };
702
+
703
+ // src/ports/http-readability-fetcher.adapter.ts
704
+ var DEFAULT_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0 Safari/537.36";
705
+ var HttpReadabilityFetcher = class {
706
+ ua;
707
+ maxChars;
708
+ constructor(opts = {}) {
709
+ this.ua = opts.userAgent ?? DEFAULT_UA;
710
+ this.maxChars = opts.maxChars ?? 2e5;
711
+ }
712
+ async fetch(url) {
713
+ if (isVideoUrl(url)) return null;
714
+ let res;
715
+ try {
716
+ res = await fetch(url, {
717
+ headers: { "user-agent": this.ua, "accept-language": "en,fr;q=0.8" },
718
+ redirect: "follow"
719
+ });
720
+ } catch {
721
+ return null;
722
+ }
723
+ if (!res.ok) return null;
724
+ const ct = res.headers.get("content-type") ?? "";
725
+ if (!ct.includes("text/html") && !ct.includes("application/xhtml")) return null;
726
+ const html = await res.text();
727
+ const title = decodeEntities(
728
+ (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? "").trim()
729
+ );
730
+ const lang = html.match(/<html[^>]*\blang=["']?([a-zA-Z-]+)/i)?.[1];
731
+ const text = extractReadable(html).slice(0, this.maxChars);
732
+ if (!text.trim()) return null;
733
+ return {
734
+ title: title || url,
735
+ text,
736
+ kind: "article",
737
+ ...lang ? { language: lang } : {},
738
+ via: "readability"
739
+ };
740
+ }
741
+ };
742
+ function extractReadable(html) {
743
+ const region = html.match(/<article[\s\S]*?<\/article>/i)?.[0] ?? html.match(/<main[\s\S]*?<\/main>/i)?.[0] ?? html.match(/<body[\s\S]*?<\/body>/i)?.[0] ?? html;
744
+ return decodeEntities(
745
+ region.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<(nav|header|footer|aside|form)[\s\S]*?<\/\1>/gi, " ").replace(/<[^>]+>/g, " ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n")
746
+ ).trim();
747
+ }
748
+ function decodeEntities(s) {
749
+ return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
750
+ }
751
+
752
+ // src/ports/anthropic-distiller.ts
753
+ var ANTHROPIC_RESPONSE = z.object({
754
+ model: z.string().optional(),
755
+ content: z.array(z.object({ type: z.string(), text: z.string().optional() }).loose()).optional(),
756
+ usage: z.object({ input_tokens: z.number(), output_tokens: z.number() }).loose().optional()
757
+ }).loose();
758
+ var AnthropicDistiller = class {
759
+ apiKey;
760
+ model;
761
+ baseUrl;
762
+ maxItems;
763
+ onUsage;
764
+ constructor(opts) {
765
+ this.apiKey = opts.apiKey;
766
+ this.model = opts.model ?? "claude-sonnet-4-6";
767
+ this.baseUrl = (opts.baseUrl ?? "https://api.anthropic.com/v1").replace(/\/+$/, "");
768
+ this.maxItems = opts.maxItems ?? 8;
769
+ this.onUsage = opts.onUsage;
770
+ }
771
+ async distill(input) {
772
+ const prompt = buildDistillPrompt(input, this.maxItems);
773
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
774
+ const res = await fetch(`${this.baseUrl}/messages`, {
775
+ method: "POST",
776
+ headers: {
777
+ "x-api-key": this.apiKey,
778
+ "anthropic-version": "2023-06-01",
779
+ "content-type": "application/json"
780
+ },
781
+ body: JSON.stringify({
782
+ model: this.model,
783
+ max_tokens: 4096,
784
+ messages: [{ role: "user", content: prompt }]
785
+ })
786
+ });
787
+ if (!res.ok) {
788
+ throw new Error(`Anthropic distill ${res.status}: ${(await res.text()).slice(0, 200)}`);
789
+ }
790
+ const parsed = ANTHROPIC_RESPONSE.safeParse(await res.json());
791
+ const text = parsed.success ? (parsed.data.content ?? []).find((c) => c.type === "text")?.text ?? "" : "";
792
+ if (parsed.success && parsed.data.usage && this.onUsage) {
793
+ this.onUsage({
794
+ model: parsed.data.model ?? this.model,
795
+ inputTokens: parsed.data.usage.input_tokens,
796
+ outputTokens: parsed.data.usage.output_tokens,
797
+ label: input.title,
798
+ startedAt,
799
+ endedAt: (/* @__PURE__ */ new Date()).toISOString()
800
+ });
801
+ }
802
+ return parseItems(text);
803
+ }
804
+ };
805
+ var CliAgentDistiller = class {
806
+ engine;
807
+ model;
808
+ maxItems;
809
+ timeoutMs;
810
+ cwd;
811
+ constructor(opts) {
812
+ this.engine = opts.engine;
813
+ this.model = opts.model;
814
+ this.maxItems = opts.maxItems ?? 8;
815
+ this.timeoutMs = opts.timeoutMs ?? 12e4;
816
+ this.cwd = opts.cwd ?? tmpdir();
817
+ }
818
+ async distill(input) {
819
+ const prompt = buildDistillPrompt(input, this.maxItems);
820
+ const stdout = await spawnWithStdin({
821
+ command: this.engine.command,
822
+ args: this.engine.buildArgs({ ...this.model ? { model: this.model } : {} }),
823
+ stdin: prompt,
824
+ cwd: this.cwd,
825
+ timeoutMs: this.timeoutMs
826
+ });
827
+ const text = this.engine.parseOutput(stdout) ?? stdout;
828
+ return parseItems(text);
829
+ }
830
+ };
831
+ var CLAUDE_CODE = {
832
+ id: "claude-code",
833
+ subscriptionBilled: true,
834
+ command: "claude",
835
+ buildArgs: ({ model }) => [
836
+ "-p",
837
+ "--output-format",
838
+ "json",
839
+ "--strict-mcp-config",
840
+ ...model ? ["--model", model] : []
841
+ ],
842
+ parseOutput: parseClaudeJsonOutput
843
+ };
844
+ var CLI_ENGINES = {
845
+ [CLAUDE_CODE.id]: CLAUDE_CODE
846
+ };
847
+ var RPC_RESPONSE = z.object({ result: z.unknown().optional(), error: z.unknown().optional() }).loose();
848
+ var RPC_MESSAGE = z.object({
849
+ id: z.number().optional(),
850
+ result: z.unknown().optional(),
851
+ error: z.unknown().optional()
852
+ }).loose();
853
+ var CALL_RESULT = z.object({
854
+ content: z.unknown().optional(),
855
+ structuredContent: z.unknown().optional(),
856
+ isError: z.boolean().optional()
857
+ }).loose();
858
+ async function connectMcpHttp(opts) {
859
+ if (opts.transport === "sse") {
860
+ const sse = new SseMcpClient(opts);
861
+ await sse.connect();
862
+ return sse;
863
+ }
864
+ const client = new StreamableHttpMcpClient(opts);
865
+ await client.initialize();
866
+ return client;
867
+ }
868
+ var StreamableHttpMcpClient = class {
869
+ id = 0;
870
+ sessionId;
871
+ endpoint;
872
+ headers;
873
+ protocolVersion;
874
+ constructor(opts) {
875
+ this.endpoint = opts.endpoint;
876
+ this.headers = opts.headers ?? {};
877
+ this.protocolVersion = opts.protocolVersion ?? "2025-06-18";
878
+ }
879
+ async initialize() {
880
+ const res = await this.rpc({
881
+ jsonrpc: "2.0",
882
+ id: ++this.id,
883
+ method: "initialize",
884
+ params: {
885
+ protocolVersion: this.protocolVersion,
886
+ capabilities: {},
887
+ clientInfo: { name: "corpus-cli", version: "0.1.0" }
888
+ }
889
+ });
890
+ await this.rpc({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }, false);
891
+ if (res?.error) throw new Error(`initialize failed: ${JSON.stringify(res.error)}`);
892
+ }
893
+ async callTool(name, args) {
894
+ const res = await this.rpc({
895
+ jsonrpc: "2.0",
896
+ id: ++this.id,
897
+ method: "tools/call",
898
+ params: { name, arguments: args }
899
+ });
900
+ if (res?.error) throw new Error(`tools/call ${name}: ${JSON.stringify(res.error)}`);
901
+ const parsed = CALL_RESULT.safeParse(res?.result ?? {});
902
+ return parsed.success ? parsed.data : {};
903
+ }
904
+ async rpc(body, expectResponse = true) {
905
+ const res = await fetch(this.endpoint, {
906
+ method: "POST",
907
+ headers: {
908
+ "content-type": "application/json",
909
+ accept: "application/json, text/event-stream",
910
+ ...this.sessionId ? { "mcp-session-id": this.sessionId } : {},
911
+ ...this.headers
912
+ },
913
+ body: JSON.stringify(body)
914
+ });
915
+ const sid = res.headers.get("mcp-session-id");
916
+ if (sid) this.sessionId = sid;
917
+ if (!expectResponse) return null;
918
+ if (!res.ok) throw new Error(`MCP HTTP ${res.status} ${res.statusText}`);
919
+ return parseRpc(await res.text(), res.headers.get("content-type"));
920
+ }
921
+ };
922
+ var SseMcpClient = class {
923
+ constructor(opts) {
924
+ this.opts = opts;
925
+ this.base = new URL(opts.endpoint);
926
+ }
927
+ id = 0;
928
+ messagesUrl;
929
+ pending = /* @__PURE__ */ new Map();
930
+ readyResolve;
931
+ ready = new Promise((r) => this.readyResolve = r);
932
+ base;
933
+ async connect() {
934
+ const res = await fetch(this.opts.endpoint, {
935
+ headers: { accept: "text/event-stream", ...this.opts.headers ?? {} }
936
+ });
937
+ if (!res.ok || !res.body) throw new Error(`SSE connect ${res.status} ${res.statusText}`);
938
+ void this.readLoop(res.body.getReader());
939
+ await withTimeout(this.ready, 1e4, "SSE endpoint event");
940
+ const init = await this.send("initialize", {
941
+ protocolVersion: this.opts.protocolVersion ?? "2025-06-18",
942
+ capabilities: {},
943
+ clientInfo: { name: "corpus-cli", version: "0.1.0" }
944
+ });
945
+ if (init?.error) throw new Error(`initialize failed: ${JSON.stringify(init.error)}`);
946
+ await this.post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} });
947
+ }
948
+ async callTool(name, args) {
949
+ const res = await this.send("tools/call", { name, arguments: args });
950
+ if (res?.error) throw new Error(`tools/call ${name}: ${JSON.stringify(res.error)}`);
951
+ const parsed = CALL_RESULT.safeParse(res?.result ?? {});
952
+ return parsed.success ? parsed.data : {};
953
+ }
954
+ async send(method, params) {
955
+ const id = ++this.id;
956
+ const wait = new Promise((resolve) => this.pending.set(id, resolve));
957
+ await this.post({ jsonrpc: "2.0", id, method, params });
958
+ return withTimeout(wait, 6e4, `${method} response`);
959
+ }
960
+ async post(body) {
961
+ if (!this.messagesUrl) throw new Error("SSE messages endpoint not ready");
962
+ const res = await fetch(this.messagesUrl, {
963
+ method: "POST",
964
+ headers: { "content-type": "application/json", ...this.opts.headers ?? {} },
965
+ body: JSON.stringify(body)
966
+ });
967
+ if (!res.ok && res.status !== 202) throw new Error(`SSE POST ${res.status}`);
968
+ }
969
+ async readLoop(reader) {
970
+ const dec = new TextDecoder();
971
+ let buf = "";
972
+ for (; ; ) {
973
+ const { done, value } = await reader.read();
974
+ if (done) break;
975
+ buf += dec.decode(value, { stream: true });
976
+ let nl;
977
+ while ((nl = buf.indexOf("\n\n")) >= 0) {
978
+ const raw = buf.slice(0, nl);
979
+ buf = buf.slice(nl + 2);
980
+ this.handleEvent(raw);
981
+ }
982
+ }
983
+ }
984
+ handleEvent(raw) {
985
+ let event = "message";
986
+ const data = [];
987
+ for (const line of raw.split("\n")) {
988
+ if (line.startsWith("event:")) event = line.slice(6).trim();
989
+ else if (line.startsWith("data:")) data.push(line.slice(5).trim());
990
+ }
991
+ const payload = data.join("\n");
992
+ if (event === "endpoint") {
993
+ this.messagesUrl = new URL(payload, this.base).toString();
994
+ this.readyResolve();
995
+ return;
996
+ }
997
+ if (!payload) return;
998
+ try {
999
+ const parsed = RPC_MESSAGE.safeParse(JSON.parse(payload));
1000
+ if (!parsed.success) return;
1001
+ const msg3 = parsed.data;
1002
+ if (typeof msg3.id === "number" && this.pending.has(msg3.id)) {
1003
+ this.pending.get(msg3.id)(msg3);
1004
+ this.pending.delete(msg3.id);
1005
+ }
1006
+ } catch {
1007
+ }
1008
+ }
1009
+ };
1010
+ function withTimeout(p, ms, what) {
1011
+ return Promise.race([
1012
+ p,
1013
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`timeout waiting for ${what}`)), ms))
1014
+ ]);
1015
+ }
1016
+ function parseRpc(text, contentType) {
1017
+ if (!text.trim()) return null;
1018
+ if (!(contentType ?? "").includes("text/event-stream")) {
1019
+ const parsed = RPC_RESPONSE.safeParse(JSON.parse(text));
1020
+ return parsed.success ? parsed.data : null;
1021
+ }
1022
+ let last = null;
1023
+ for (const line of text.split("\n")) {
1024
+ const t = line.trim();
1025
+ if (!t.startsWith("data:")) continue;
1026
+ const p = t.slice(5).trim();
1027
+ if (!p || p === "[DONE]") continue;
1028
+ try {
1029
+ last = JSON.parse(p);
1030
+ } catch {
1031
+ }
1032
+ }
1033
+ return last;
1034
+ }
1035
+ z.object({
1036
+ endpoint: z.string().min(1),
1037
+ tool: z.string().min(1),
1038
+ /** Arg template — substituted per entry. */
1039
+ args: z.record(z.string(), z.unknown()),
1040
+ headers: z.record(z.string(), z.string()).optional(),
1041
+ /** MCP transport: "streamable-http" (default) or "sse". */
1042
+ transport: z.enum(["streamable-http", "sse"]).optional(),
1043
+ /**
1044
+ * If set, calls go through `mcp_imported_call({ alias, toolName, args })`
1045
+ * (the agentproto daemon's imported-MCP proxy) instead of the tool directly.
1046
+ */
1047
+ importedAlias: z.string().optional()
1048
+ });
1049
+ var McpSink = class {
1050
+ constructor(config, client) {
1051
+ this.config = config;
1052
+ this.client = client;
1053
+ }
1054
+ client;
1055
+ async ensure() {
1056
+ if (!this.client) {
1057
+ this.client = await connectMcpHttp({
1058
+ endpoint: this.config.endpoint,
1059
+ ...this.config.headers ? { headers: this.config.headers } : {},
1060
+ ...this.config.transport ? { transport: this.config.transport } : {}
1061
+ });
1062
+ }
1063
+ return this.client;
1064
+ }
1065
+ async push(item) {
1066
+ let client;
1067
+ try {
1068
+ client = await this.ensure();
1069
+ } catch (e) {
1070
+ return { uri: item.uri, ok: false, error: `connect: ${msg2(e)}` };
1071
+ }
1072
+ const args = template(this.config.args, item);
1073
+ const call = this.config.importedAlias ? { name: "mcp_imported_call", args: { alias: this.config.importedAlias, toolName: this.config.tool, args } } : { name: this.config.tool, args };
1074
+ try {
1075
+ const res = await client.callTool(call.name, call.args);
1076
+ if (res.isError) return { uri: item.uri, ok: false, error: "tool returned isError" };
1077
+ return { uri: item.uri, ok: true };
1078
+ } catch (e) {
1079
+ return { uri: item.uri, ok: false, error: msg2(e) };
1080
+ }
1081
+ }
1082
+ };
1083
+ var PLACEHOLDER_RE = /\$\{(\w+)\}/g;
1084
+ function template(args, item) {
1085
+ const fields = {
1086
+ slug: item.slug,
1087
+ title: item.title,
1088
+ body: item.body,
1089
+ sources: item.sources,
1090
+ tags: item.tags,
1091
+ access: item.access ?? "",
1092
+ uri: item.uri,
1093
+ kind: item.kind,
1094
+ confidence: item.confidence
1095
+ };
1096
+ const out = {};
1097
+ for (const [key, value] of Object.entries(args)) {
1098
+ out[key] = substitute(value, fields);
1099
+ }
1100
+ return out;
1101
+ }
1102
+ function substitute(value, fields) {
1103
+ if (typeof value === "string") {
1104
+ const whole = value.match(/^\$\{(\w+)\}$/);
1105
+ if (whole && whole[1] in fields) return fields[whole[1]];
1106
+ return value.replace(
1107
+ PLACEHOLDER_RE,
1108
+ (_, k) => k in fields ? stringify(fields[k]) : `\${${k}}`
1109
+ );
1110
+ }
1111
+ if (Array.isArray(value)) return value.map((v) => substitute(v, fields));
1112
+ if (value && typeof value === "object") {
1113
+ const out = {};
1114
+ for (const [k, v] of Object.entries(value)) out[k] = substitute(v, fields);
1115
+ return out;
1116
+ }
1117
+ return value;
1118
+ }
1119
+ function stringify(v) {
1120
+ if (Array.isArray(v)) return v.join(", ");
1121
+ return v == null ? "" : String(v);
1122
+ }
1123
+ function msg2(e) {
1124
+ return e instanceof Error ? e.message : String(e);
1125
+ }
1126
+
1127
+ export { AnthropicDistiller, AssemblyAiStt, BrowserMcpFetcher, CLI_ENGINES, ChunkedStt, CliAgentDistiller, CompositeFetcher, HttpReadabilityFetcher, McpSink, NodeFsAdapter, OpenAiWhisperStt, OsIdentityAdapter, ScrapeMcpFetcher, ThrottleFetcher, YtDlpWhisperFetcher, connectMcpHttp };
1128
+ //# sourceMappingURL=index.mjs.map
1129
+ //# sourceMappingURL=index.mjs.map