@agentproto/corpus-cli 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -404,8 +404,8 @@ function resolveWorkspacePath(arg) {
404
404
  if (!arg) return process.cwd();
405
405
  return path.resolve(process.cwd(), arg);
406
406
  }
407
- function fail(msg4, code = 1) {
408
- process.stderr.write(`corpus: ${msg4}
407
+ function fail(msg5, code = 1) {
408
+ process.stderr.write(`corpus: ${msg5}
409
409
  `);
410
410
  return code;
411
411
  }
@@ -926,7 +926,8 @@ var YtDlpWhisperFetcher = class {
926
926
  this.download = opts.download ?? defaultYtDlpDownloader(opts.ytDlpBin ?? "yt-dlp", {
927
927
  maxDurationSec: opts.maxDurationSec,
928
928
  cookiesFromBrowser: opts.cookiesFromBrowser,
929
- cookiesFile: opts.cookiesFile
929
+ cookiesFile: opts.cookiesFile,
930
+ ffmpegLocation: opts.ffmpegLocation
930
931
  });
931
932
  this.extraHosts = opts.extraVideoHosts ? new Set(opts.extraVideoHosts) : void 0;
932
933
  }
@@ -970,32 +971,44 @@ function isAuthError(e) {
970
971
  const m = msg(e).toLowerCase();
971
972
  return m.includes(" 401") || m.includes(" 403") || m.includes("unauthorized") || m.includes("forbidden") || m.includes("api key") || m.includes("api_key");
972
973
  }
974
+ function buildYtDlpArgs(url, dir, opts) {
975
+ const durationGuard = opts?.maxDurationSec && opts.maxDurationSec > 0 ? ["--match-filter", `duration <= ${Math.floor(opts.maxDurationSec)}`] : [];
976
+ const cookieArgs = opts?.cookiesFromBrowser ? ["--cookies-from-browser", opts.cookiesFromBrowser] : opts?.cookiesFile ? ["--cookies", opts.cookiesFile] : [];
977
+ const ffmpegArgs = opts?.ffmpegLocation ? ["--ffmpeg-location", opts.ffmpegLocation] : [];
978
+ const client = cookieArgs.length === 0 ? "android" : "web_creator";
979
+ const extractorArgs = ["--extractor-args", `youtube:player_client=${client}`];
980
+ return [
981
+ "-f",
982
+ "bestaudio/best",
983
+ // /best fallback: many videos lack a standalone bestaudio
984
+ "-x",
985
+ "--audio-format",
986
+ "mp3",
987
+ "--audio-quality",
988
+ "64K",
989
+ "--no-playlist",
990
+ "--no-progress",
991
+ "--remote-components",
992
+ "ejs:github",
993
+ // solves YouTube nsig "n challenge"
994
+ ...extractorArgs,
995
+ ...durationGuard,
996
+ ...cookieArgs,
997
+ ...ffmpegArgs,
998
+ "--write-info-json",
999
+ "-o",
1000
+ join(dir, "track.%(ext)s"),
1001
+ url
1002
+ ];
1003
+ }
973
1004
  function defaultYtDlpDownloader(bin, opts) {
974
1005
  return async (url) => {
975
1006
  const dir = await mkdtemp(join(tmpdir(), "corpus-ytdlp-"));
976
1007
  const cleanup = () => rm(dir, { recursive: true, force: true });
977
1008
  try {
978
- const durationGuard = opts?.maxDurationSec && opts.maxDurationSec > 0 ? ["--match-filter", `duration <= ${Math.floor(opts.maxDurationSec)}`] : [];
979
- const cookieArgs = opts?.cookiesFromBrowser ? ["--cookies-from-browser", opts.cookiesFromBrowser] : opts?.cookiesFile ? ["--cookies", opts.cookiesFile] : [];
980
1009
  await execFileAsync(
981
1010
  bin,
982
- [
983
- "-f",
984
- "bestaudio",
985
- "-x",
986
- "--audio-format",
987
- "mp3",
988
- "--audio-quality",
989
- "64K",
990
- "--no-playlist",
991
- "--no-progress",
992
- ...durationGuard,
993
- ...cookieArgs,
994
- "--write-info-json",
995
- "-o",
996
- join(dir, "track.%(ext)s"),
997
- url
998
- ],
1011
+ buildYtDlpArgs(url, dir, opts),
999
1012
  { maxBuffer: 16 * 1024 * 1024 }
1000
1013
  );
1001
1014
  const files = await readdir(dir);
@@ -1027,6 +1040,146 @@ function defaultYtDlpDownloader(bin, opts) {
1027
1040
  }
1028
1041
  };
1029
1042
  }
1043
+ var execFileAsync2 = promisify(execFile);
1044
+ var YTDLP_INFO2 = z.object({ title: z.string().optional(), language: z.string().optional() }).loose();
1045
+ var YtDlpCaptionsFetcher = class {
1046
+ download;
1047
+ extraHosts;
1048
+ minChars;
1049
+ constructor(opts = {}) {
1050
+ this.minChars = opts.minChars ?? 400;
1051
+ this.download = opts.download ?? defaultYtDlpCaptionDownloader(opts.ytDlpBin ?? "yt-dlp", {
1052
+ ...opts.subLangs ? { subLangs: opts.subLangs } : {},
1053
+ ...opts.preferLang ? { preferLang: opts.preferLang } : {},
1054
+ ...opts.maxDurationSec ? { maxDurationSec: opts.maxDurationSec } : {},
1055
+ ...opts.cookiesFromBrowser ? { cookiesFromBrowser: opts.cookiesFromBrowser } : {},
1056
+ ...opts.cookiesFile ? { cookiesFile: opts.cookiesFile } : {}
1057
+ });
1058
+ this.extraHosts = opts.extraVideoHosts ? new Set(opts.extraVideoHosts) : void 0;
1059
+ }
1060
+ async fetch(url) {
1061
+ if (!isVideoUrl(url, this.extraHosts)) return null;
1062
+ let dl;
1063
+ try {
1064
+ dl = await this.download(url);
1065
+ } catch (e) {
1066
+ process.stderr.write(
1067
+ `corpus: caption fetch failed for ${url} \u2014 falling back (${msg2(e)})
1068
+ `
1069
+ );
1070
+ return null;
1071
+ }
1072
+ try {
1073
+ if (!dl.vttPath) return null;
1074
+ const text = parseVttToText(await readFile(dl.vttPath, "utf-8"));
1075
+ if (text.length < this.minChars) return null;
1076
+ return {
1077
+ title: dl.title || url,
1078
+ text,
1079
+ kind: "video",
1080
+ ...dl.language ? { language: dl.language } : {},
1081
+ via: "captions"
1082
+ };
1083
+ } catch {
1084
+ return null;
1085
+ } finally {
1086
+ await dl.cleanup().catch(() => {
1087
+ });
1088
+ }
1089
+ }
1090
+ };
1091
+ function msg2(e) {
1092
+ return e instanceof Error ? e.message : String(e);
1093
+ }
1094
+ function parseVttToText(vtt) {
1095
+ const out = [];
1096
+ for (const raw of vtt.split(/\r?\n/)) {
1097
+ if (/^WEBVTT/.test(raw)) continue;
1098
+ if (/^(Kind|Language|NOTE|STYLE):/i.test(raw)) continue;
1099
+ if (raw.includes("-->")) continue;
1100
+ const line = raw.replace(/<[^>]+>/g, "").replace(/&nbsp;/gi, " ").replace(/ /g, " ").trim();
1101
+ if (!line) continue;
1102
+ if (out.length && out[out.length - 1] === line) continue;
1103
+ out.push(line);
1104
+ }
1105
+ const cleaned = [];
1106
+ for (let i = 0; i < out.length; i++) {
1107
+ const next = out[i + 1];
1108
+ if (next && next.startsWith(out[i])) continue;
1109
+ cleaned.push(out[i]);
1110
+ }
1111
+ return cleaned.join(" ").replace(/\s+/g, " ").trim();
1112
+ }
1113
+ function buildYtDlpCaptionArgs(url, dir, opts) {
1114
+ const subLangs = opts?.subLangs ?? (opts?.preferLang ? `${opts.preferLang}.*` : "en.*,fr.*,es.*,de.*,it.*,pt.*,nl.*");
1115
+ const durationGuard = opts?.maxDurationSec && opts.maxDurationSec > 0 ? ["--match-filter", `duration <= ${Math.floor(opts.maxDurationSec)}`] : [];
1116
+ const cookieArgs = opts?.cookiesFromBrowser ? ["--cookies-from-browser", opts.cookiesFromBrowser] : opts?.cookiesFile ? ["--cookies", opts.cookiesFile] : [];
1117
+ return [
1118
+ "--skip-download",
1119
+ "--write-auto-subs",
1120
+ "--write-subs",
1121
+ "--sub-langs",
1122
+ subLangs,
1123
+ "--sub-format",
1124
+ "vtt",
1125
+ "--no-playlist",
1126
+ "--no-progress",
1127
+ "--sleep-requests",
1128
+ "1",
1129
+ ...durationGuard,
1130
+ ...cookieArgs,
1131
+ "--write-info-json",
1132
+ "-o",
1133
+ join(dir, "%(id)s.%(ext)s"),
1134
+ url
1135
+ ];
1136
+ }
1137
+ function pickVtt(files, preferLang) {
1138
+ const vtts = files.filter((f) => f.endsWith(".vtt"));
1139
+ if (vtts.length === 0) return void 0;
1140
+ const bySuffix = (suffix) => vtts.find((f) => f.toLowerCase().endsWith(suffix.toLowerCase()));
1141
+ if (preferLang) {
1142
+ return bySuffix(`.${preferLang}-orig.vtt`) ?? bySuffix(`.${preferLang}.vtt`) ?? vtts.find((f) => new RegExp(`\\.${preferLang}[.-]`, "i").test(f)) ?? vtts.find((f) => /-orig\.vtt$/i.test(f)) ?? vtts[0];
1143
+ }
1144
+ return vtts.find((f) => /-orig\.vtt$/i.test(f)) ?? vtts[0];
1145
+ }
1146
+ function defaultYtDlpCaptionDownloader(bin, opts) {
1147
+ return async (url) => {
1148
+ const dir = await mkdtemp(join(tmpdir(), "corpus-captions-"));
1149
+ const cleanup = () => rm(dir, { recursive: true, force: true });
1150
+ try {
1151
+ await execFileAsync2(bin, buildYtDlpCaptionArgs(url, dir, opts), {
1152
+ maxBuffer: 16 * 1024 * 1024
1153
+ }).catch(() => {
1154
+ });
1155
+ const files = await readdir(dir);
1156
+ const vtt = pickVtt(files, opts?.preferLang);
1157
+ let title = url;
1158
+ let language;
1159
+ const infoName = files.find((f) => f.endsWith(".info.json"));
1160
+ if (infoName) {
1161
+ try {
1162
+ const info = YTDLP_INFO2.parse(
1163
+ JSON.parse(await readFile(join(dir, infoName), "utf-8"))
1164
+ );
1165
+ if (info.title) title = info.title;
1166
+ if (info.language) language = info.language;
1167
+ } catch {
1168
+ }
1169
+ }
1170
+ return {
1171
+ vttPath: vtt ? join(dir, vtt) : null,
1172
+ title,
1173
+ ...language ? { language } : {},
1174
+ cleanup
1175
+ };
1176
+ } catch (e) {
1177
+ await cleanup().catch(() => {
1178
+ });
1179
+ throw e;
1180
+ }
1181
+ };
1182
+ }
1030
1183
  var WHISPER_RESPONSE = z.object({ text: z.string().optional(), language: z.string().optional() }).loose();
1031
1184
  var WHISPER_MAX_BYTES = 25 * 1024 * 1024;
1032
1185
  var OpenAiWhisperStt = class {
@@ -1165,7 +1318,7 @@ function formatDiarized(job) {
1165
1318
  }
1166
1319
  return (job.text ?? "").trim();
1167
1320
  }
1168
- var execFileAsync2 = promisify(execFile);
1321
+ var execFileAsync3 = promisify(execFile);
1169
1322
  var DEFAULT_MAX_BYTES = 24 * 1024 * 1024;
1170
1323
  var DEFAULT_SEGMENT_SECONDS = 1200;
1171
1324
  var ChunkedStt = class {
@@ -1224,7 +1377,7 @@ var ChunkedStt = class {
1224
1377
  function defaultFfmpegSplitter(bin) {
1225
1378
  return async (audioPath, segmentSeconds, outDir) => {
1226
1379
  const ext = extname(audioPath) || ".mp3";
1227
- await execFileAsync2(
1380
+ await execFileAsync3(
1228
1381
  bin,
1229
1382
  [
1230
1383
  "-hide_banner",
@@ -1451,9 +1604,11 @@ function parse(args) {
1451
1604
  maxDurationSec: void 0,
1452
1605
  cookiesFromBrowser: void 0,
1453
1606
  cookiesFile: void 0,
1607
+ ffmpegLocation: void 0,
1454
1608
  throttleMs: 2e3,
1455
1609
  force: false,
1456
1610
  diarize: false,
1611
+ noCaptions: false,
1457
1612
  browserMcp: process.env.BROWSER_MCP_URL,
1458
1613
  scrapeMcp: process.env.SCRAPE_MCP_URL,
1459
1614
  importerId: "web",
@@ -1495,6 +1650,9 @@ function parse(args) {
1495
1650
  case "--cookies":
1496
1651
  out.cookiesFile = next();
1497
1652
  break;
1653
+ case "--ffmpeg-location":
1654
+ out.ffmpegLocation = next();
1655
+ break;
1498
1656
  case "--throttle": {
1499
1657
  const v = next();
1500
1658
  if (v) out.throttleMs = Number(v);
@@ -1506,6 +1664,9 @@ function parse(args) {
1506
1664
  case "--diarize":
1507
1665
  out.diarize = true;
1508
1666
  break;
1667
+ case "--no-captions":
1668
+ out.noCaptions = true;
1669
+ break;
1509
1670
  case "--browser-mcp":
1510
1671
  out.browserMcp = next();
1511
1672
  break;
@@ -1572,10 +1733,11 @@ async function runImportWeb(args) {
1572
1733
  const todo = urls.filter((u) => !done.has(u));
1573
1734
  const batch = parsed.max !== void 0 ? todo.slice(0, parsed.max) : todo;
1574
1735
  const remaining = todo.length - batch.length;
1736
+ const videoLabel = parsed.noCaptions ? parsed.diarize ? "AssemblyAI" : "Whisper" : `captions-first \u2192 ${parsed.diarize ? "AssemblyAI" : "Whisper"} fallback`;
1575
1737
  const plan = ` workspace: ${target}
1576
1738
  urls: ${urls.length} total \xB7 ${done.size && !parsed.force ? `${urls.length - todo.length} already ingested \xB7 ` : ""}${todo.length} to do
1577
1739
  this run: ${batch.length}${parsed.max !== void 0 ? ` (--max ${parsed.max})` : ""} \xB7 ${remaining} remaining after
1578
- video stt: ${parsed.diarize ? "AssemblyAI (speaker-labelled)" : "Whisper"}
1740
+ video: ${videoLabel}
1579
1741
  throttle: ${parsed.throttleMs} ms between fetches
1580
1742
  `;
1581
1743
  if (batch.length === 0) {
@@ -1596,6 +1758,16 @@ ${plan}`);
1596
1758
  process.stdout.write(`import-web
1597
1759
  ${plan}`);
1598
1760
  const chain = [];
1761
+ if (!parsed.noCaptions) {
1762
+ chain.push(
1763
+ new YtDlpCaptionsFetcher({
1764
+ ...parsed.lang ? { preferLang: parsed.lang } : {},
1765
+ ...parsed.maxDurationSec ? { maxDurationSec: parsed.maxDurationSec } : {},
1766
+ ...parsed.cookiesFromBrowser ? { cookiesFromBrowser: parsed.cookiesFromBrowser } : {},
1767
+ ...parsed.cookiesFile ? { cookiesFile: parsed.cookiesFile } : {}
1768
+ })
1769
+ );
1770
+ }
1599
1771
  let stt;
1600
1772
  if (parsed.diarize) {
1601
1773
  const aaiKey = process.env.ASSEMBLYAI_API_KEY;
@@ -1610,7 +1782,7 @@ ${plan}`);
1610
1782
  stt = new ChunkedStt({ base: new OpenAiWhisperStt({ apiKey: openaiKey }) });
1611
1783
  else
1612
1784
  process.stderr.write(
1613
- "corpus: OPENAI_API_KEY not set \u2014 video URLs will be skipped (no transcription).\n"
1785
+ parsed.noCaptions ? "corpus: OPENAI_API_KEY not set \u2014 video URLs will be skipped (no transcription).\n" : "corpus: OPENAI_API_KEY not set \u2014 caption-less videos will be skipped (captioned videos still import).\n"
1614
1786
  );
1615
1787
  }
1616
1788
  if (stt)
@@ -1619,7 +1791,8 @@ ${plan}`);
1619
1791
  stt,
1620
1792
  ...parsed.maxDurationSec ? { maxDurationSec: parsed.maxDurationSec } : {},
1621
1793
  ...parsed.cookiesFromBrowser ? { cookiesFromBrowser: parsed.cookiesFromBrowser } : {},
1622
- ...parsed.cookiesFile ? { cookiesFile: parsed.cookiesFile } : {}
1794
+ ...parsed.cookiesFile ? { cookiesFile: parsed.cookiesFile } : {},
1795
+ ...parsed.ffmpegLocation ? { ffmpegLocation: parsed.ffmpegLocation } : {}
1623
1796
  })
