@agentproto/corpus-cli 0.2.0 → 0.4.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
@@ -12,7 +12,7 @@ import matter2 from 'gray-matter';
12
12
  import { execFile } from 'child_process';
13
13
  import { promisify } from 'util';
14
14
  import { parseClaudeJsonOutput, spawnWithStdin } from '@agentproto/cli-exec';
15
- import { stitchReport, buildPacks, reportConfigSchema } from '@agentproto/corpus/report';
15
+ import { stitchReport, buildPacks, lintReportConfig, reportConfigSchema } from '@agentproto/corpus/report';
16
16
 
17
17
  /**
18
18
  * @agentproto/corpus-cli v0.1.0-alpha
@@ -104,9 +104,9 @@ function resolvePackageJson(packageName) {
104
104
  }
105
105
  return null;
106
106
  }
107
- async function readJsonIfExists(path5) {
107
+ async function readJsonIfExists(path6) {
108
108
  try {
109
- const raw = await readFile(path5, "utf8");
109
+ const raw = await readFile(path6, "utf8");
110
110
  return JSON.parse(raw);
111
111
  } catch (err) {
112
112
  if (err.code === "ENOENT") return void 0;
@@ -115,9 +115,9 @@ async function readJsonIfExists(path5) {
115
115
  }
116
116
  async function readConfiguredPresetPackages() {
117
117
  const base = process.env["AGENTPROTO_HOME"] ?? join(homedir(), ".agentproto");
118
- const path5 = join(base, "config.json");
118
+ const path6 = join(base, "config.json");
119
119
  try {
120
- const raw = await readFile(path5, "utf8");
120
+ const raw = await readFile(path6, "utf8");
121
121
  const parsed = JSON.parse(raw);
122
122
  const list = parsed.corpusPresetPackages;
123
123
  if (Array.isArray(list) && list.length > 0) return list;
@@ -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
  }
@@ -631,6 +631,160 @@ async function runLint(args) {
631
631
  );
632
632
  return report.errorCount > 0 ? 1 : 0;
633
633
  }
634
+ var FLAG_PATTERNS = [
635
+ /does not match/i,
636
+ /content is about/i,
637
+ /no substantive content/i,
638
+ /content mismatch/i,
639
+ /appears to be a (?:newsletter|navigation|login|error) page/i,
640
+ /stated title/i,
641
+ /placeholder page/i,
642
+ /cookie consent/i,
643
+ /page could not be/i
644
+ ];
645
+ function parse(args) {
646
+ const out = {
647
+ workspace: void 0,
648
+ facets: [],
649
+ thin: 8,
650
+ apply: false,
651
+ contaminated: false
652
+ };
653
+ for (let i = 0; i < args.length; i++) {
654
+ const a = args[i];
655
+ const next = () => args[++i];
656
+ switch (a) {
657
+ case "--facets": {
658
+ const v = next();
659
+ if (v) out.facets = v.split(",").map((s) => s.trim()).filter(Boolean);
660
+ break;
661
+ }
662
+ case "--thin": {
663
+ const v = next();
664
+ if (v) out.thin = Number(v);
665
+ break;
666
+ }
667
+ case "--apply":
668
+ out.apply = true;
669
+ break;
670
+ case "--contaminated":
671
+ out.contaminated = true;
672
+ break;
673
+ default:
674
+ if (!a.startsWith("-") && out.workspace === void 0) out.workspace = a;
675
+ }
676
+ }
677
+ return out;
678
+ }
679
+ function firstFlag(haystack) {
680
+ for (const re of FLAG_PATTERNS) {
681
+ const m = haystack.match(re);
682
+ if (m) return m[0];
683
+ }
684
+ return void 0;
685
+ }
686
+ function entryStatus(entry) {
687
+ const corpus = entry.frontmatter.metadata?.corpus;
688
+ return corpus?.status ?? "active";
689
+ }
690
+ function tagsOf(entry) {
691
+ const tags = entry.frontmatter.tags;
692
+ return Array.isArray(tags) ? tags.map(String) : [];
693
+ }
694
+ function sourcesOf(entry) {
695
+ const sources = entry.frontmatter.sources;
696
+ return Array.isArray(sources) ? sources.map(String) : [];
697
+ }
698
+ async function runVerify(args) {
699
+ const parsed = parse(args);
700
+ if (parsed.facets.length === 0) {
701
+ return fail(
702
+ "verify requires --facets a,b,c. Usage: corpus verify <workspace> --facets landscape,daemons,... [--thin 8] [--apply] [--contaminated]",
703
+ 2
704
+ );
705
+ }
706
+ const target = resolveWorkspacePath(parsed.workspace);
707
+ const fs = new NodeFsAdapter({ root: target });
708
+ if (!await fs.exists("entries")) {
709
+ return fail(`verify: no entries/ under ${target}`, 1);
710
+ }
711
+ const snapshot = await new CorpusWorkspaceReader({ fs }).read("");
712
+ const entries = snapshot.entries.filter((e) => entryStatus(e) === "active");
713
+ const perFacet = /* @__PURE__ */ new Map();
714
+ for (const facet of parsed.facets) perFacet.set(facet, 0);
715
+ for (const entry of entries) {
716
+ for (const tag of tagsOf(entry)) {
717
+ if (perFacet.has(tag)) perFacet.set(tag, (perFacet.get(tag) ?? 0) + 1);
718
+ }
719
+ }
720
+ process.stdout.write(`== coverage (${entries.length} entries) ==
721
+ `);
722
+ const thin = [];
723
+ for (const facet of parsed.facets) {
724
+ const n = perFacet.get(facet) ?? 0;
725
+ if (n < parsed.thin) thin.push(facet);
726
+ const mark = n < parsed.thin ? " THIN \u26A0" : "";
727
+ process.stdout.write(` ${facet.padEnd(16)} ${String(n).padStart(4)}${mark}
728
+ `);
729
+ }
730
+ const flagged = [];
731
+ for (const entry of entries) {
732
+ const title = String(entry.frontmatter.title ?? "");
733
+ const why = firstFlag(`${title}
734
+ ${entry.body}`);
735
+ if (why) flagged.push({ entry, why });
736
+ }
737
+ process.stdout.write(`
738
+ == self-flagged entries (${flagged.length}) ==
739
+ `);
740
+ const poisonedSources = /* @__PURE__ */ new Set();
741
+ for (const { entry, why } of flagged) {
742
+ process.stdout.write(` ${entry.path} [${why}]
743
+ `);
744
+ for (const s of sourcesOf(entry)) poisonedSources.add(s);
745
+ }
746
+ const flaggedPaths = new Set(flagged.map((f) => f.entry.path));
747
+ const contaminated = [];
748
+ if (parsed.contaminated && poisonedSources.size > 0) {
749
+ for (const entry of entries) {
750
+ if (flaggedPaths.has(entry.path)) continue;
751
+ if (sourcesOf(entry).some((s) => poisonedSources.has(s))) {
752
+ contaminated.push(entry);
753
+ }
754
+ }
755
+ process.stdout.write(
756
+ `
757
+ == contaminated siblings (${contaminated.length}) from ${poisonedSources.size} poisoned source(s) ==
758
+ `
759
+ );
760
+ for (const entry of contaminated) {
761
+ process.stdout.write(` ${entry.path}
762
+ `);
763
+ }
764
+ }
765
+ if (parsed.apply) {
766
+ const toMove = [...flagged.map((f) => f.entry), ...contaminated];
767
+ for (const entry of toMove) {
768
+ const rel = path.relative("entries", entry.path);
769
+ const dest = path.join(target, "demoted", rel);
770
+ await mkdir(path.dirname(dest), { recursive: true });
771
+ await rename(path.join(target, entry.path), dest);
772
+ }
773
+ process.stdout.write(`
774
+ moved ${toMove.length} entries \u2192 demoted/
775
+ `);
776
+ } else if (flagged.length > 0) {
777
+ process.stdout.write(
778
+ "\n(re-run with --apply to quarantine; --contaminated to also demote siblings)\n"
779
+ );
780
+ }
781
+ if (thin.length > 0) {
782
+ process.stdout.write(`
783
+ thin facets \u2192 loop back to \u2461 DISCOVER: ${thin.join(", ")}
784
+ `);
785
+ }
786
+ return 0;
787
+ }
634
788
  var OsIdentityAdapter = class {
635
789
  cached;
636
790
  constructor(opts) {
@@ -1040,6 +1194,146 @@ function defaultYtDlpDownloader(bin, opts) {
1040
1194
  }
1041
1195
  };
1042
1196
  }
