@agentproto/corpus-cli 0.1.1 → 0.2.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
@@ -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);
@@ -1451,6 +1464,7 @@ function parse(args) {
1451
1464
  maxDurationSec: void 0,
1452
1465
  cookiesFromBrowser: void 0,
1453
1466
  cookiesFile: void 0,
1467
+ ffmpegLocation: void 0,
1454
1468
  throttleMs: 2e3,
1455
1469
  force: false,
1456
1470
  diarize: false,
@@ -1495,6 +1509,9 @@ function parse(args) {
1495
1509
  case "--cookies":
1496
1510
  out.cookiesFile = next();
1497
1511
  break;
1512
+ case "--ffmpeg-location":
1513
+ out.ffmpegLocation = next();
1514
+ break;
1498
1515
  case "--throttle": {
1499
1516
  const v = next();
1500
1517
  if (v) out.throttleMs = Number(v);
@@ -1619,7 +1636,8 @@ ${plan}`);
1619
1636
  stt,
1620
1637
  ...parsed.maxDurationSec ? { maxDurationSec: parsed.maxDurationSec } : {},
1621
1638
  ...parsed.cookiesFromBrowser ? { cookiesFromBrowser: parsed.cookiesFromBrowser } : {},
1622
- ...parsed.cookiesFile ? { cookiesFile: parsed.cookiesFile } : {}
1639
+ ...parsed.cookiesFile ? { cookiesFile: parsed.cookiesFile } : {},
1640
+ ...parsed.ffmpegLocation ? { ffmpegLocation: parsed.ffmpegLocation } : {}
1623
1641
  })
1624
1642
  );
1625
1643
  if (parsed.scrapeMcp) {
@@ -1690,6 +1708,231 @@ knowledge engine (RAG) via the WriterPort. Import only stages the sources.
1690
1708
  function msg2(e) {
1691
1709
  return e instanceof Error ? e.message : String(e);
1692
1710
  }
1711
+ function parse2(args) {
1712
+ const out = {
1713
+ topic: void 0,
1714
+ outputPath: void 0,
1715
+ max: 20,
1716
+ channels: /* @__PURE__ */ new Set(["web"]),
1717
+ lang: void 0,
1718
+ tags: [],
1719
+ doImport: false
1720
+ };
1721
+ for (let i = 0; i < args.length; i++) {
1722
+ const a = args[i];
1723
+ const next = () => args[++i];
1724
+ switch (a) {
1725
+ case "--max": {
1726
+ const v = next();
1727
+ if (v) out.max = Number(v);
1728
+ break;
1729
+ }
1730
+ case "--channels": {
1731
+ const v = next();
1732
+ if (v) out.channels = new Set(v.split(",").map((s) => s.trim()).filter(Boolean));
1733
+ break;
1734
+ }
1735
+ case "--lang":
1736
+ out.lang = next();
1737
+ break;
1738
+ case "--tags": {
1739
+ const v = next();
1740
+ if (v) out.tags.push(...v.split(",").map((s) => s.trim()).filter(Boolean));
1741
+ break;
1742
+ }
1743
+ case "--import":
1744
+ out.doImport = true;
1745
+ break;
1746
+ default:
1747
+ if (!a.startsWith("-")) {
1748
+ if (out.topic === void 0) out.topic = a;
1749
+ else if (out.outputPath === void 0) out.outputPath = a;
1750
+ }
1751
+ }
1752
+ }
1753
+ return out;
1754
+ }
1755
+ async function searchSerper(query, lang, max) {
1756
+ const apiKey = process.env.SERPER_API_KEY;
1757
+ const gl = lang === "fr" ? "fr" : lang ?? "us";
1758
+ const hl = lang ?? "en";
1759
+ const body = JSON.stringify({ q: query, gl, hl, num: max });
1760
+ const res = await fetch("https://google.serper.dev/search", {
1761
+ method: "POST",
1762
+ headers: { "X-API-KEY": apiKey, "Content-Type": "application/json" },
1763
+ body
1764
+ });
1765
+ if (!res.ok) throw new Error(`Serper ${res.status}: ${await res.text()}`);
1766
+ const json = await res.json();
1767
+ return (json.organic ?? []).flatMap((r) => r.link ? [{ url: r.link, title: r.title }] : []);
1768
+ }
1769
+ async function searchExa(query, _lang, max) {
1770
+ const apiKey = process.env.EXA_API_KEY;
1771
+ const body = JSON.stringify({ query, numResults: max, type: "neural", contents: { text: false } });
1772
+ const res = await fetch("https://api.exa.ai/search", {
1773
+ method: "POST",
1774
+ headers: { "x-api-key": apiKey, "Content-Type": "application/json" },
1775
+ body
1776
+ });
1777
+ if (!res.ok) throw new Error(`Exa ${res.status}: ${await res.text()}`);
1778
+ const json = await res.json();
1779
+ return (json.results ?? []).flatMap((r) => r.url ? [{ url: r.url, title: r.title }] : []);
1780
+ }
1781
+ async function searchTavily(query, _lang, max) {
1782
+ const apiKey = process.env.TAVILY_API_KEY;
1783
+ const body = JSON.stringify({ api_key: apiKey, query, max_results: max });
1784
+ const res = await fetch("https://api.tavily.com/search", {
1785
+ method: "POST",
1786
+ headers: { "Content-Type": "application/json" },
1787
+ body
1788
+ });
1789
+ if (!res.ok) throw new Error(`Tavily ${res.status}: ${await res.text()}`);
1790
+ const json = await res.json();
1791
+ return (json.results ?? []).flatMap((r) => r.url ? [{ url: r.url, title: r.title }] : []);
1792
+ }
1793
+ async function searchGoogle(query, lang, max) {
1794
+ const apiKey = process.env.GOOGLE_SEARCH_API_KEY;
1795
+ const cx = process.env.GOOGLE_SEARCH_CX ?? "";
1796
+ const params = new URLSearchParams({
1797
+ key: apiKey,
1798
+ q: query,
1799
+ num: String(Math.min(max, 10)),
1800
+ ...cx ? { cx } : {},
1801
+ ...lang ? { lr: `lang_${lang}`, hl: lang } : {}
1802
+ });
1803
+ const res = await fetch(`https://www.googleapis.com/customsearch/v1?${params}`);
1804
+ if (!res.ok) throw new Error(`Google ${res.status}: ${await res.text()}`);
1805
+ const json = await res.json();
1806
+ return (json.items ?? []).flatMap((r) => r.link ? [{ url: r.link, title: r.title }] : []);
1807
+ }
1808
+ function resolveWebProvider() {
1809
+ if (process.env.SERPER_API_KEY) return { name: "serper", fn: searchSerper };
1810
+ if (process.env.EXA_API_KEY) return { name: "exa", fn: searchExa };
1811
+ if (process.env.TAVILY_API_KEY) return { name: "tavily", fn: searchTavily };
1812
+ if (process.env.GOOGLE_SEARCH_API_KEY) return { name: "google", fn: searchGoogle };
1813
+ return null;
1814
+ }
1815
+ function buildWebQueries(topic, lang) {
1816
+ const langSuffix = lang && lang !== "en" ? ` ${lang}` : "";
1817
+ const q1 = topic;
1818
+ const q2 = `${topic} guide tutorial${langSuffix}`;
1819
+ const q3 = lang && lang !== "en" ? `${topic} ressources ${lang}` : `${topic} best practices resources`;
1820
+ return [q1, q2, q3];
1821
+ }
1822
+ async function discoverWeb(topic, lang, max) {
1823
+ const provider = resolveWebProvider();
1824
+ if (!provider) {
1825
+ 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");
1826
+ return { urls: [], provider: "none" };
1827
+ }
1828
+ const queries = buildWebQueries(topic, lang);
1829
+ const seen = /* @__PURE__ */ new Set();
1830
+ const urls = [];
1831
+ for (const q of queries) {
1832
+ if (urls.length >= max) break;
1833
+ try {
1834
+ const results = await provider.fn(q, lang, max);
1835
+ for (const r of results) {
1836
+ if (urls.length >= max) break;
1837
+ if (!seen.has(r.url)) {
1838
+ seen.add(r.url);
1839
+ urls.push(r.url);
1840
+ }
1841
+ }
1842
+ } catch (e) {
1843
+ process.stderr.write(`corpus discover: web search query "${q}" failed \u2014 ${e instanceof Error ? e.message : String(e)}
1844
+ `);
1845
+ }
1846
+ }
1847
+ return { urls, provider: provider.name };
1848
+ }
1849
+ async function discoverYoutube(topic, max) {
1850
+ return new Promise((resolve2) => {
1851
+ execFile(
1852
+ "yt-dlp",
1853
+ [`ytsearch${max}:${topic}`, "--flat-playlist", "--print", "%(url)s", "--no-warnings"],
1854
+ { timeout: 6e4 },
1855
+ (err, stdout, _stderr) => {
1856
+ if (err) {
1857
+ process.stderr.write(`corpus discover: youtube channel failed \u2014 ${err.message}
1858
+ `);
1859
+ resolve2([]);
1860
+ return;
1861
+ }
1862
+ resolve2(
1863
+ stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("http"))
1864
+ );
1865
+ }
1866
+ );
1867
+ });
1868
+ }
1869
+ async function discoverSocial(_topic, _lang, _max) {
1870
+ process.stdout.write(" social channel needs a Bureau session \u2014 skipped.\n");
1871
+ return [];
1872
+ }
1873
+ async function runDiscover(args) {
1874
+ const parsed = parse2(args);
1875
+ if (!parsed.topic) {
1876
+ return fail("discover requires a <topic> argument. Usage: corpus discover <topic> [path] [--max N] [--channels web,youtube,social] [--lang fr] [--tags t] [--import]", 2);
1877
+ }
1878
+ const outputPath = parsed.outputPath ? resolveWorkspacePath(parsed.outputPath) : process.cwd();
1879
+ process.stdout.write(`discover "${parsed.topic}"
1880
+ `);
1881
+ process.stdout.write(` channels: ${[...parsed.channels].join(", ")} \xB7 max: ${parsed.max}${parsed.lang ? ` \xB7 lang: ${parsed.lang}` : ""}
1882
+ `);
1883
+ const webUrls = [];
1884
+ const ytUrls = [];
1885
+ const socialUrls = [];
1886
+ if (parsed.channels.has("web")) {
1887
+ const { urls, provider } = await discoverWeb(parsed.topic, parsed.lang, parsed.max);
1888
+ webUrls.push(...urls);
1889
+ if (urls.length > 0) process.stdout.write(` web (${provider}): ${urls.length} URLs
1890
+ `);
1891
+ }
1892
+ if (parsed.channels.has("youtube")) {
1893
+ const urls = await discoverYoutube(parsed.topic, parsed.max);
1894
+ ytUrls.push(...urls);
1895
+ if (urls.length > 0) process.stdout.write(` youtube: ${urls.length} URLs
1896
+ `);
1897
+ }
1898
+ if (parsed.channels.has("social")) {
1899
+ const urls = await discoverSocial();
1900
+ socialUrls.push(...urls);
1901
+ }
1902
+ const seen = /* @__PURE__ */ new Set();
1903
+ const allUrls = [];
1904
+ for (const url of [...webUrls, ...ytUrls, ...socialUrls]) {
1905
+ if (!seen.has(url)) {
1906
+ seen.add(url);
1907
+ allUrls.push(url);
1908
+ }
1909
+ }
1910
+ process.stdout.write(`
1911
+ web: ${webUrls.length} \xB7 youtube: ${ytUrls.length} \xB7 social: ${socialUrls.length} \xB7 total: ${allUrls.length} unique
1912
+ `);
1913
+ if (allUrls.length === 0) {
1914
+ process.stderr.write("corpus discover: no URLs found across any channel.\n");
1915
+ return 1;
1916
+ }
1917
+ await mkdir(outputPath, { recursive: true });
1918
+ const outFile = join(outputPath, "urls.discovered.txt");
1919
+ await writeFile(outFile, allUrls.join("\n") + "\n", "utf-8");
1920
+ process.stdout.write(`
1921
+ written \u2192 ${outFile}
1922
+ `);
1923
+ if (parsed.doImport) {
1924
+ process.stdout.write("\n --import: chaining import-web\u2026\n");
1925
+ const importArgs = [
1926
+ outputPath,
1927
+ "--urls-file",
1928
+ outFile,
1929
+ ...parsed.tags.flatMap((t) => ["--tags", t]),
1930
+ ...parsed.lang ? ["--lang", parsed.lang] : []
1931
+ ];
1932
+ return runImportWeb(importArgs);
1933
+ }
1934
+ return 0;
1935
+ }
1693
1936
 
1694
1937
  // src/ports/anthropic-distiller.ts
1695
1938
  var ANTHROPIC_RESPONSE = z.object({
@@ -1703,15 +1946,17 @@ var AnthropicDistiller = class {
1703
1946
  baseUrl;
1704
1947
  maxItems;
1705
1948
  onUsage;
1949
+ lang;
1706
1950
  constructor(opts) {
1707
1951
  this.apiKey = opts.apiKey;
1708
1952
  this.model = opts.model ?? "claude-sonnet-4-6";
1709
1953
  this.baseUrl = (opts.baseUrl ?? "https://api.anthropic.com/v1").replace(/\/+$/, "");
1710
1954
  this.maxItems = opts.maxItems ?? 8;
1711
1955
  this.onUsage = opts.onUsage;
1956
+ this.lang = opts.lang;
1712
1957
  }
1713
1958
  async distill(input) {
1714
- const prompt = buildDistillPrompt(input, this.maxItems);
1959
+ const prompt = buildDistillPrompt(input, this.maxItems, { lang: this.lang });
1715
1960
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1716
1961
  const res = await fetch(`${this.baseUrl}/messages`, {
1717
1962
  method: "POST",
@@ -1750,18 +1995,20 @@ var CliAgentDistiller = class {
1750
1995
  maxItems;
1751
1996
  timeoutMs;
1752
1997
  cwd;
1998
+ lang;
1753
1999
  constructor(opts) {
1754
2000
  this.engine = opts.engine;
1755
2001
  this.model = opts.model;
1756
2002
  this.maxItems = opts.maxItems ?? 8;
1757
2003
  this.timeoutMs = opts.timeoutMs ?? 12e4;
1758
2004
  this.cwd = opts.cwd ?? tmpdir();
2005
+ this.lang = opts.lang;
1759
2006
  }
1760
2007
  async distill(input) {
1761
- const prompt = buildDistillPrompt(input, this.maxItems);
2008
+ const prompt = buildDistillPrompt(input, this.maxItems, { lang: this.lang });
1762
2009
  const stdout = await spawnWithStdin({
1763
2010
  command: this.engine.command,
1764
- args: this.engine.buildArgs({ ...this.model ? { model: this.model } : {} }),
2011
+ args: this.engine.buildArgs({ ...this.model ? { model: this.model } : {}, prompt }),
1765
2012
  stdin: prompt,
1766
2013
  cwd: this.cwd,
1767
2014
  timeoutMs: this.timeoutMs
@@ -1836,11 +2083,23 @@ var OPENCODE = {
1836
2083
  buildArgs: ({ model }) => ["run", ...model ? ["-m", model] : []],
1837
2084
  parseOutput: plainTextOutput
1838
2085
  };
2086
+ var HERMES = {
2087
+ id: "hermes",
2088
+ subscriptionBilled: true,
2089
+ command: "hermes",
2090
+ buildArgs: ({ model, prompt }) => [
2091
+ "--oneshot",
2092
+ prompt ?? "",
2093
+ ...model ? ["-m", model] : []
2094
+ ],
2095
+ parseOutput: plainTextOutput
2096
+ };
1839
2097
  var CLI_ENGINES = {
1840
2098
  [CLAUDE_CODE.id]: CLAUDE_CODE,
1841
2099
  [GEMINI.id]: GEMINI,
1842
2100
  [CODEX.id]: CODEX,
1843
- [OPENCODE.id]: OPENCODE
2101
+ [OPENCODE.id]: OPENCODE,
2102
+ [HERMES.id]: HERMES
1844
2103
  };
1845
2104
  var PRICING = [
1846
2105
  { match: "opus", inPerM: 15, outPerM: 75 },
@@ -2009,10 +2268,11 @@ var DISTILLER_ENGINES = {
2009
2268
  [DEFAULT_ENGINE]: {
2010
2269
  id: DEFAULT_ENGINE,
2011
2270
  needsApiKey: true,
2012
- create: ({ apiKey, model, onUsage }) => new AnthropicDistiller({
2271
+ create: ({ apiKey, model, onUsage, lang }) => new AnthropicDistiller({
2013
2272
  apiKey,
2014
2273
  ...model ? { model } : {},
2015
- ...onUsage ? { onUsage } : {}
2274
+ ...onUsage ? { onUsage } : {},
2275
+ ...lang ? { lang } : {}
2016
2276
  })
2017
2277
  },
2018
2278
  ...Object.fromEntries(
@@ -2021,19 +2281,24 @@ var DISTILLER_ENGINES = {
2021
2281
  {
2022
2282
  id: engine.id,
2023
2283
  needsApiKey: false,
2024
- create: ({ model }) => new CliAgentDistiller({ engine, ...model ? { model } : {} })
2284
+ create: ({ model, lang }) => new CliAgentDistiller({
2285
+ engine,
2286
+ ...model ? { model } : {},
2287
+ ...lang ? { lang } : {}
2288
+ })
2025
2289
  }
2026
2290
  ])
2027
2291
  )
2028
2292
  };
2029
- function parse2(args) {
2293
+ function parse3(args) {
2030
2294
  const out = {
2031
2295
  workspace: void 0,
2032
2296
  sourceId: void 0,
2033
2297
  max: void 0,
2034
2298
  throttleMs: 1e3,
2035
2299
  model: void 0,
2036
- engine: DEFAULT_ENGINE
2300
+ engine: DEFAULT_ENGINE,
2301
+ lang: void 0
2037
2302
  };
2038
2303
  for (let i = 0; i < args.length; i++) {
2039
2304
  const a = args[i];
@@ -2060,6 +2325,9 @@ function parse2(args) {
2060
2325
  if (v) out.engine = v;
2061
2326
  break;
2062
2327
  }
2328
+ case "--lang":
2329
+ out.lang = next();
2330
+ break;
2063
2331
  default:
2064
2332
  if (!a.startsWith("-") && out.workspace === void 0) out.workspace = a;
2065
2333
  }
@@ -2119,7 +2387,7 @@ async function scanDistilledSourceIds(root) {
2119
2387
  }
2120
2388
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2121
2389
  async function runDistill(args) {
2122
- const parsed = parse2(args);
2390
+ const parsed = parse3(args);
2123
2391
  const target = resolveWorkspacePath(parsed.workspace);
2124
2392
  const engine = DISTILLER_ENGINES[parsed.engine];
2125
2393
  if (!engine) {
@@ -2157,7 +2425,8 @@ async function runDistill(args) {
2157
2425
  distiller: engine.create({
2158
2426
  ...apiKey ? { apiKey } : {},
2159
2427
  ...parsed.model ? { model: parsed.model } : {},
2160
- onUsage: usage.record
2428
+ onUsage: usage.record,
2429
+ ...parsed.lang ? { lang: parsed.lang } : {}
2161
2430
  })
2162
2431
  });
2163
2432
  let totalEntries = 0;
@@ -2741,6 +3010,12 @@ Commands:
2741
3010
  events:emit <kind> --payload <json> [path]
2742
3011
  Append an event to _log.md
2743
3012
  events:tail [path] Print _log.md
3013
+ discover <topic> [path] [--max N --channels web,youtube,social --lang l --tags t --import]
3014
+ Fan out across channels (web search + YouTube +
3015
+ social), dedup, write urls.discovered.txt.
3016
+ Web: auto-picks first available key (SERPER /
3017
+ EXA / TAVILY / GOOGLE_SEARCH). YouTube: yt-dlp
3018
+ flat-playlist. --import chains import-web.
2744
3019
  import-web [path] --urls-file <f> [--max n --max-duration s --throttle ms --tags t --lang l --force --diarize]
2745
3020
  Import URLs as sources (video\u2192Whisper, article\u2192
2746
3021
  readability). Resumable: skips already-ingested
@@ -2755,7 +3030,7 @@ Commands:
2755
3030
  scrape MCP server (stealth + clean Markdown) for
2756
3031
  walled/JS pages, ahead of plain readability.
2757
3032
  --diarize: AssemblyAI speaker labels (interviews).
2758
- distill [path] [--source id --max n --throttle ms --model m]
3033
+ distill [path] [--source id --max n --throttle ms --model m --lang l --engine e]
2759
3034
  Distill raw sources \u2192 refined entries
2760
3035
  (principle/pattern/\u2026) with sources:[id] provenance.
2761
3036
  Resumable: skips already-distilled sources.
@@ -2805,6 +3080,8 @@ async function main(argv2) {
2805
3080
  return await runEventsTail(rest);
2806
3081
  case "import-web":
2807
3082
  return await runImportWeb(rest);
3083
+ case "discover":
3084
+ return await runDiscover(rest);
2808
3085
  case "distill":
2809
3086
  return await runDistill(rest);
2810
3087
  case "knowledge":