1624
1797
  );
1625
1798
  if (parsed.scrapeMcp) {
@@ -1627,7 +1800,7 @@ ${plan}`);
1627
1800
  const client = await connectBrowserMcp({ endpoint: parsed.scrapeMcp });
1628
1801
  chain.push(new ScrapeMcpFetcher({ client }));
1629
1802
  } catch (e) {
1630
- return fail(`could not connect to scrape MCP at ${parsed.scrapeMcp}: ${msg2(e)}`, 1);
1803
+ return fail(`could not connect to scrape MCP at ${parsed.scrapeMcp}: ${msg3(e)}`, 1);
1631
1804
  }
1632
1805
  }
1633
1806
  if (parsed.browserMcp) {
@@ -1636,7 +1809,7 @@ ${plan}`);
1636
1809
  browser = await connectBrowserMcp({ endpoint: parsed.browserMcp });
1637
1810
  chain.push(new BrowserMcpFetcher({ browser }));
1638
1811
  } catch (e) {
1639
- return fail(`could not connect to browser MCP at ${parsed.browserMcp}: ${msg2(e)}`, 1);
1812
+ return fail(`could not connect to browser MCP at ${parsed.browserMcp}: ${msg3(e)}`, 1);
1640
1813
  }
1641
1814
  }
1642
1815
  chain.push(new HttpReadabilityFetcher());