1197
+ var execFileAsync2 = promisify(execFile);
1198
+ var YTDLP_INFO2 = z.object({ title: z.string().optional(), language: z.string().optional() }).loose();
1199
+ var YtDlpCaptionsFetcher = class {
1200
+ download;
1201
+ extraHosts;
1202
+ minChars;
1203
+ constructor(opts = {}) {
1204
+ this.minChars = opts.minChars ?? 400;
1205
+ this.download = opts.download ?? defaultYtDlpCaptionDownloader(opts.ytDlpBin ?? "yt-dlp", {
1206
+ ...opts.subLangs ? { subLangs: opts.subLangs } : {},
1207
+ ...opts.preferLang ? { preferLang: opts.preferLang } : {},
1208
+ ...opts.maxDurationSec ? { maxDurationSec: opts.maxDurationSec } : {},
1209
+ ...opts.cookiesFromBrowser ? { cookiesFromBrowser: opts.cookiesFromBrowser } : {},
1210
+ ...opts.cookiesFile ? { cookiesFile: opts.cookiesFile } : {}
1211
+ });
1212
+ this.extraHosts = opts.extraVideoHosts ? new Set(opts.extraVideoHosts) : void 0;
1213
+ }
1214
+ async fetch(url) {
1215
+ if (!isVideoUrl(url, this.extraHosts)) return null;
1216
+ let dl;
1217
+ try {
1218
+ dl = await this.download(url);
1219
+ } catch (e) {
1220
+ process.stderr.write(
1221
+ `corpus: caption fetch failed for ${url} \u2014 falling back (${msg2(e)})
1222
+ `
1223
+ );
1224
+ return null;
1225
+ }
1226
+ try {
1227
+ if (!dl.vttPath) return null;
1228
+ const text = parseVttToText(await readFile(dl.vttPath, "utf-8"));
1229
+ if (text.length < this.minChars) return null;
1230
+ return {
1231
+ title: dl.title || url,
1232
+ text,
1233
+ kind: "video",
1234
+ ...dl.language ? { language: dl.language } : {},
1235
+ via: "captions"
1236
+ };
1237
+ } catch {
1238
+ return null;
1239
+ } finally {
1240
+ await dl.cleanup().catch(() => {
1241
+ });
1242
+ }
1243
+ }
1244
+ };
1245
+ function msg2(e) {
1246
+ return e instanceof Error ? e.message : String(e);
1247
+ }
1248
+ function parseVttToText(vtt) {
1249
+ const out = [];
1250
+ for (const raw of vtt.split(/\r?\n/)) {
1251
+ if (/^WEBVTT/.test(raw)) continue;
1252
+ if (/^(Kind|Language|NOTE|STYLE):/i.test(raw)) continue;
1253
+ if (raw.includes("-->")) continue;
1254
+ const line = raw.replace(/<[^>]+>/g, "").replace(/&nbsp;/gi, " ").replace(/ /g, " ").trim();
1255
+ if (!line) continue;
1256
+ if (out.length && out[out.length - 1] === line) continue;
1257
+ out.push(line);
1258
+ }
1259
+ const cleaned = [];
1260
+ for (let i = 0; i < out.length; i++) {
1261
+ const next = out[i + 1];
1262
+ if (next && next.startsWith(out[i])) continue;
1263
+ cleaned.push(out[i]);
1264
+ }
1265
+ return cleaned.join(" ").replace(/\s+/g, " ").trim();
1266
+ }
1267
+ function buildYtDlpCaptionArgs(url, dir, opts) {
1268
+ const subLangs = opts?.subLangs ?? (opts?.preferLang ? `${opts.preferLang}.*` : "en.*,fr.*,es.*,de.*,it.*,pt.*,nl.*");
1269
+ const durationGuard = opts?.maxDurationSec && opts.maxDurationSec > 0 ? ["--match-filter", `duration <= ${Math.floor(opts.maxDurationSec)}`] : [];
1270
+ const cookieArgs = opts?.cookiesFromBrowser ? ["--cookies-from-browser", opts.cookiesFromBrowser] : opts?.cookiesFile ? ["--cookies", opts.cookiesFile] : [];
1271
+ return [
1272
+ "--skip-download",
1273
+ "--write-auto-subs",
1274
+ "--write-subs",
1275
+ "--sub-langs",
1276
+ subLangs,
1277
+ "--sub-format",
1278
+ "vtt",
1279
+ "--no-playlist",
1280
+ "--no-progress",
1281
+ "--sleep-requests",
1282
+ "1",
1283
+ ...durationGuard,
1284
+ ...cookieArgs,
1285
+ "--write-info-json",
1286
+ "-o",
1287
+ join(dir, "%(id)s.%(ext)s"),
1288
+ url
1289
+ ];
1290
+ }
1291
+ function pickVtt(files, preferLang) {
1292
+ const vtts = files.filter((f) => f.endsWith(".vtt"));
1293
+ if (vtts.length === 0) return void 0;
1294
+ const bySuffix = (suffix) => vtts.find((f) => f.toLowerCase().endsWith(suffix.toLowerCase()));
1295
+ if (preferLang) {
1296
+ 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];
1297
+ }
1298
+ return vtts.find((f) => /-orig\.vtt$/i.test(f)) ?? vtts[0];
1299
+ }
1300
+ function defaultYtDlpCaptionDownloader(bin, opts) {
1301
+ return async (url) => {
1302
+ const dir = await mkdtemp(join(tmpdir(), "corpus-captions-"));
1303
+ const cleanup = () => rm(dir, { recursive: true, force: true });
1304
+ try {
1305
+ await execFileAsync2(bin, buildYtDlpCaptionArgs(url, dir, opts), {
1306
+ maxBuffer: 16 * 1024 * 1024
1307
+ }).catch(() => {
1308
+ });
1309
+ const files = await readdir(dir);
1310
+ const vtt = pickVtt(files, opts?.preferLang);
1311
+ let title = url;
1312
+ let language;
1313
+ const infoName = files.find((f) => f.endsWith(".info.json"));
1314
+ if (infoName) {
1315
+ try {
1316
+ const info = YTDLP_INFO2.parse(
1317
+ JSON.parse(await readFile(join(dir, infoName), "utf-8"))
1318
+ );
1319
+ if (info.title) title = info.title;
1320
+ if (info.language) language = info.language;
1321
+ } catch {
1322
+ }
1323
+ }
1324
+ return {
1325
+ vttPath: vtt ? join(dir, vtt) : null,
1326
+ title,
1327
+ ...language ? { language } : {},
1328
+ cleanup
1329
+ };
1330
+ } catch (e) {
1331
+ await cleanup().catch(() => {
1332
+ });
1333
+ throw e;
1334
+ }
1335
+ };
1336
+ }
1043
1337
  var WHISPER_RESPONSE = z.object({ text: z.string().optional(), language: z.string().optional() }).loose();
1044
1338
  var WHISPER_MAX_BYTES = 25 * 1024 * 1024;
1045
1339
  var OpenAiWhisperStt = class {
@@ -1157,18 +1451,18 @@ var AssemblyAiStt = class {
1157
1451
  ...job.language_code ? { language: job.language_code } : {}
1158
1452
  };
1159
1453
  }
1160
- async post(path5, body) {
1161
- const r = await fetch(`${this.baseUrl}${path5}`, {
1454
+ async post(path6, body) {
1455
+ const r = await fetch(`${this.baseUrl}${path6}`, {
1162
1456
  method: "POST",
1163
1457
  headers: { authorization: this.apiKey, "content-type": "application/json" },
1164
1458
  body: JSON.stringify(body)
1165
1459
  });
1166
- if (!r.ok) throw new Error(`AssemblyAI POST ${path5} ${r.status}: ${(await r.text()).slice(0, 200)}`);
1460
+ if (!r.ok) throw new Error(`AssemblyAI POST ${path6} ${r.status}: ${(await r.text()).slice(0, 200)}`);
1167
1461
  return AAI_TRANSCRIPT.parse(await r.json());
1168
1462
  }
1169
- async get(path5) {
1170
- const r = await fetch(`${this.baseUrl}${path5}`, { headers: { authorization: this.apiKey } });
1171
- if (!r.ok) throw new Error(`AssemblyAI GET ${path5} ${r.status}: ${(await r.text()).slice(0, 200)}`);
1463
+ async get(path6) {
1464
+ const r = await fetch(`${this.baseUrl}${path6}`, { headers: { authorization: this.apiKey } });
1465
+ if (!r.ok) throw new Error(`AssemblyAI GET ${path6} ${r.status}: ${(await r.text()).slice(0, 200)}`);
1172
1466
  return AAI_TRANSCRIPT.parse(await r.json());
1173
1467
  }
1174
1468
  };
@@ -1178,7 +1472,7 @@ function formatDiarized(job) {
1178
1472
  }
1179
1473
  return (job.text ?? "").trim();
1180
1474
  }
1181
- var execFileAsync2 = promisify(execFile);
1475
+ var execFileAsync3 = promisify(execFile);
1182
1476
  var DEFAULT_MAX_BYTES = 24 * 1024 * 1024;
1183
1477
  var DEFAULT_SEGMENT_SECONDS = 1200;
1184
1478
  var ChunkedStt = class {
@@ -1237,7 +1531,7 @@ var ChunkedStt = class {
1237
1531
  function defaultFfmpegSplitter(bin) {
1238
1532
  return async (audioPath, segmentSeconds, outDir) => {
1239
1533
  const ext = extname(audioPath) || ".mp3";
1240
- await execFileAsync2(
1534
+ await execFileAsync3(
1241
1535
  bin,
1242
1536
  [
1243
1537
  "-hide_banner",
@@ -1453,7 +1747,7 @@ var SOURCE_URL_FRONTMATTER = z.object({
1453
1747
  }).loose().optional().catch(void 0)
1454
1748
  }).loose().optional().catch(void 0)
1455
1749
  }).loose();
1456
- function parse(args) {
1750
+ function parse2(args) {
1457
1751
  const out = {
1458
1752
  workspace: void 0,
1459
1753
  urlsFile: void 0,
@@ -1468,6 +1762,7 @@ function parse(args) {
1468
1762
  throttleMs: 2e3,
1469
1763
  force: false,
1470
1764
  diarize: false,
1765
+ noCaptions: false,
1471
1766
  browserMcp: process.env.BROWSER_MCP_URL,
1472
1767
  scrapeMcp: process.env.SCRAPE_MCP_URL,
1473
1768
  importerId: "web",
@@ -1523,6 +1818,9 @@ function parse(args) {
1523
1818
  case "--diarize":
1524
1819
  out.diarize = true;
1525
1820
  break;
1821
+ case "--no-captions":
1822
+ out.noCaptions = true;
1823
+ break;
1526
1824
  case "--browser-mcp":
1527
1825
  out.browserMcp = next();
1528
1826
  break;
@@ -1579,7 +1877,7 @@ async function scanIngestedUrls(workspaceRoot) {
1579
1877
  return seen;
1580
1878
  }
1581
1879
  async function runImportWeb(args) {
1582
- const parsed = parse(args);
1880
+ const parsed = parse2(args);
1583
1881
  const target = resolveWorkspacePath(parsed.workspace);
1584
1882
  const urls = await readUrls(parsed);
1585
1883
  if (urls.length === 0) {
@@ -1589,10 +1887,11 @@ async function runImportWeb(args) {
1589
1887
  const todo = urls.filter((u) => !done.has(u));
1590
1888
  const batch = parsed.max !== void 0 ? todo.slice(0, parsed.max) : todo;
1591
1889
  const remaining = todo.length - batch.length;
1890
+ const videoLabel = parsed.noCaptions ? parsed.diarize ? "AssemblyAI" : "Whisper" : `captions-first \u2192 ${parsed.diarize ? "AssemblyAI" : "Whisper"} fallback`;
1592
1891
  const plan = ` workspace: ${target}
1593
1892
  urls: ${urls.length} total \xB7 ${done.size && !parsed.force ? `${urls.length - todo.length} already ingested \xB7 ` : ""}${todo.length} to do
1594
1893
  this run: ${batch.length}${parsed.max !== void 0 ? ` (--max ${parsed.max})` : ""} \xB7 ${remaining} remaining after
1595
- video stt: ${parsed.diarize ? "AssemblyAI (speaker-labelled)" : "Whisper"}
1894
+ video: ${videoLabel}
1596
1895
  throttle: ${parsed.throttleMs} ms between fetches
1597
1896
  `;
1598
1897
  if (batch.length === 0) {
@@ -1613,6 +1912,16 @@ ${plan}`);
1613
1912
  process.stdout.write(`import-web
1614
1913
  ${plan}`);
1615
1914
  const chain = [];
1915
+ if (!parsed.noCaptions) {
1916
+ chain.push(
1917
+ new YtDlpCaptionsFetcher({
1918
+ ...parsed.lang ? { preferLang: parsed.lang } : {},
1919
+ ...parsed.maxDurationSec ? { maxDurationSec: parsed.maxDurationSec } : {},
1920
+ ...parsed.cookiesFromBrowser ? { cookiesFromBrowser: parsed.cookiesFromBrowser } : {},
1921
+ ...parsed.cookiesFile ? { cookiesFile: parsed.cookiesFile } : {}
1922
+ })
1923
+ );
1924
+ }
1616
1925
  let stt;
1617
1926
  if (parsed.diarize) {
1618
1927
  const aaiKey = process.env.ASSEMBLYAI_API_KEY;
@@ -1627,7 +1936,7 @@ ${plan}`);
1627
1936
  stt = new ChunkedStt({ base: new OpenAiWhisperStt({ apiKey: openaiKey }) });
1628
1937
  else
1629
1938
  process.stderr.write(
1630
- "corpus: OPENAI_API_KEY not set \u2014 video URLs will be skipped (no transcription).\n"
1939
+ 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"
1631
1940
  );
1632
1941
  }
1633
1942
  if (stt)
@@ -1645,7 +1954,7 @@ ${plan}`);
1645
1954
  const client = await connectBrowserMcp({ endpoint: parsed.scrapeMcp });
1646
1955
  chain.push(new ScrapeMcpFetcher({ client }));
1647
1956
  } catch (e) {
1648
- return fail(`could not connect to scrape MCP at ${parsed.scrapeMcp}: ${msg2(e)}`, 1);
1957
+ return fail(`could not connect to scrape MCP at ${parsed.scrapeMcp}: ${msg3(e)}`, 1);
1649
1958
  }
1650
1959
  }
1651
1960
  if (parsed.browserMcp) {
@@ -1654,7 +1963,7 @@ ${plan}`);
1654
1963
  browser = await connectBrowserMcp({ endpoint: parsed.browserMcp });
1655
1964
  chain.push(new BrowserMcpFetcher({ browser }));
1656
1965
  } catch (e) {
1657
- return fail(`could not connect to browser MCP at ${parsed.browserMcp}: ${msg2(e)}`, 1);
1966
+ return fail(`could not connect to browser MCP at ${parsed.browserMcp}: ${msg3(e)}`, 1);
1658
1967
  }
1659
1968
  }
1660
1969
  chain.push(new HttpReadabilityFetcher());
@@ -1685,7 +1994,7 @@ ${plan}`);
1685
1994
  }
1686
1995
  });
1687
1996
  } catch (e) {
1688
- return fail(`import failed: ${msg2(e)}`, 1);
1997
+ return fail(`import failed: ${msg3(e)}`, 1);
1689
1998
  }
1690
1999
  process.stdout.write(
1691
2000
  `import-web \u2192 ${target}
@@ -1705,10 +2014,10 @@ knowledge engine (RAG) via the WriterPort. Import only stages the sources.
1705
2014
  );
1706
2015
  return 0;
1707
2016
  }
1708
- function msg2(e) {
2017
+ function msg3(e) {
1709
2018
  return e instanceof Error ? e.message : String(e);
1710
2019
  }
1711
- function parse2(args) {
2020
+ function parse3(args) {
1712
2021
  const out = {
1713
2022
  topic: void 0,
1714
2023
  outputPath: void 0,
@@ -1716,7 +2025,8 @@ function parse2(args) {
1716
2025
  channels: /* @__PURE__ */ new Set(["web"]),
1717
2026
  lang: void 0,
1718
2027
  tags: [],
1719
- doImport: false
2028
+ doImport: false,
2029
+ fresh: false
1720
2030
  };
1721
2031
  for (let i = 0; i < args.length; i++) {
1722
2032
  const a = args[i];
@@ -1743,6 +2053,9 @@ function parse2(args) {
1743
2053
  case "--import":
1744
2054
  out.doImport = true;
1745
2055
  break;
2056
+ case "--fresh":
2057
+ out.fresh = true;
2058
+ break;
1746
2059
  default:
1747
2060
  if (!a.startsWith("-")) {
1748
2061
  if (out.topic === void 0) out.topic = a;
@@ -1870,10 +2183,18 @@ async function discoverSocial(_topic, _lang, _max) {
1870
2183
  process.stdout.write(" social channel needs a Bureau session \u2014 skipped.\n");
1871
2184
  return [];
1872
2185
  }
2186
+ async function readExistingUrls(outFile) {
2187
+ try {
2188
+ const prior = await readFile(outFile, "utf-8");
2189
+ return prior.split("\n").map((l) => l.trim()).filter(Boolean);
2190
+ } catch {
2191
+ return [];
2192
+ }
2193
+ }
1873
2194
  async function runDiscover(args) {
1874
- const parsed = parse2(args);
2195
+ const parsed = parse3(args);
1875
2196
  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);
2197
+ return fail("discover requires a <topic> argument. Usage: corpus discover <topic> [path] [--max N] [--channels web,youtube,social] [--lang fr] [--tags t] [--import] [--fresh]", 2);
1877
2198
  }
1878
2199
  const outputPath = parsed.outputPath ? resolveWorkspacePath(parsed.outputPath) : process.cwd();
1879
2200
  process.stdout.write(`discover "${parsed.topic}"
@@ -1916,10 +2237,22 @@ async function runDiscover(args) {
1916
2237
  }
1917
2238
  await mkdir(outputPath, { recursive: true });
1918
2239
  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
- `);
2240
+ const existing = parsed.fresh ? [] : await readExistingUrls(outFile);
2241
+ const merged = [...existing];
2242
+ const known = new Set(existing);
2243
+ for (const url of allUrls) {
2244
+ if (!known.has(url)) {
2245
+ known.add(url);
2246
+ merged.push(url);
2247
+ }
2248
+ }
2249
+ const added = merged.length - existing.length;
2250
+ await writeFile(outFile, merged.join("\n") + "\n", "utf-8");
2251
+ process.stdout.write(
2252
+ `
2253
+ written \u2192 ${outFile} (${merged.length} URLs` + (parsed.fresh ? ", --fresh overwrite" : `, ${added} new, ${existing.length} kept from previous runs`) + `)
2254
+ `
2255
+ );
1923
2256
  if (parsed.doImport) {
1924
2257
  process.stdout.write("\n --import: chaining import-web\u2026\n");
1925
2258
  const importArgs = [
@@ -2290,7 +2623,7 @@ var DISTILLER_ENGINES = {
2290
2623
  ])
2291
2624
  )
2292
2625
  };
2293
- function parse3(args) {
2626
+ function parse4(args) {
2294
2627
  const out = {
2295
2628
  workspace: void 0,
2296
2629
  sourceId: void 0,
@@ -2345,13 +2678,13 @@ async function readSources(root) {
2345
2678
  const out = [];
2346
2679
  for (const e of entries) {
2347
2680
  if (!e.isFile() || !e.name.endsWith(".md")) continue;
2348
- const path5 = join(e.parentPath, e.name);
2681
+ const path6 = join(e.parentPath, e.name);
2349
2682
  try {
2350
- const parsed = matter2(await readFile(path5, "utf-8"));
2683
+ const parsed = matter2(await readFile(path6, "utf-8"));
2351
2684
  const fm = SOURCE_FRONTMATTER.parse(parsed.data);
2352
2685
  if (!fm.id || !parsed.content.trim()) continue;
2353
2686
  out.push({
2354
- path: path5,
2687
+ path: path6,
2355
2688
  id: fm.id,
2356
2689
  title: fm.title ?? fm.id,
2357
2690
  body: parsed.content.trim(),
@@ -2387,7 +2720,7 @@ async function scanDistilledSourceIds(root) {
2387
2720
  }
2388
2721
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2389
2722
  async function runDistill(args) {
2390
- const parsed = parse3(args);
2723
+ const parsed = parse4(args);
2391
2724
  const target = resolveWorkspacePath(parsed.workspace);
2392
2725
  const engine = DISTILLER_ENGINES[parsed.engine];
2393
2726
  if (!engine) {
@@ -2526,7 +2859,12 @@ async function runReport(args) {
2526
2859
  }
2527
2860
  function loadReportConfig(configPath) {
2528
2861
  const raw = readFileSync(path.resolve(process.cwd(), configPath), "utf8");
2529
- return reportConfigSchema.parse(JSON.parse(raw));
2862
+ const parsed = JSON.parse(raw);
2863
+ for (const warning of lintReportConfig(parsed)) {
2864
+ process.stderr.write(`corpus report: ${warning}
2865
+ `);
2866
+ }
2867
+ return reportConfigSchema.parse(parsed);
2530
2868
  }
2531
2869
  async function runReportPacks(args) {
2532
2870
  let dataset;
@@ -2786,10 +3124,10 @@ var SseMcpClient = class {
2786
3124
  try {
2787
3125
  const parsed = RPC_MESSAGE.safeParse(JSON.parse(payload));
2788
3126
  if (!parsed.success) return;
2789
- const msg4 = parsed.data;
2790
- if (typeof msg4.id === "number" && this.pending.has(msg4.id)) {
2791
- this.pending.get(msg4.id)(msg4);
2792
- this.pending.delete(msg4.id);
3127
+ const msg5 = parsed.data;
3128
+ if (typeof msg5.id === "number" && this.pending.has(msg5.id)) {
3129
+ this.pending.get(msg5.id)(msg5);
3130
+ this.pending.delete(msg5.id);
2793
3131
  }
2794
3132
  } catch {
2795
3133
  }
@@ -2858,7 +3196,7 @@ var McpSink = class {
2858
3196
  try {
2859
3197
  client = await this.ensure();
2860
3198
  } catch (e) {
2861
- return { uri: item.uri, ok: false, error: `connect: ${msg3(e)}` };
3199
+ return { uri: item.uri, ok: false, error: `connect: ${msg4(e)}` };
2862
3200
  }
2863
3201
  const args = template(this.config.args, item);
2864
3202
  const call = this.config.importedAlias ? { name: "mcp_imported_call", args: { alias: this.config.importedAlias, toolName: this.config.tool, args } } : { name: this.config.tool, args };
@@ -2867,7 +3205,7 @@ var McpSink = class {
2867
3205
  if (res.isError) return { uri: item.uri, ok: false, error: "tool returned isError" };
2868
3206
  return { uri: item.uri, ok: true };
2869
3207
  } catch (e) {
2870
- return { uri: item.uri, ok: false, error: msg3(e) };
3208
+ return { uri: item.uri, ok: false, error: msg4(e) };
2871
3209
  }
2872
3210
  }
2873
3211
  };
@@ -2911,7 +3249,7 @@ function stringify(v) {
2911
3249
  if (Array.isArray(v)) return v.join(", ");
2912
3250
  return v == null ? "" : String(v);
2913
3251
  }
2914
- function msg3(e) {
3252
+ function msg4(e) {
2915
3253
  return e instanceof Error ? e.message : String(e);
2916
3254
  }
2917
3255
 
@@ -3007,12 +3345,20 @@ Commands:
3007
3345
  (--preset seeds a full vertical; --list shows them)
3008
3346
  validate [path] JSON Schema check across every AIP file
3009
3347
  lint [path] Run lints declared in KNOWLEDGE.md
3348
+ verify [path] --facets a,b,... [--thin 8] [--apply] [--contaminated]
3349
+ SOP \u2464b: coverage per facet tag +
3350
+ quarantine of self-flagged bad scrapes.
3351
+ --apply moves flagged entries to
3352
+ demoted/ (reversible); --contaminated
3353
+ (with --apply) also demotes siblings
3354
+ sharing a poisoned source.
3010
3355
  events:emit <kind> --payload <json> [path]
3011
3356
  Append an event to _log.md
3012
3357
  events:tail [path] Print _log.md
3013
- discover <topic> [path] [--max N --channels web,youtube,social --lang l --tags t --import]
3358
+ discover <topic> [path] [--max N --channels web,youtube,social --lang l --tags t --import --fresh]
3014
3359
  Fan out across channels (web search + YouTube +
3015
- social), dedup, write urls.discovered.txt.
3360
+ social), dedup, merge into urls.discovered.txt
3361
+ (union with previous runs; --fresh overwrites).
3016
3362
  Web: auto-picks first available key (SERPER /
3017
3363
  EXA / TAVILY / GOOGLE_SEARCH). YouTube: yt-dlp
3018
3364
  flat-playlist. --import chains import-web.
@@ -3074,6 +3420,8 @@ async function main(argv2) {
3074
3420
  return await runValidate(rest);
3075
3421
  case "lint":
3076
3422
  return await runLint(rest);
3423
+ case "verify":
3424
+ return await runVerify(rest);
3077
3425
  case "events:emit":
3078
3426
  return await runEventsEmit(rest);
3079
3427
  case "events:tail":