@@ -1667,7 +1840,7 @@ ${plan}`);
1667
1840
  }
1668
1841
  });
1669
1842
  } catch (e) {
1670
- return fail(`import failed: ${msg2(e)}`, 1);
1843
+ return fail(`import failed: ${msg3(e)}`, 1);
1671
1844
  }
1672
1845
  process.stdout.write(
1673
1846
  `import-web \u2192 ${target}
@@ -1687,9 +1860,234 @@ knowledge engine (RAG) via the WriterPort. Import only stages the sources.
1687
1860
  );
1688
1861
  return 0;
1689
1862
  }
1690
- function msg2(e) {
1863
+ function msg3(e) {
1691
1864
  return e instanceof Error ? e.message : String(e);
1692
1865
  }
1866
+ function parse2(args) {
1867
+ const out = {
1868
+ topic: void 0,
1869
+ outputPath: void 0,
1870
+ max: 20,
1871
+ channels: /* @__PURE__ */ new Set(["web"]),
1872
+ lang: void 0,
1873
+ tags: [],
1874
+ doImport: false
1875
+ };
1876
+ for (let i = 0; i < args.length; i++) {
1877
+ const a = args[i];
1878
+ const next = () => args[++i];
1879
+ switch (a) {
1880
+ case "--max": {
1881
+ const v = next();
1882
+ if (v) out.max = Number(v);
1883
+ break;
1884
+ }
1885
+ case "--channels": {
1886
+ const v = next();
1887
+ if (v) out.channels = new Set(v.split(",").map((s) => s.trim()).filter(Boolean));
1888
+ break;
1889
+ }
1890
+ case "--lang":
1891
+ out.lang = next();
1892
+ break;
1893
+ case "--tags": {
1894
+ const v = next();
1895
+ if (v) out.tags.push(...v.split(",").map((s) => s.trim()).filter(Boolean));
1896
+ break;
1897
+ }
1898
+ case "--import":
1899
+ out.doImport = true;
1900
+ break;
1901
+ default:
1902
+ if (!a.startsWith("-")) {
1903
+ if (out.topic === void 0) out.topic = a;
1904
+ else if (out.outputPath === void 0) out.outputPath = a;
1905
+ }
1906
+ }
1907
+ }
1908
+ return out;
1909
+ }
1910
+ async function searchSerper(query, lang, max) {
1911
+ const apiKey = process.env.SERPER_API_KEY;
1912
+ const gl = lang === "fr" ? "fr" : lang ?? "us";
1913
+ const hl = lang ?? "en";
1914
+ const body = JSON.stringify({ q: query, gl, hl, num: max });
1915
+ const res = await fetch("https://google.serper.dev/search", {
1916
+ method: "POST",
1917
+ headers: { "X-API-KEY": apiKey, "Content-Type": "application/json" },
1918
+ body
1919
+ });
1920
+ if (!res.ok) throw new Error(`Serper ${res.status}: ${await res.text()}`);
1921
+ const json = await res.json();
1922
+ return (json.organic ?? []).flatMap((r) => r.link ? [{ url: r.link, title: r.title }] : []);
1923
+ }
1924
+ async function searchExa(query, _lang, max) {
1925
+ const apiKey = process.env.EXA_API_KEY;
1926
+ const body = JSON.stringify({ query, numResults: max, type: "neural", contents: { text: false } });
1927
+ const res = await fetch("https://api.exa.ai/search", {
1928
+ method: "POST",
1929
+ headers: { "x-api-key": apiKey, "Content-Type": "application/json" },
1930
+ body
1931
+ });
1932
+ if (!res.ok) throw new Error(`Exa ${res.status}: ${await res.text()}`);
1933
+ const json = await res.json();
1934
+ return (json.results ?? []).flatMap((r) => r.url ? [{ url: r.url, title: r.title }] : []);
1935
+ }
1936
+ async function searchTavily(query, _lang, max) {
1937
+ const apiKey = process.env.TAVILY_API_KEY;
1938
+ const body = JSON.stringify({ api_key: apiKey, query, max_results: max });
1939
+ const res = await fetch("https://api.tavily.com/search", {
1940
+ method: "POST",
1941
+ headers: { "Content-Type": "application/json" },
1942
+ body
1943
+ });
1944
+ if (!res.ok) throw new Error(`Tavily ${res.status}: ${await res.text()}`);
1945
+ const json = await res.json();
1946
+ return (json.results ?? []).flatMap((r) => r.url ? [{ url: r.url, title: r.title }] : []);
1947
+ }
1948
+ async function searchGoogle(query, lang, max) {
1949
+ const apiKey = process.env.GOOGLE_SEARCH_API_KEY;
1950
+ const cx = process.env.GOOGLE_SEARCH_CX ?? "";
1951
+ const params = new URLSearchParams({
1952
+ key: apiKey,
1953
+ q: query,
1954
+ num: String(Math.min(max, 10)),
1955
+ ...cx ? { cx } : {},
1956
+ ...lang ? { lr: `lang_${lang}`, hl: lang } : {}
1957
+ });
1958
+ const res = await fetch(`https://www.googleapis.com/customsearch/v1?${params}`);
1959
+ if (!res.ok) throw new Error(`Google ${res.status}: ${await res.text()}`);
1960
+ const json = await res.json();
1961
+ return (json.items ?? []).flatMap((r) => r.link ? [{ url: r.link, title: r.title }] : []);
1962
+ }
1963
+ function resolveWebProvider() {
1964
+ if (process.env.SERPER_API_KEY) return { name: "serper", fn: searchSerper };
1965
+ if (process.env.EXA_API_KEY) return { name: "exa", fn: searchExa };
1966
+ if (process.env.TAVILY_API_KEY) return { name: "tavily", fn: searchTavily };
1967
+ if (process.env.GOOGLE_SEARCH_API_KEY) return { name: "google", fn: searchGoogle };
1968
+ return null;
1969
+ }
1970
+ function buildWebQueries(topic, lang) {
1971
+ const langSuffix = lang && lang !== "en" ? ` ${lang}` : "";
1972
+ const q1 = topic;
1973
+ const q2 = `${topic} guide tutorial${langSuffix}`;
1974
+ const q3 = lang && lang !== "en" ? `${topic} ressources ${lang}` : `${topic} best practices resources`;
1975
+ return [q1, q2, q3];
1976
+ }
1977
+ async function discoverWeb(topic, lang, max) {
1978
+ const provider = resolveWebProvider();
1979
+ if (!provider) {
1980
+ process.stderr.write("corpus discover: no web search API key found (SERPER_API_KEY / EXA_API_KEY / TAVILY_API_KEY / GOOGLE_SEARCH_API_KEY) \u2014 web channel skipped.\n");
1981
+ return { urls: [], provider: "none" };
1982
+ }
1983
+ const queries = buildWebQueries(topic, lang);
1984
+ const seen = /* @__PURE__ */ new Set();
1985
+ const urls = [];
1986
+ for (const q of queries) {
1987
+ if (urls.length >= max) break;
1988
+ try {
1989
+ const results = await provider.fn(q, lang, max);
1990
+ for (const r of results) {
1991
+ if (urls.length >= max) break;
1992
+ if (!seen.has(r.url)) {
1993
+ seen.add(r.url);
1994
+ urls.push(r.url);
1995
+ }
1996
+ }
1997
+ } catch (e) {
1998
+ process.stderr.write(`corpus discover: web search query "${q}" failed \u2014 ${e instanceof Error ? e.message : String(e)}
1999
+ `);
2000
+ }
2001
+ }
2002
+ return { urls, provider: provider.name };
2003
+ }
2004
+ async function discoverYoutube(topic, max) {
2005
+ return new Promise((resolve2) => {
2006
+ execFile(
2007
+ "yt-dlp",
2008
+ [`ytsearch${max}:${topic}`, "--flat-playlist", "--print", "%(url)s", "--no-warnings"],
2009
+ { timeout: 6e4 },
2010
+ (err, stdout, _stderr) => {
2011
+ if (err) {
2012
+ process.stderr.write(`corpus discover: youtube channel failed \u2014 ${err.message}
2013
+ `);
2014
+ resolve2([]);
2015
+ return;
2016
+ }
2017
+ resolve2(
2018
+ stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("http"))
2019
+ );
2020
+ }
2021
+ );
2022
+ });
2023
+ }
2024
+ async function discoverSocial(_topic, _lang, _max) {
2025
+ process.stdout.write(" social channel needs a Bureau session \u2014 skipped.\n");
2026
+ return [];
2027
+ }
2028
+ async function runDiscover(args) {
2029
+ const parsed = parse2(args);
2030
+ if (!parsed.topic) {
2031
+ return fail("discover requires a <topic> argument. Usage: corpus discover <topic> [path] [--max N] [--channels web,youtube,social] [--lang fr] [--tags t] [--import]", 2);
2032
+ }
2033
+ const outputPath = parsed.outputPath ? resolveWorkspacePath(parsed.outputPath) : process.cwd();
2034
+ process.stdout.write(`discover "${parsed.topic}"
2035
+ `);
2036
+ process.stdout.write(` channels: ${[...parsed.channels].join(", ")} \xB7 max: ${parsed.max}${parsed.lang ? ` \xB7 lang: ${parsed.lang}` : ""}
2037
+ `);
2038
+ const webUrls = [];
2039
+ const ytUrls = [];
2040
+ const socialUrls = [];
2041
+ if (parsed.channels.has("web")) {
2042
+ const { urls, provider } = await discoverWeb(parsed.topic, parsed.lang, parsed.max);
2043
+ webUrls.push(...urls);
2044
+ if (urls.length > 0) process.stdout.write(` web (${provider}): ${urls.length} URLs
2045
+ `);
2046
+ }
2047
+ if (parsed.channels.has("youtube")) {
2048
+ const urls = await discoverYoutube(parsed.topic, parsed.max);
2049
+ ytUrls.push(...urls);
2050
+ if (urls.length > 0) process.stdout.write(` youtube: ${urls.length} URLs
2051
+ `);
2052
+ }
2053
+ if (parsed.channels.has("social")) {
2054
+ const urls = await discoverSocial();
2055
+ socialUrls.push(...urls);
2056
+ }
2057
+ const seen = /* @__PURE__ */ new Set();
2058
+ const allUrls = [];
2059
+ for (const url of [...webUrls, ...ytUrls, ...socialUrls]) {
2060
+ if (!seen.has(url)) {
2061
+ seen.add(url);
2062
+ allUrls.push(url);
2063
+ }
2064
+ }
2065
+ process.stdout.write(`
2066
+ web: ${webUrls.length} \xB7 youtube: ${ytUrls.length} \xB7 social: ${socialUrls.length} \xB7 total: ${allUrls.length} unique
2067
+ `);
2068
+ if (allUrls.length === 0) {
2069
+ process.stderr.write("corpus discover: no URLs found across any channel.\n");
2070
+ return 1;
2071
+ }
2072
+ await mkdir(outputPath, { recursive: true });
2073
+ const outFile = join(outputPath, "urls.discovered.txt");
2074
+ await writeFile(outFile, allUrls.join("\n") + "\n", "utf-8");
2075
+ process.stdout.write(`
2076
+ written \u2192 ${outFile}
2077
+ `);
2078
+ if (parsed.doImport) {
2079
+ process.stdout.write("\n --import: chaining import-web\u2026\n");
2080
+ const importArgs = [
2081
+ outputPath,
2082
+ "--urls-file",
2083
+ outFile,
2084
+ ...parsed.tags.flatMap((t) => ["--tags", t]),
2085
+ ...parsed.lang ? ["--lang", parsed.lang] : []
2086
+ ];
2087
+ return runImportWeb(importArgs);
2088
+ }
2089
+ return 0;
2090
+ }
1693
2091
 
1694
2092
  // src/ports/anthropic-distiller.ts
1695
2093
  var ANTHROPIC_RESPONSE = z.object({
@@ -1703,15 +2101,17 @@ var AnthropicDistiller = class {
1703
2101
  baseUrl;
1704
2102
  maxItems;
1705
2103
  onUsage;
2104
+ lang;
1706
2105
  constructor(opts) {
1707
2106
  this.apiKey = opts.apiKey;
1708
2107
  this.model = opts.model ?? "claude-sonnet-4-6";
1709
2108
  this.baseUrl = (opts.baseUrl ?? "https://api.anthropic.com/v1").replace(/\/+$/, "");
1710
2109
  this.maxItems = opts.maxItems ?? 8;
1711
2110
  this.onUsage = opts.onUsage;
2111
+ this.lang = opts.lang;
1712
2112
  }
1713
2113
  async distill(input) {
1714
- const prompt = buildDistillPrompt(input, this.maxItems);
2114
+ const prompt = buildDistillPrompt(input, this.maxItems, { lang: this.lang });
1715
2115
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1716
2116
  const res = await fetch(`${this.baseUrl}/messages`, {
1717
2117
  method: "POST",
@@ -1750,18 +2150,20 @@ var CliAgentDistiller = class {
1750
2150
  maxItems;
1751
2151
  timeoutMs;
1752
2152
  cwd;
2153
+ lang;
1753
2154
  constructor(opts) {
1754
2155
  this.engine = opts.engine;
1755
2156
  this.model = opts.model;
1756
2157
  this.maxItems = opts.maxItems ?? 8;
1757
2158
  this.timeoutMs = opts.timeoutMs ?? 12e4;
1758
2159
  this.cwd = opts.cwd ?? tmpdir();
2160
+ this.lang = opts.lang;
1759
2161
  }
1760
2162
  async distill(input) {
1761
- const prompt = buildDistillPrompt(input, this.maxItems);
2163
+ const prompt = buildDistillPrompt(input, this.maxItems, { lang: this.lang });
1762
2164
  const stdout = await spawnWithStdin({
1763
2165
  command: this.engine.command,
1764
- args: this.engine.buildArgs({ ...this.model ? { model: this.model } : {} }),
2166
+ args: this.engine.buildArgs({ ...this.model ? { model: this.model } : {}, prompt }),
1765
2167
  stdin: prompt,
1766
2168
  cwd: this.cwd,
1767
2169
  timeoutMs: this.timeoutMs
@@ -1836,11 +2238,23 @@ var OPENCODE = {
1836
2238
  buildArgs: ({ model }) => ["run", ...model ? ["-m", model] : []],
1837
2239
  parseOutput: plainTextOutput
1838
2240
  };
2241
+ var HERMES = {
2242
+ id: "hermes",
2243
+ subscriptionBilled: true,
2244
+ command: "hermes",
2245
+ buildArgs: ({ model, prompt }) => [
2246
+ "--oneshot",
2247
+ prompt ?? "",
2248
+ ...model ? ["-m", model] : []
2249
+ ],
2250
+ parseOutput: plainTextOutput
2251
+ };
1839
2252
  var CLI_ENGINES = {
1840
2253
  [CLAUDE_CODE.id]: CLAUDE_CODE,
1841
2254
  [GEMINI.id]: GEMINI,
1842
2255
  [CODEX.id]: CODEX,
1843
- [OPENCODE.id]: OPENCODE
2256
+ [OPENCODE.id]: OPENCODE,
2257
+ [HERMES.id]: HERMES
1844
2258
  };
1845
2259
  var PRICING = [
1846
2260
  { match: "opus", inPerM: 15, outPerM: 75 },
@@ -2009,10 +2423,11 @@ var DISTILLER_ENGINES = {
2009
2423
  [DEFAULT_ENGINE]: {
2010
2424
  id: DEFAULT_ENGINE,
2011
2425
  needsApiKey: true,
2012
- create: ({ apiKey, model, onUsage }) => new AnthropicDistiller({
2426
+ create: ({ apiKey, model, onUsage, lang }) => new AnthropicDistiller({
2013
2427
  apiKey,
2014
2428
  ...model ? { model } : {},
2015
- ...onUsage ? { onUsage } : {}
2429
+ ...onUsage ? { onUsage } : {},
2430
+ ...lang ? { lang } : {}
2016
2431
  })
2017
2432
  },
2018
2433
  ...Object.fromEntries(
@@ -2021,19 +2436,24 @@ var DISTILLER_ENGINES = {
2021
2436
  {
2022
2437
  id: engine.id,
2023
2438
  needsApiKey: false,
2024
- create: ({ model }) => new CliAgentDistiller({ engine, ...model ? { model } : {} })
2439
+ create: ({ model, lang }) => new CliAgentDistiller({
2440
+ engine,
2441
+ ...model ? { model } : {},
2442
+ ...lang ? { lang } : {}
2443
+ })
2025
2444
  }
2026
2445
  ])
2027
2446
  )
2028
2447
  };
2029
- function parse2(args) {
2448
+ function parse3(args) {
2030
2449
  const out = {
2031
2450
  workspace: void 0,
2032
2451
  sourceId: void 0,
2033
2452
  max: void 0,
2034
2453
  throttleMs: 1e3,
2035
2454
  model: void 0,
2036
- engine: DEFAULT_ENGINE
2455
+ engine: DEFAULT_ENGINE,
2456
+ lang: void 0
2037
2457
  };
2038
2458
  for (let i = 0; i < args.length; i++) {
2039
2459
  const a = args[i];
@@ -2060,6 +2480,9 @@ function parse2(args) {
2060
2480
  if (v) out.engine = v;
2061
2481
  break;
2062
2482
  }
2483
+ case "--lang":
2484
+ out.lang = next();
2485
+ break;
2063
2486
  default:
2064
2487
  if (!a.startsWith("-") && out.workspace === void 0) out.workspace = a;
2065
2488
  }
@@ -2119,7 +2542,7 @@ async function scanDistilledSourceIds(root) {
2119
2542
  }
2120
2543
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2121
2544
  async function runDistill(args) {
2122
- const parsed = parse2(args);
2545
+ const parsed = parse3(args);
2123
2546
  const target = resolveWorkspacePath(parsed.workspace);
2124
2547
  const engine = DISTILLER_ENGINES[parsed.engine];
2125
2548
  if (!engine) {
@@ -2157,7 +2580,8 @@ async function runDistill(args) {
2157
2580
  distiller: engine.create({
2158
2581
  ...apiKey ? { apiKey } : {},
2159
2582
  ...parsed.model ? { model: parsed.model } : {},
2160
- onUsage: usage.record
2583
+ onUsage: usage.record,
2584
+ ...parsed.lang ? { lang: parsed.lang } : {}
2161
2585
  })
2162
2586
  });
2163
2587
  let totalEntries = 0;
@@ -2517,10 +2941,10 @@ var SseMcpClient = class {
2517
2941
  try {
2518
2942
  const parsed = RPC_MESSAGE.safeParse(JSON.parse(payload));
2519
2943
  if (!parsed.success) return;
2520
- const msg4 = parsed.data;
2521
- if (typeof msg4.id === "number" && this.pending.has(msg4.id)) {
2522
- this.pending.get(msg4.id)(msg4);
2523
- this.pending.delete(msg4.id);
2944
+ const msg5 = parsed.data;
2945
+ if (typeof msg5.id === "number" && this.pending.has(msg5.id)) {
2946
+ this.pending.get(msg5.id)(msg5);
2947
+ this.pending.delete(msg5.id);
2524
2948
  }
2525
2949
  } catch {
2526
2950
  }
@@ -2589,7 +3013,7 @@ var McpSink = class {
2589
3013
  try {
2590
3014
  client = await this.ensure();
2591
3015
  } catch (e) {
2592
- return { uri: item.uri, ok: false, error: `connect: ${msg3(e)}` };
3016
+ return { uri: item.uri, ok: false, error: `connect: ${msg4(e)}` };
2593
3017
  }
2594
3018
  const args = template(this.config.args, item);
2595
3019
  const call = this.config.importedAlias ? { name: "mcp_imported_call", args: { alias: this.config.importedAlias, toolName: this.config.tool, args } } : { name: this.config.tool, args };
@@ -2598,7 +3022,7 @@ var McpSink = class {
2598
3022
  if (res.isError) return { uri: item.uri, ok: false, error: "tool returned isError" };
2599
3023
  return { uri: item.uri, ok: true };
2600
3024
  } catch (e) {
2601
- return { uri: item.uri, ok: false, error: msg3(e) };
3025
+ return { uri: item.uri, ok: false, error: msg4(e) };
2602
3026
  }
2603
3027
  }
2604
3028
  };
@@ -2642,7 +3066,7 @@ function stringify(v) {
2642
3066
  if (Array.isArray(v)) return v.join(", ");
2643
3067
  return v == null ? "" : String(v);
2644
3068
  }
2645
- function msg3(e) {
3069
+ function msg4(e) {
2646
3070
  return e instanceof Error ? e.message : String(e);
2647
3071
  }
2648
3072
 
@@ -2741,6 +3165,12 @@ Commands:
2741
3165
  events:emit <kind> --payload <json> [path]
2742
3166
  Append an event to _log.md
2743
3167
  events:tail [path] Print _log.md
3168
+ discover <topic> [path] [--max N --channels web,youtube,social --lang l --tags t --import]
3169
+ Fan out across channels (web search + YouTube +
3170
+ social), dedup, write urls.discovered.txt.
3171
+ Web: auto-picks first available key (SERPER /
3172
+ EXA / TAVILY / GOOGLE_SEARCH). YouTube: yt-dlp
3173
+ flat-playlist. --import chains import-web.
2744
3174
  import-web [path] --urls-file <f> [--max n --max-duration s --throttle ms --tags t --lang l --force --diarize]
2745
3175
  Import URLs as sources (video\u2192Whisper, article\u2192
2746
3176
  readability). Resumable: skips already-ingested
@@ -2755,7 +3185,7 @@ Commands:
2755
3185
  scrape MCP server (stealth + clean Markdown) for
2756
3186
  walled/JS pages, ahead of plain readability.
2757
3187
  --diarize: AssemblyAI speaker labels (interviews).
2758
- distill [path] [--source id --max n --throttle ms --model m]
3188
+ distill [path] [--source id --max n --throttle ms --model m --lang l --engine e]
2759
3189
  Distill raw sources \u2192 refined entries
2760
3190
  (principle/pattern/\u2026) with sources:[id] provenance.
2761
3191
  Resumable: skips already-distilled sources.
@@ -2805,6 +3235,8 @@ async function main(argv2) {
2805
3235
  return await runEventsTail(rest);
2806
3236
  case "import-web":
2807
3237
  return await runImportWeb(rest);
3238
+ case "discover":
3239
+ return await runDiscover(rest);
2808
3240
  case "distill":
2809
3241
  return await runDistill(rest);
2810
3242
  case "knowledge":