@gresmcp/mcp 1.0.0 → 1.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.js CHANGED
@@ -37,6 +37,7 @@ __export(db_exports, {
37
37
  contentHash: () => contentHash,
38
38
  countEntries: () => countEntries,
39
39
  createKs: () => createKs,
40
+ dbClockTimestamp: () => dbClockTimestamp,
40
41
  deleteBySource: () => deleteBySource,
41
42
  deleteKs: () => deleteKs,
42
43
  ensureEntryTable: () => ensureEntryTable,
@@ -45,6 +46,7 @@ __export(db_exports, {
45
46
  getPool: () => getPool,
46
47
  initDb: () => initDb,
47
48
  insertChunks: () => insertChunks,
49
+ ksStats: () => ksStats,
48
50
  listKs: () => listKs,
49
51
  sourceChunkHashes: () => sourceChunkHashes,
50
52
  updateKs: () => updateKs
@@ -211,6 +213,41 @@ async function countEntries(ks) {
211
213
  const res = await getPool().query(`SELECT count(*)::int AS n FROM ${tbl}`);
212
214
  return Number(res.rows[0]?.n ?? 0);
213
215
  }
216
+ async function dbClockTimestamp() {
217
+ const res = await getPool().query(`SELECT clock_timestamp() AS ts`);
218
+ return res.rows[0].ts;
219
+ }
220
+ async function ksStats(ks, since) {
221
+ const tbl = entryTableName(ks);
222
+ const params = [];
223
+ let where = "";
224
+ if (since) {
225
+ params.push(since);
226
+ where = ` WHERE created_at >= $1`;
227
+ }
228
+ const res = await getPool().query(
229
+ `SELECT count(*)::int AS entries,
230
+ count(DISTINCT source)::int AS sources,
231
+ COALESCE(sum(length(content)), 0)::bigint AS chars,
232
+ round(avg(length(content)))::int AS avg_chunk_chars,
233
+ min(length(content))::int AS min_chunk_chars,
234
+ max(length(content))::int AS max_chunk_chars,
235
+ max(created_at) AS last_fed_at
236
+ FROM ${tbl}${where}`,
237
+ params
238
+ );
239
+ const r = res.rows[0];
240
+ return {
241
+ ks,
242
+ entries: Number(r.entries ?? 0),
243
+ sources: Number(r.sources ?? 0),
244
+ chars: Number(r.chars ?? 0),
245
+ avg_chunk_chars: r.avg_chunk_chars === null ? null : Number(r.avg_chunk_chars),
246
+ min_chunk_chars: r.min_chunk_chars === null ? null : Number(r.min_chunk_chars),
247
+ max_chunk_chars: r.max_chunk_chars === null ? null : Number(r.max_chunk_chars),
248
+ last_fed_at: r.last_fed_at === null ? null : r.last_fed_at
249
+ };
250
+ }
214
251
  var pool;
215
252
  var init_db = __esm({
216
253
  "src/db.ts"() {
@@ -353,8 +390,8 @@ function chunkMarkdown(text, opts = {}) {
353
390
  const m = HEADING_RE.exec(line);
354
391
  if (m) {
355
392
  const level = m[1].length;
356
- const path2 = sections[sections.length - 1].path;
357
- const parent = path2.slice(0, level - 1);
393
+ const path3 = sections[sections.length - 1].path;
394
+ const parent = path3.slice(0, level - 1);
358
395
  sections.push({ path: [...parent, m[2].trim()], body: [line] });
359
396
  } else {
360
397
  sections[sections.length - 1].body.push(line);
@@ -388,9 +425,12 @@ function mdTitle(text) {
388
425
  // src/html.ts
389
426
  import { JSDOM } from "jsdom";
390
427
  import TurndownService from "turndown";
428
+ import { Readability, isProbablyReaderable } from "@mozilla/readability";
391
429
  import gfmModule from "turndown-plugin-gfm";
392
430
  var gfm = typeof gfmModule === "function" ? gfmModule : gfmModule.gfm;
393
431
  var REMOVE_SELECTORS = "script, style, noscript, template, iframe, svg, nav, footer, aside, form, button";
432
+ var SHELL_MARKER_RE = /id=["'](root|app|__next|__nuxt|q-app)["']|data-reactroot|ng-version=/i;
433
+ var SHELL_TEXT_THRESHOLD = 250;
394
434
  var service;
395
435
  function turndown() {
396
436
  if (!service) {
@@ -406,25 +446,276 @@ function turndown() {
406
446
  }
407
447
  return service;
408
448
  }
409
- function htmlToMarkdown(html) {
449
+ function parseHtmlFilter(spec) {
450
+ const steps = spec.split(/\s*->\s*/).map((s) => s.trim()).filter(Boolean);
451
+ if (steps.length === 0) {
452
+ throw new Error(`invalid --html-filter '${spec.trim()}': expected a CSS selector chain, e.g. '#article -> .content'`);
453
+ }
454
+ return steps;
455
+ }
456
+ function validateCssSelector(selector) {
457
+ const dom = new JSDOM();
458
+ try {
459
+ dom.window.document.querySelector(selector);
460
+ } catch (err) {
461
+ throw new Error(`invalid CSS selector '${selector}': ${err instanceof Error ? err.message : err}`);
462
+ }
463
+ }
464
+ function selectChain(scope, steps) {
465
+ for (const step of steps.slice(0, -1)) {
466
+ const next = scope.querySelector(step);
467
+ if (!next) return void 0;
468
+ scope = next;
469
+ }
470
+ const matches = [...scope.querySelectorAll(steps[steps.length - 1])];
471
+ return matches.length > 0 ? matches : void 0;
472
+ }
473
+ function elementsToMarkdown(elements) {
474
+ const parts = [];
475
+ for (const el of elements) {
476
+ try {
477
+ parts.push(turndown().turndown(el.innerHTML));
478
+ } catch {
479
+ parts.push(el.textContent ?? "");
480
+ }
481
+ }
482
+ return parts.join("\n\n");
483
+ }
484
+ function htmlToMarkdown(html, opts = {}) {
410
485
  const dom = new JSDOM(html);
411
486
  const doc = dom.window.document;
412
487
  doc.querySelectorAll(REMOVE_SELECTORS).forEach((el) => el.remove());
413
488
  const title = doc.title?.trim() || doc.querySelector("h1")?.textContent?.trim() || void 0;
414
- const root = doc.body ?? doc.documentElement;
415
489
  let markdown = "";
416
- try {
417
- markdown = turndown().turndown(root.innerHTML);
418
- } catch {
419
- markdown = root.textContent ?? "";
490
+ if (opts.filter) {
491
+ const elements = selectChain(doc, parseHtmlFilter(opts.filter));
492
+ if (elements) markdown = elementsToMarkdown(elements);
493
+ } else {
494
+ const root = doc.body ?? doc.documentElement;
495
+ try {
496
+ markdown = turndown().turndown(root.innerHTML);
497
+ } catch {
498
+ markdown = root.textContent ?? "";
499
+ }
420
500
  }
421
501
  markdown = markdown.replace(/\n{3,}/g, "\n\n").trim();
422
502
  return { markdown, title: title || void 0 };
423
503
  }
504
+ function metaContent(doc, selectors) {
505
+ for (const selector of selectors) {
506
+ const el = doc.querySelector(selector);
507
+ const value = (el?.getAttribute("content") ?? el?.textContent ?? "").trim();
508
+ if (value) return value;
509
+ }
510
+ return void 0;
511
+ }
512
+ function collectTags(doc) {
513
+ const tags = [];
514
+ const keywords = metaContent(doc, ['meta[name="keywords"]']);
515
+ if (keywords) {
516
+ for (const part of keywords.split(",")) {
517
+ const tag = part.trim();
518
+ if (tag) tags.push(tag);
519
+ }
520
+ }
521
+ doc.querySelectorAll('meta[property="og:article:tag"]').forEach((el) => {
522
+ const value = (el.getAttribute("content") ?? "").trim();
523
+ if (value) tags.push(value);
524
+ });
525
+ const seen = /* @__PURE__ */ new Set();
526
+ return tags.filter((t) => {
527
+ const key = t.toLowerCase();
528
+ if (seen.has(key)) return false;
529
+ seen.add(key);
530
+ return true;
531
+ });
532
+ }
533
+ function extractPage(html, opts = {}) {
534
+ const dom = new JSDOM(html);
535
+ const doc = dom.window.document;
536
+ const title = doc.title?.trim() || doc.querySelector('meta[property="og:title"]')?.getAttribute("content")?.trim() || doc.querySelector("h1")?.textContent?.trim() || void 0;
537
+ const description = metaContent(doc, [
538
+ 'meta[name="description"]',
539
+ 'meta[property="og:description"]'
540
+ ]);
541
+ const tags = collectTags(doc);
542
+ doc.querySelectorAll(REMOVE_SELECTORS).forEach((el) => el.remove());
543
+ let markdown = "";
544
+ let filterMatched;
545
+ if (opts.filter) {
546
+ const elements = selectChain(doc, parseHtmlFilter(opts.filter));
547
+ filterMatched = elements !== void 0;
548
+ if (elements) markdown = elementsToMarkdown(elements);
549
+ } else {
550
+ try {
551
+ if (isProbablyReaderable(doc)) {
552
+ const article = new Readability(doc).parse();
553
+ if (article?.content) markdown = turndown().turndown(article.content);
554
+ }
555
+ } catch {
556
+ markdown = "";
557
+ }
558
+ if (!markdown.trim()) {
559
+ const root = doc.body ?? doc.documentElement;
560
+ try {
561
+ markdown = turndown().turndown(root.innerHTML);
562
+ } catch {
563
+ markdown = root.textContent ?? "";
564
+ }
565
+ }
566
+ }
567
+ markdown = markdown.replace(/\n{3,}/g, "\n\n").trim();
568
+ const likelyShell = markdown.length < SHELL_TEXT_THRESHOLD && SHELL_MARKER_RE.test(html);
569
+ return {
570
+ markdown,
571
+ title,
572
+ description,
573
+ tags: tags.length > 0 ? tags : void 0,
574
+ likelyShell,
575
+ filterMatched
576
+ };
577
+ }
424
578
 
425
579
  // src/ingest.ts
426
580
  init_db();
427
- init_config();
581
+
582
+ // src/progress.ts
583
+ var BAR_WIDTH = 20;
584
+ var PLAIN_STEP = 32;
585
+ function fmt(n) {
586
+ return n.toLocaleString("en-US");
587
+ }
588
+ function bar(pct2) {
589
+ const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round(pct2 * BAR_WIDTH)));
590
+ return "#".repeat(filled) + ".".repeat(BAR_WIDTH - filled);
591
+ }
592
+ function pct(p) {
593
+ return `${Math.round(Math.max(0, Math.min(1, p)) * 100)}%`;
594
+ }
595
+ var NOOP_HANDLE = { handled: () => {
596
+ } };
597
+ var FeedProgress = class {
598
+ write;
599
+ interactive;
600
+ total;
601
+ rejected;
602
+ slots = [];
603
+ filesDone = 0;
604
+ totalChars = 0;
605
+ blockLines = 0;
606
+ finished = false;
607
+ constructor(opts) {
608
+ this.total = Math.max(0, Math.floor(opts.total));
609
+ this.rejected = Math.max(0, Math.floor(opts.rejected));
610
+ this.write = opts.out ?? ((s) => process.stderr.write(s));
611
+ this.interactive = opts.interactive ?? (opts.out === void 0 && !!process.stderr.isTTY);
612
+ }
613
+ get isInteractive() {
614
+ return this.interactive;
615
+ }
616
+ startFile(source, totalChunks, totalChars) {
617
+ if (this.finished) return NOOP_HANDLE;
618
+ const slot = { source, totalChunks, totalChars, doneChunks: 0, doneChars: 0, printedChunks: 0 };
619
+ this.slots.push(slot);
620
+ this.render();
621
+ return {
622
+ handled: (chunks, chars) => this.handleSlot(slot, chunks, chars)
623
+ };
624
+ }
625
+ log(msg) {
626
+ if (this.finished) return;
627
+ this.clearBlock();
628
+ this.write(`${msg}
629
+ `);
630
+ }
631
+ finish() {
632
+ if (this.finished) return;
633
+ this.finished = true;
634
+ if (this.slots.length === 0 && this.total === 0 && this.rejected === 0) return;
635
+ if (this.interactive) {
636
+ this.redraw(this.lines());
637
+ this.write("\n");
638
+ this.blockLines = 0;
639
+ } else {
640
+ this.write(`${this.totalLine()}
641
+ `);
642
+ }
643
+ }
644
+ close() {
645
+ if (this.finished) return;
646
+ this.finished = true;
647
+ this.clearBlock();
648
+ }
649
+ lines() {
650
+ return [...this.slots.map((slot) => this.fileLine(slot)), this.totalLine()];
651
+ }
652
+ handleSlot(slot, chunks, chars) {
653
+ if (this.finished || chunks <= 0 || slot.doneChunks >= slot.totalChunks) return;
654
+ slot.doneChunks += chunks;
655
+ slot.doneChars += chars;
656
+ this.totalChars += chars;
657
+ if (slot.doneChunks >= slot.totalChunks) {
658
+ this.filesDone++;
659
+ if (this.interactive) {
660
+ this.render();
661
+ } else {
662
+ this.printFileLine(slot);
663
+ }
664
+ this.slots.splice(this.slots.indexOf(slot), 1);
665
+ return;
666
+ }
667
+ if (!this.interactive && slot.doneChunks - slot.printedChunks >= PLAIN_STEP) {
668
+ this.printFileLine(slot);
669
+ } else if (this.interactive) {
670
+ this.render();
671
+ }
672
+ }
673
+ render() {
674
+ if (!this.interactive) return;
675
+ this.redraw(this.lines());
676
+ }
677
+ printFileLine(slot) {
678
+ slot.printedChunks = slot.doneChunks;
679
+ this.write(`${this.fileLine(slot)}
680
+ `);
681
+ }
682
+ fileLine(slot) {
683
+ const p = slot.totalChunks > 0 ? slot.doneChunks / slot.totalChunks : 1;
684
+ return `[${bar(p)}] ${pct(p).padStart(4)} ${fmt(slot.doneChunks)}/${fmt(slot.totalChunks)} chunks ${fmt(slot.doneChars)}/${fmt(slot.totalChars)} chars ${slot.source}`;
685
+ }
686
+ totalLine() {
687
+ const p = this.total > 0 ? this.filesDone / this.total : 0;
688
+ const rejected = this.rejected > 0 ? ` | rejected: ${this.rejected} (excluded)` : "";
689
+ return `total: ${this.filesDone}/${this.total} files (${pct(p)}) ${fmt(this.totalChars)} chars${rejected}`;
690
+ }
691
+ redraw(lines) {
692
+ let out = "";
693
+ if (this.blockLines > 1) out += `\x1B[${this.blockLines - 1}A`;
694
+ out += "\r";
695
+ for (let i = 0; i < lines.length; i++) {
696
+ out += `\x1B[2K${lines[i]}${i < lines.length - 1 ? "\n" : ""}`;
697
+ }
698
+ this.write(out);
699
+ this.blockLines = lines.length;
700
+ }
701
+ clearBlock() {
702
+ if (!this.interactive || this.blockLines === 0) {
703
+ this.blockLines = 0;
704
+ return;
705
+ }
706
+ let out = "";
707
+ if (this.blockLines > 1) out += `\x1B[${this.blockLines - 1}A`;
708
+ out += "\r";
709
+ for (let i = 0; i < this.blockLines; i++) {
710
+ out += `\x1B[2K${i < this.blockLines - 1 ? "\n" : ""}`;
711
+ }
712
+ out += "\n";
713
+ this.write(out);
714
+ this.blockLines = 0;
715
+ }
716
+ };
717
+
718
+ // src/ingest.ts
428
719
  var MD_EXT = /* @__PURE__ */ new Set([".md", ".markdown", ".mdx"]);
429
720
  var HTML_EXT = /* @__PURE__ */ new Set([".html", ".htm", ".xhtml"]);
430
721
  var TEXT_EXT = /* @__PURE__ */ new Set([
@@ -555,14 +846,17 @@ async function loadTarget(target, opts) {
555
846
  let text = raw;
556
847
  let title = "";
557
848
  if (HTML_EXT.has(ext)) {
558
- const conv = htmlToMarkdown(raw);
849
+ const conv = htmlToMarkdown(raw, { filter: opts.htmlFilter });
559
850
  text = conv.markdown;
560
851
  title = conv.title ?? "";
561
852
  } else if (MD_EXT.has(ext)) {
562
853
  title = mdTitle(raw) ?? "";
563
854
  }
564
855
  if (!text.trim()) {
565
- skipped.push({ path: file, reason: "empty" });
856
+ skipped.push({
857
+ path: file,
858
+ reason: HTML_EXT.has(ext) && opts.htmlFilter ? `html filter '${opts.htmlFilter}' matched nothing` : "empty"
859
+ });
566
860
  continue;
567
861
  }
568
862
  if (!title) title = path.basename(file);
@@ -575,115 +869,587 @@ async function loadTarget(target, opts) {
575
869
  }
576
870
  return { docs, fileCount: files.length, skipped };
577
871
  }
578
- async function embedAndInsert(ks, pending, opts) {
579
- if (pending.length === 0 || opts.dryRun) return { inserted: 0 };
580
- const contents = pending.map((p) => p.chunk.content);
581
- opts.log?.(`Embedding ${contents.length} chunk(s) with ${ks.embedding_model} at ${opts.url} ...`);
582
- const embeddings = await embedBatch(opts.url, ks.embedding_model, contents, opts.batchSize, (done, total) => {
583
- opts.log?.(` embedded ${done}/${total}`);
584
- });
585
- const byDoc = /* @__PURE__ */ new Map();
586
- pending.forEach((p, i) => {
587
- const list = byDoc.get(p.doc) ?? [];
588
- list.push({
589
- source: p.doc.source,
590
- title: p.doc.title,
591
- chunk_index: p.chunk.index,
592
- content: p.chunk.content,
593
- metadata: p.metadata,
594
- embedding: embeddings[i]
595
- });
596
- byDoc.set(p.doc, list);
597
- });
598
- let inserted = 0;
599
- for (const [, rows] of byDoc) {
600
- inserted += await insertChunks(ks, rows);
601
- }
602
- return { inserted };
603
- }
604
- async function feedTarget(ks, target, opts = {}) {
605
- const loadOpts = { maxLen: opts.maxLen ?? 1200, overlap: opts.overlap ?? 180 };
606
- const { docs, fileCount, skipped } = await loadTarget(target, loadOpts);
607
- const log = opts.log ?? ((m) => console.log(m));
608
- for (const doc of docs) {
609
- log(`${doc.source}: ${doc.chunks.length} chunk(s)`);
610
- }
611
- for (const s of skipped) {
612
- log(`skipped ${s.path}: ${s.reason}`);
613
- }
614
- const meta = { ...opts.metadata };
615
- if (opts.tags && opts.tags.length > 0) meta.tags = opts.tags;
872
+ var DOC_CONCURRENCY = 3;
873
+ async function feedDocs(ks, docs, opts = {}) {
874
+ const log2 = opts.log ?? ((m) => console.log(m));
875
+ const progress = opts.progress;
876
+ const batchSize = opts.batchSize ?? 32;
877
+ const baseMeta = { ...opts.metadata ?? {} };
878
+ const cliTags = opts.tags && opts.tags.length > 0 ? opts.tags : void 0;
879
+ const say = (msg) => progress ? progress.log(msg) : log2(msg);
616
880
  let deletedForReplace = 0;
617
- const pending = [];
618
881
  let skippedExisting = 0;
619
- for (const doc of docs) {
882
+ let inserted = 0;
883
+ let announcedEmbed = false;
884
+ const processDoc = async (doc) => {
885
+ const file = progress?.startFile(
886
+ doc.source,
887
+ doc.chunks.length,
888
+ doc.chunks.reduce((n, c) => n + c.content.length, 0)
889
+ );
620
890
  if (opts.replace) {
621
- deletedForReplace += await deleteBySource(ks, doc.source);
891
+ const deleted = await deleteBySource(ks, doc.source);
892
+ deletedForReplace += deleted;
622
893
  }
623
894
  const existing = await sourceChunkHashes(ks, doc.source);
895
+ const meta = { ...baseMeta, ...doc.metadata ?? {} };
896
+ if (!("tags" in meta) && cliTags) meta.tags = cliTags;
897
+ const todo = [];
624
898
  for (const chunk of doc.chunks) {
625
899
  if (!opts.replace && existing.get(chunk.index) === contentHash(chunk.content)) {
626
900
  skippedExisting++;
901
+ file?.handled(1, chunk.content.length);
627
902
  continue;
628
903
  }
629
- pending.push({ doc, chunk, metadata: meta });
904
+ todo.push(chunk);
905
+ }
906
+ if (todo.length === 0) return;
907
+ if (opts.dryRun) {
908
+ for (const chunk of todo) file?.handled(1, chunk.content.length);
909
+ return;
630
910
  }
911
+ if (!announcedEmbed) {
912
+ announcedEmbed = true;
913
+ say(`Embedding with ${ks.embedding_model} at ${ks.ollama_url} ...`);
914
+ }
915
+ const contents = todo.map((c) => c.content);
916
+ const charLens = contents.map((c) => c.length);
917
+ let reported = 0;
918
+ const embeddings = await embedBatch(ks.ollama_url, ks.embedding_model, contents, batchSize, (done) => {
919
+ const delta = done - reported;
920
+ if (delta <= 0) return;
921
+ let chars = 0;
922
+ for (let i = reported; i < done; i++) chars += charLens[i];
923
+ reported = done;
924
+ file?.handled(delta, chars);
925
+ });
926
+ const rows = todo.map((chunk, i) => ({
927
+ source: doc.source,
928
+ title: doc.title,
929
+ chunk_index: chunk.index,
930
+ content: chunk.content,
931
+ metadata: meta,
932
+ embedding: embeddings[i]
933
+ }));
934
+ const rowCount = await insertChunks(ks, rows);
935
+ inserted += rowCount;
936
+ };
937
+ let cursor = 0;
938
+ let firstError;
939
+ const runWorker = async () => {
940
+ while (firstError === void 0) {
941
+ const index = cursor++;
942
+ if (index >= docs.length) return;
943
+ try {
944
+ await processDoc(docs[index]);
945
+ } catch (err) {
946
+ firstError ??= err;
947
+ return;
948
+ }
949
+ }
950
+ };
951
+ const workerCount = Math.min(DOC_CONCURRENCY, docs.length);
952
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
953
+ if (firstError !== void 0) {
954
+ progress?.close();
955
+ throw firstError;
631
956
  }
632
- const { inserted } = await embedAndInsert(ks, pending, {
633
- url: ollamaUrl(opts.url),
634
- dryRun: opts.dryRun ?? false,
635
- batchSize: opts.batchSize ?? 32,
636
- log
637
- });
957
+ progress?.finish();
638
958
  return {
639
959
  documents: docs.length,
640
960
  chunks: docs.reduce((n, d) => n + d.chunks.length, 0),
641
961
  inserted,
642
962
  skippedExisting,
643
963
  deletedForReplace,
644
- skippedFiles: skipped,
964
+ skippedFiles: [],
645
965
  dryRun: opts.dryRun ?? false
646
966
  };
647
967
  }
968
+ async function feedTarget(ks, target, opts = {}) {
969
+ const loadOpts = { maxLen: opts.maxLen ?? 1200, overlap: opts.overlap ?? 180, htmlFilter: opts.htmlFilter };
970
+ const { docs, skipped } = await loadTarget(target, loadOpts);
971
+ for (const s of skipped) {
972
+ (opts.log ?? ((m) => console.log(m)))(`skipped ${s.path}: ${s.reason}`);
973
+ }
974
+ const progress = opts.progress ?? new FeedProgress({ total: docs.length, rejected: skipped.length });
975
+ const summary = await feedDocs(ks, docs, { ...opts, progress });
976
+ return { ...summary, skippedFiles: skipped };
977
+ }
648
978
  async function feedText(ks, text, opts = {}) {
649
- const log = opts.log ?? ((m) => console.log(m));
979
+ const log2 = opts.log ?? ((m) => console.log(m));
650
980
  const source = opts.sourceName?.trim() || "manual";
651
981
  const title = opts.title?.trim() || source;
652
982
  const chunks = chunkMarkdown(text, { maxLen: opts.maxLen ?? 1200, overlap: opts.overlap ?? 180 });
653
983
  const doc = { source, title, chunks };
654
- log(`${source}: ${chunks.length} chunk(s)`);
655
- const meta = { ...opts.metadata };
656
- if (opts.tags && opts.tags.length > 0) meta.tags = opts.tags;
657
- let deletedForReplace = 0;
658
- const pending = [];
659
- let skippedExisting = 0;
660
- if (opts.replace) {
661
- deletedForReplace += await deleteBySource(ks, source);
984
+ const progress = opts.progress ?? new FeedProgress({ total: 1, rejected: 0 });
985
+ return feedDocs(ks, [doc], { ...opts, progress });
986
+ }
987
+
988
+ // src/scrape.ts
989
+ import { mkdtempSync } from "fs";
990
+ import { tmpdir } from "os";
991
+ import path2 from "path";
992
+ import { randomUUID } from "crypto";
993
+ import {
994
+ CheerioCrawler,
995
+ PlaywrightCrawler,
996
+ PuppeteerCrawler,
997
+ Request as CrawleeRequest,
998
+ RequestQueue,
999
+ RobotsTxtFile,
1000
+ Sitemap,
1001
+ constructGlobObjectsFromGlobs,
1002
+ filterRequestsByPatterns,
1003
+ log
1004
+ } from "crawlee";
1005
+ var CRAWLER_KINDS = ["auto", "crawlee", "playwright", "puppeteer"];
1006
+ function parseSitemapOption(value) {
1007
+ const trimmed = value.trim();
1008
+ if (!trimmed) {
1009
+ throw new Error("invalid --sitemap '': use auto, sitemap-only, html-only or a CSS selector");
662
1010
  }
663
- const existing = await sourceChunkHashes(ks, source);
664
- for (const chunk of chunks) {
665
- if (!opts.replace && existing.get(chunk.index) === contentHash(chunk.content)) {
666
- skippedExisting++;
667
- continue;
1011
+ const key = trimmed.toLowerCase();
1012
+ if (key === "auto") return { kind: "auto" };
1013
+ if (key === "sitemap-only") return { kind: "sitemap-only" };
1014
+ if (key === "html-only") return { kind: "html-only" };
1015
+ return { kind: "selector", selector: trimmed };
1016
+ }
1017
+ var BINARY_LINK_RE = /\.(css|js|mjs|cjs|map|png|jpe?g|gif|svg|webp|avif|ico|bmp|tiff?|woff2?|ttf|otf|eot|pdf|zip|gz|tgz|tar|rar|7z|bz2|xz|zst|mp3|mp4|m4a|webm|mov|avi|wmv|flv|docx?|xlsx?|pptx?|odt|ods|odp|rtf|dmg|exe|msi|apk|iso|bin|wasm|deb|rpm)(\?.*)?$/i;
1018
+ process.env.CRAWLEE_STORAGE_DIR ??= mkdtempSync(path2.join(tmpdir(), "gresmcp-crawl-"));
1019
+ async function freshQueue() {
1020
+ return RequestQueue.open(`gresmcp-${randomUUID()}`);
1021
+ }
1022
+ function normalizeSeedUrl(seed) {
1023
+ let url;
1024
+ try {
1025
+ url = new URL(seed.trim());
1026
+ } catch {
1027
+ throw new Error(`invalid URL '${seed}'`);
1028
+ }
1029
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
1030
+ throw new Error(`unsupported URL protocol '${url.protocol.replace(":", "")}' (use http or https)`);
1031
+ }
1032
+ url.hash = "";
1033
+ return url.toString();
1034
+ }
1035
+ function responseStatus(response) {
1036
+ if (!response || typeof response !== "object") return void 0;
1037
+ const res = response;
1038
+ if (typeof res.status === "number") return res.status;
1039
+ if (typeof res.status === "function") {
1040
+ const value = res.status();
1041
+ return typeof value === "number" ? value : void 0;
1042
+ }
1043
+ if (typeof res.statusCode === "number") return res.statusCode;
1044
+ return void 0;
1045
+ }
1046
+ function toText(body) {
1047
+ return typeof body === "string" ? body : body.toString("utf-8");
1048
+ }
1049
+ function titleFor(url, fallback) {
1050
+ const trimmed = fallback?.trim();
1051
+ if (trimmed) return trimmed;
1052
+ try {
1053
+ const segment = new URL(url).pathname.split("/").filter(Boolean).pop();
1054
+ const name = segment ? decodeURIComponent(segment) : "";
1055
+ return name || url;
1056
+ } catch {
1057
+ return url;
1058
+ }
1059
+ }
1060
+ function unionTags(...lists) {
1061
+ const out = [];
1062
+ const seen = /* @__PURE__ */ new Set();
1063
+ for (const list of lists) {
1064
+ for (const tag of list ?? []) {
1065
+ const value = tag.trim();
1066
+ if (!value) continue;
1067
+ const key = value.toLowerCase();
1068
+ if (seen.has(key)) continue;
1069
+ seen.add(key);
1070
+ out.push(value);
668
1071
  }
669
- pending.push({ doc, chunk, metadata: meta });
670
1072
  }
671
- const { inserted } = await embedAndInsert(ks, pending, {
672
- url: ollamaUrl(opts.url),
673
- dryRun: opts.dryRun ?? false,
674
- batchSize: opts.batchSize ?? 32,
675
- log
1073
+ return out.length > 0 ? out : void 0;
1074
+ }
1075
+ function buildDoc(url, kind, text, page, deps) {
1076
+ const chunkOpts = { maxLen: deps.maxLen, overlap: deps.overlap };
1077
+ const chunks = kind === "plain" ? chunkPlain(text, chunkOpts) : chunkMarkdown(text, chunkOpts);
1078
+ if (chunks.length === 0) return void 0;
1079
+ const title = kind === "html" ? titleFor(url, page?.title) : kind === "md" ? mdTitle(text) ?? titleFor(url) : titleFor(url);
1080
+ const metadata = { source_url: url, crawled_at: (/* @__PURE__ */ new Date()).toISOString() };
1081
+ if (page?.description) metadata.description = page.description;
1082
+ const tags = unionTags(page?.tags, deps.cliTags);
1083
+ if (tags) metadata.tags = tags;
1084
+ return { source: url, title, chunks, metadata };
1085
+ }
1086
+ function dedupeDocs(docs) {
1087
+ const bySource = /* @__PURE__ */ new Map();
1088
+ for (const doc of docs) {
1089
+ if (!bySource.has(doc.source)) bySource.set(doc.source, doc);
1090
+ }
1091
+ return [...bySource.values()];
1092
+ }
1093
+ function shortReason(err) {
1094
+ const message = (err?.message ?? String(err)).replace(/\s+/g, " ").trim();
1095
+ const contentType = /served Content-Type ([^,\s]+),/.exec(message);
1096
+ if (contentType) return `unsupported content-type '${contentType[1]}'`;
1097
+ return message.slice(0, 200);
1098
+ }
1099
+ async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1100
+ const crawler = new CheerioCrawler({
1101
+ requestQueue: queue,
1102
+ maxRequestsPerCrawl: Math.max(1, opts.maxPages),
1103
+ maxCrawlDepth: Math.max(0, opts.depth),
1104
+ additionalMimeTypes: ["text/plain", "text/markdown"],
1105
+ async requestHandler(ctx) {
1106
+ const { request, response, contentType, body, $ } = ctx;
1107
+ const url = request.loadedUrl ?? request.url;
1108
+ const status = responseStatus(response);
1109
+ if (status === void 0 || status < 200 || status >= 300) {
1110
+ sink.skipped.push({ path: url, reason: status === void 0 ? "no HTTP response" : `HTTP ${status}` });
1111
+ return;
1112
+ }
1113
+ const type = contentType?.type ?? "";
1114
+ if (type === "text/html" || type === "application/xhtml+xml") {
1115
+ const html = typeof body === "string" ? body : toText(body);
1116
+ const page = extractPage(html, { filter: opts.htmlFilter });
1117
+ if (page.likelyShell) {
1118
+ sink.shellUrls?.push(url);
1119
+ sink.skipped.push({ path: url, reason: "likely JS-rendered shell" });
1120
+ return;
1121
+ }
1122
+ if (opts.htmlFilter && !page.filterMatched) {
1123
+ sink.skipped.push({ path: url, reason: `html filter '${opts.htmlFilter}' matched nothing` });
1124
+ return;
1125
+ }
1126
+ if (!page.markdown.trim()) {
1127
+ sink.skipped.push({ path: url, reason: "empty" });
1128
+ return;
1129
+ }
1130
+ const doc = buildDoc(url, "html", page.markdown, page, sinkDeps(opts));
1131
+ if (!doc) {
1132
+ sink.skipped.push({ path: url, reason: "no chunks produced" });
1133
+ return;
1134
+ }
1135
+ sink.docs.push(doc);
1136
+ if (opts.followLinks !== false) {
1137
+ const selector = opts.linkSelector;
1138
+ if (selector) {
1139
+ if ($(selector).length > 0) {
1140
+ opts.selectorStats.hits += 1;
1141
+ await ctx.enqueueLinks({
1142
+ selector,
1143
+ strategy: "same-hostname",
1144
+ exclude: [BINARY_LINK_RE],
1145
+ globs: opts.urlFilter
1146
+ });
1147
+ } else {
1148
+ opts.selectorStats.misses += 1;
1149
+ }
1150
+ } else {
1151
+ await ctx.enqueueLinks({
1152
+ strategy: "same-hostname",
1153
+ exclude: [BINARY_LINK_RE],
1154
+ globs: opts.urlFilter
1155
+ });
1156
+ }
1157
+ }
1158
+ } else if (type === "text/markdown") {
1159
+ const doc = buildDoc(url, "md", toText(body), void 0, sinkDeps(opts));
1160
+ if (!doc) {
1161
+ sink.skipped.push({ path: url, reason: "no chunks produced" });
1162
+ return;
1163
+ }
1164
+ sink.docs.push(doc);
1165
+ } else if (type === "text/plain") {
1166
+ const doc = buildDoc(url, "plain", toText(body), void 0, sinkDeps(opts));
1167
+ if (!doc) {
1168
+ sink.skipped.push({ path: url, reason: "no chunks produced" });
1169
+ return;
1170
+ }
1171
+ sink.docs.push(doc);
1172
+ } else {
1173
+ sink.skipped.push({ path: url, reason: `unsupported content-type '${type}'` });
1174
+ }
1175
+ },
1176
+ async failedRequestHandler(ctx, error) {
1177
+ const url = ctx.request.loadedUrl ?? ctx.request.url;
1178
+ sink.skipped.push({ path: url, reason: shortReason(error) });
1179
+ }
676
1180
  });
1181
+ try {
1182
+ await crawler.run(seedUrls);
1183
+ } finally {
1184
+ await crawler.teardown().catch(() => {
1185
+ });
1186
+ }
1187
+ }
1188
+ function sinkDeps(opts) {
677
1189
  return {
678
- documents: 1,
679
- chunks: chunks.length,
680
- inserted,
681
- skippedExisting,
682
- deletedForReplace,
683
- skippedFiles: [],
684
- dryRun: opts.dryRun ?? false
1190
+ maxLen: opts.maxLen,
1191
+ overlap: opts.overlap,
1192
+ cliTags: opts.tags,
1193
+ urlFilter: opts.urlFilter,
1194
+ htmlFilter: opts.htmlFilter,
1195
+ followLinks: opts.followLinks,
1196
+ linkSelector: opts.linkSelector,
1197
+ selectorStats: opts.selectorStats
1198
+ };
1199
+ }
1200
+ async function importOptional(name) {
1201
+ try {
1202
+ return await import(name);
1203
+ } catch {
1204
+ return void 0;
1205
+ }
1206
+ }
1207
+ async function requireBrowserEngine(kind) {
1208
+ const moduleName = kind === "playwright" ? "playwright" : "puppeteer";
1209
+ const mod = await importOptional(moduleName);
1210
+ if (!mod) {
1211
+ const install = kind === "playwright" ? "npm i -g playwright && npx playwright install chromium" : "npm i -g puppeteer";
1212
+ throw new Error(`--crawler ${kind} requires ${moduleName}, which is not installed (install it with: ${install})`);
1213
+ }
1214
+ }
1215
+ async function handleBrowserPage(ctx, deps) {
1216
+ const request = ctx.request;
1217
+ const url = request.loadedUrl ?? request.url;
1218
+ const status = responseStatus(ctx.response);
1219
+ if (status === void 0 || status < 200 || status >= 300) {
1220
+ deps.sink.skipped.push({ path: url, reason: status === void 0 ? "no HTTP response" : `HTTP ${status}` });
1221
+ return;
1222
+ }
1223
+ const html = await ctx.page.content();
1224
+ const page = extractPage(html, { filter: deps.htmlFilter });
1225
+ if (deps.htmlFilter && !page.filterMatched) {
1226
+ deps.sink.skipped.push({ path: url, reason: `html filter '${deps.htmlFilter}' matched nothing` });
1227
+ return;
1228
+ }
1229
+ if (!page.markdown.trim()) {
1230
+ deps.sink.skipped.push({ path: url, reason: "empty after render" });
1231
+ return;
1232
+ }
1233
+ const doc = buildDoc(url, "html", page.markdown, page, deps);
1234
+ if (!doc) {
1235
+ deps.sink.skipped.push({ path: url, reason: "no chunks produced" });
1236
+ return;
1237
+ }
1238
+ deps.sink.docs.push(doc);
1239
+ if (deps.followLinks !== false) {
1240
+ const selector = deps.linkSelector;
1241
+ if (selector) {
1242
+ const elements = await ctx.page.$$(selector);
1243
+ if (elements.length > 0) {
1244
+ deps.selectorStats.hits += 1;
1245
+ await ctx.enqueueLinks({
1246
+ selector,
1247
+ strategy: "same-hostname",
1248
+ exclude: [BINARY_LINK_RE],
1249
+ globs: deps.urlFilter
1250
+ });
1251
+ } else {
1252
+ deps.selectorStats.misses += 1;
1253
+ }
1254
+ } else {
1255
+ await ctx.enqueueLinks({
1256
+ strategy: "same-hostname",
1257
+ exclude: [BINARY_LINK_RE],
1258
+ globs: deps.urlFilter
1259
+ });
1260
+ }
1261
+ }
1262
+ }
1263
+ async function crawlWithBrowser(kind, queue, seedUrls, opts, sink) {
1264
+ const crawler = kind === "playwright" ? new PlaywrightCrawler({
1265
+ requestQueue: queue,
1266
+ maxRequestsPerCrawl: Math.max(1, opts.maxPages),
1267
+ maxCrawlDepth: Math.max(0, opts.depth),
1268
+ preNavigationHooks: [
1269
+ (_ctx, gotoOptions) => {
1270
+ gotoOptions.waitUntil = "networkidle";
1271
+ gotoOptions.timeout = 3e4;
1272
+ }
1273
+ ],
1274
+ requestHandler: (ctx) => handleBrowserPage(ctx, { ...sinkDeps(opts), sink }),
1275
+ failedRequestHandler: (ctx, error) => {
1276
+ const request = ctx.request;
1277
+ sink.skipped.push({ path: request.loadedUrl ?? request.url, reason: shortReason(error) });
1278
+ }
1279
+ }) : new PuppeteerCrawler({
1280
+ requestQueue: queue,
1281
+ maxRequestsPerCrawl: Math.max(1, opts.maxPages),
1282
+ maxCrawlDepth: Math.max(0, opts.depth),
1283
+ preNavigationHooks: [
1284
+ (_ctx, gotoOptions) => {
1285
+ gotoOptions.waitUntil = "networkidle2";
1286
+ gotoOptions.timeout = 3e4;
1287
+ }
1288
+ ],
1289
+ requestHandler: (ctx) => handleBrowserPage(ctx, { ...sinkDeps(opts), sink }),
1290
+ failedRequestHandler: (ctx, error) => {
1291
+ const request = ctx.request;
1292
+ sink.skipped.push({ path: request.loadedUrl ?? request.url, reason: shortReason(error) });
1293
+ }
1294
+ });
1295
+ try {
1296
+ await crawler.run(seedUrls);
1297
+ } finally {
1298
+ await crawler.teardown().catch(() => {
1299
+ });
1300
+ }
1301
+ }
1302
+ function normalizeSitemapUrls(urls) {
1303
+ const out = [];
1304
+ const seen = /* @__PURE__ */ new Set();
1305
+ for (const raw of urls) {
1306
+ let url;
1307
+ try {
1308
+ url = new URL(raw);
1309
+ } catch {
1310
+ continue;
1311
+ }
1312
+ if (url.protocol !== "http:" && url.protocol !== "https:") continue;
1313
+ url.hash = "";
1314
+ const key = url.toString();
1315
+ if (seen.has(key)) continue;
1316
+ seen.add(key);
1317
+ out.push(key);
1318
+ }
1319
+ return out;
1320
+ }
1321
+ function applyUrlFilter(urls, globs) {
1322
+ if (!globs || globs.length === 0) return urls;
1323
+ const requests = urls.map((url) => new CrawleeRequest({ url }));
1324
+ const patterns = constructGlobObjectsFromGlobs(globs);
1325
+ return filterRequestsByPatterns(requests, patterns).map((r) => r.url);
1326
+ }
1327
+ async function loadSitemap(urls) {
1328
+ try {
1329
+ const sitemap = await Sitemap.load(urls, void 0, { reportNetworkErrors: false });
1330
+ return normalizeSitemapUrls(sitemap.urls);
1331
+ } catch {
1332
+ return [];
1333
+ }
1334
+ }
1335
+ async function discoverSitemapUrls(seedUrl) {
1336
+ const origin = new URL(seedUrl).origin;
1337
+ const fromSitemapXml = await loadSitemap([`${origin}/sitemap.xml`]);
1338
+ if (fromSitemapXml.length > 0) return fromSitemapXml;
1339
+ try {
1340
+ const robots = await RobotsTxtFile.find(seedUrl);
1341
+ const referenced = robots.getSitemaps();
1342
+ if (referenced.length > 0) {
1343
+ const fromRobots = await loadSitemap(referenced);
1344
+ if (fromRobots.length > 0) return fromRobots;
1345
+ }
1346
+ } catch {
1347
+ }
1348
+ return void 0;
1349
+ }
1350
+ async function runCrawl(kind, seedUrls, opts) {
1351
+ if (kind === "playwright" || kind === "puppeteer") {
1352
+ await requireBrowserEngine(kind);
1353
+ const sink2 = { docs: [], skipped: [] };
1354
+ const queue = await freshQueue();
1355
+ await crawlWithBrowser(kind, queue, seedUrls, opts, sink2);
1356
+ return { docs: dedupeDocs(sink2.docs), skipped: sink2.skipped };
1357
+ }
1358
+ const shellUrls = [];
1359
+ const sink = { docs: [], skipped: [], shellUrls };
1360
+ const staticQueue = await freshQueue();
1361
+ await crawlWithCheerio(staticQueue, seedUrls, opts, sink);
1362
+ if (kind !== "auto" || shellUrls.length === 0) {
1363
+ return { docs: dedupeDocs(sink.docs), skipped: sink.skipped };
1364
+ }
1365
+ opts.log(
1366
+ `${shellUrls.length} page(s) look JS-rendered; retrying with playwright (install: npm i -g playwright && npx playwright install chromium) ...`
1367
+ );
1368
+ const mod = await importOptional("playwright");
1369
+ if (!mod) {
1370
+ return { docs: dedupeDocs(sink.docs), skipped: sink.skipped };
1371
+ }
1372
+ const browserSink = { docs: [], skipped: [] };
1373
+ const browserQueue = await freshQueue();
1374
+ await crawlWithBrowser("playwright", browserQueue, shellUrls, opts, browserSink);
1375
+ return {
1376
+ docs: dedupeDocs([...sink.docs, ...browserSink.docs]),
1377
+ skipped: [...sink.skipped, ...browserSink.skipped]
685
1378
  };
686
1379
  }
1380
+ async function crawlWebsite(seed, opts) {
1381
+ const seedUrl = normalizeSeedUrl(seed);
1382
+ const kind = opts.crawler;
1383
+ const mode = opts.sitemap ?? { kind: "auto" };
1384
+ const selectorStats = { hits: 0, misses: 0 };
1385
+ if (mode.kind === "auto" || mode.kind === "sitemap-only") {
1386
+ const sitemapUrls = await discoverSitemapUrls(seedUrl);
1387
+ if (sitemapUrls) {
1388
+ const filtered = applyUrlFilter(sitemapUrls, opts.urlFilter);
1389
+ const detail = filtered.length < sitemapUrls.length ? `${filtered.length} matching --url-filter` : `${filtered.length} URL(s)`;
1390
+ opts.log(`sitemap: ${sitemapUrls.length} URL(s) discovered, ${detail} to crawl`);
1391
+ return runCrawl(kind, filtered, { ...opts, followLinks: false, selectorStats });
1392
+ }
1393
+ if (mode.kind === "sitemap-only") {
1394
+ opts.log(`warning: no sitemap.xml and no sitemaps in robots.txt found for ${seedUrl}; nothing to ingest`);
1395
+ return { docs: [], skipped: [] };
1396
+ }
1397
+ opts.log("no sitemap found; falling back to HTML link crawling");
1398
+ }
1399
+ const result = await runCrawl(kind, [seedUrl], {
1400
+ ...opts,
1401
+ followLinks: true,
1402
+ linkSelector: mode.kind === "selector" ? mode.selector : void 0,
1403
+ selectorStats
1404
+ });
1405
+ if (mode.kind === "selector" && selectorStats.hits === 0 && selectorStats.misses > 0) {
1406
+ opts.log(`warning: --sitemap selector '${mode.selector}' matched no links`);
1407
+ }
1408
+ return result;
1409
+ }
1410
+ async function feedUrl(ks, url, opts = {}) {
1411
+ const log2 = opts.log ?? ((m) => console.log(m));
1412
+ const kind = opts.crawler ?? "auto";
1413
+ if (!CRAWLER_KINDS.includes(kind)) {
1414
+ throw new Error(`unknown crawler '${kind}' (use auto, crawlee, playwright or puppeteer)`);
1415
+ }
1416
+ const maxPages = opts.maxPages && opts.maxPages > 0 ? Math.floor(opts.maxPages) : 999;
1417
+ const depth = typeof opts.depth === "number" && Number.isFinite(opts.depth) && opts.depth >= 0 ? Math.floor(opts.depth) : 5;
1418
+ const maxLen = opts.maxLen ?? 1200;
1419
+ const overlap = opts.overlap ?? 180;
1420
+ const { docs, skipped } = await crawlWebsite(url, {
1421
+ crawler: kind,
1422
+ maxPages,
1423
+ depth,
1424
+ maxLen,
1425
+ overlap,
1426
+ tags: opts.tags,
1427
+ urlFilter: opts.urlFilter,
1428
+ htmlFilter: opts.htmlFilter,
1429
+ sitemap: opts.sitemap,
1430
+ log: log2
1431
+ });
1432
+ for (const s of skipped) {
1433
+ log2(`skipped ${s.path}: ${s.reason}`);
1434
+ }
1435
+ const progress = opts.progress ?? new FeedProgress({ total: docs.length, rejected: skipped.length });
1436
+ const summary = await feedDocs(ks, docs, {
1437
+ replace: opts.replace,
1438
+ dryRun: opts.dryRun,
1439
+ batchSize: opts.batchSize,
1440
+ tags: opts.tags,
1441
+ metadata: opts.metadata,
1442
+ log: log2,
1443
+ progress
1444
+ });
1445
+ return { ...summary, skippedFiles: [...summary.skippedFiles, ...skipped] };
1446
+ }
1447
+ try {
1448
+ if (typeof log?.setLevel === "function" && log.LEVELS) {
1449
+ log.setLevel(log.LEVELS.ERROR);
1450
+ }
1451
+ } catch {
1452
+ }
687
1453
 
688
1454
  // src/check.ts
689
1455
  init_config();
@@ -754,7 +1520,7 @@ async function checkOneModel(id, model, baseUrl, names, ks, probe) {
754
1520
  }
755
1521
  return { id, status: "ok", detail: `model '${model}' available at ${baseUrl}${scope}` };
756
1522
  }
757
- async function runChecks(opts = {}) {
1523
+ async function runChecks(opts) {
758
1524
  const results = [];
759
1525
  const major = Number(process.versions.node.split(".")[0]);
760
1526
  results.push({
@@ -876,44 +1642,35 @@ async function runChecks(opts = {}) {
876
1642
  results.push(
877
1643
  "error" in defaultTags ? { id: "ollama", status: "fail", detail: `Ollama not reachable at ${defaultUrl} \u2014 ${defaultTags.error}` } : { id: "ollama", status: "ok", detail: `Ollama reachable at ${defaultUrl} (${defaultTags.names.length} model(s))` }
878
1644
  );
879
- if (opts.model) {
880
- const tags = await getTags(defaultUrl);
881
- if ("error" in tags) {
882
- results.push({
883
- id: "model",
884
- status: "skip",
885
- detail: `Ollama unreachable at ${defaultUrl} \u2014 cannot verify model '${opts.model}'`
886
- });
887
- } else {
888
- results.push(await checkOneModel("model", opts.model, defaultUrl, tags.names, void 0, opts.probe ?? false));
889
- }
1645
+ const tags = await getTags(defaultUrl);
1646
+ if ("error" in tags) {
1647
+ results.push({
1648
+ id: "model",
1649
+ status: "skip",
1650
+ detail: `Ollama unreachable at ${defaultUrl} \u2014 cannot verify model '${opts.model}'`
1651
+ });
1652
+ } else {
1653
+ results.push(await checkOneModel("model", opts.model, defaultUrl, tags.names, void 0, opts.probe ?? false));
890
1654
  }
891
1655
  for (const ks of kss) {
892
- const tags = await getTags(ks.ollama_url);
893
- if ("error" in tags) {
1656
+ const tags2 = await getTags(ks.ollama_url);
1657
+ if ("error" in tags2) {
894
1658
  results.push({
895
1659
  id: `model:${ks.name}`,
896
1660
  status: "fail",
897
- detail: `Ollama not reachable at ${ks.ollama_url} (required by ks '${ks.name}') \u2014 ${tags.error}`
1661
+ detail: `Ollama not reachable at ${ks.ollama_url} (required by ks '${ks.name}') \u2014 ${tags2.error}`
898
1662
  });
899
1663
  continue;
900
1664
  }
901
1665
  results.push(
902
- await checkOneModel(`model:${ks.name}`, ks.embedding_model, ks.ollama_url, tags.names, ks, opts.probe ?? false)
1666
+ await checkOneModel(`model:${ks.name}`, ks.embedding_model, ks.ollama_url, tags2.names, ks, opts.probe ?? false)
903
1667
  );
904
1668
  }
905
- if (!opts.model && kss.length === 0) {
906
- results.push({
907
- id: "model",
908
- status: "warn",
909
- detail: "no embedding model to verify \u2014 pass --model <model> or create a knowledge source"
910
- });
911
- }
912
1669
  return results;
913
1670
  }
914
1671
 
915
1672
  // src/version.ts
916
- var VERSION = "1.0.0";
1673
+ var VERSION = "1.2.0";
917
1674
 
918
1675
  // src/cli.ts
919
1676
  async function run(fn) {
@@ -954,6 +1711,11 @@ function parseTags(tags) {
954
1711
  const list = tags.split(",").map((t) => t.trim()).filter(Boolean);
955
1712
  return list.length > 0 ? list : void 0;
956
1713
  }
1714
+ function parseGlobs(globs) {
1715
+ if (!globs || globs.length === 0) return void 0;
1716
+ const list = [...new Set(globs.map((g) => g.trim()).filter(Boolean))];
1717
+ return list.length > 0 ? list : void 0;
1718
+ }
957
1719
  var program = new Command();
958
1720
  program.name("gresmcp").description("Manage Postgres-backed knowledge sources for the gresmcp MCP server").version(VERSION);
959
1721
  program.command("init").description("Initialize the database schema (extension, ks table; repairs entry tables)").action(
@@ -966,7 +1728,7 @@ program.command("init").description("Initialize the database schema (extension,
966
1728
  console.log("database ready");
967
1729
  })
968
1730
  );
969
- program.command("check").description("Verify environment requirements (Node, Postgres, pgvector, schema, Ollama models)").option("--model <model>", "Verify this Ollama model is available (e.g. nomic-embed-text)").option("--url <url>", "Ollama base URL (default http://localhost:11434)").option("--probe", "Embed a test string with each model to verify dimensions (slower)").option("--json", "Output as JSON").action(
1731
+ program.command("check").description("Verify environment requirements (Node, Postgres, pgvector, schema, Ollama models)").requiredOption("--model <model>", "Verify this Ollama model is available (e.g. nomic-embed-text)").option("--url <url>", "Ollama base URL (default http://localhost:11434)").option("--probe", "Embed a test string with each model to verify dimensions (slower)").option("--json", "Output as JSON").action(
970
1732
  (opts) => run(async () => {
971
1733
  const results = await runChecks({ model: opts.model, url: opts.url, probe: opts.probe });
972
1734
  if (opts.json) {
@@ -1044,6 +1806,34 @@ ksCmd.command("list").description("List knowledge sources").option("--json", "Ou
1044
1806
  }
1045
1807
  })
1046
1808
  );
1809
+ ksCmd.command("stats <name>").description("Show statistics for a knowledge source (entries, sources, content volume)").option("--json", "Output as JSON").action(
1810
+ (name, opts) => run(async () => {
1811
+ await initDb();
1812
+ const ks = requireKs(await getKs(name), name);
1813
+ const stats = await ksStats(ks);
1814
+ if (opts.json) {
1815
+ console.log(JSON.stringify(stats, null, 2));
1816
+ return;
1817
+ }
1818
+ console.log(
1819
+ `Knowledge source '${stats.ks.name}' (model=${stats.ks.embedding_model}@${stats.ks.ollama_url}, dim=${stats.ks.embedding_dim})`
1820
+ );
1821
+ if (stats.ks.description) console.log(` description: ${stats.ks.description}`);
1822
+ console.log(` created: ${stats.ks.created_at.toISOString()}`);
1823
+ console.log(` last fed: ${stats.last_fed_at ? stats.last_fed_at.toISOString() : "never"}`);
1824
+ console.log("");
1825
+ if (stats.entries === 0) {
1826
+ console.log(` no entries yet; feed it with: gresmcp feed ${stats.ks.name}`);
1827
+ return;
1828
+ }
1829
+ const size = (n) => n === null ? "-" : n.toLocaleString("en-US");
1830
+ console.log(` entries (chunks): ${stats.entries.toLocaleString("en-US")}`);
1831
+ console.log(` distinct sources: ${stats.sources.toLocaleString("en-US")}`);
1832
+ console.log(
1833
+ ` content: ${stats.chars.toLocaleString("en-US")} chars (avg ${size(stats.avg_chunk_chars)}, min ${size(stats.min_chunk_chars)}, max ${size(stats.max_chunk_chars)} per chunk)`
1834
+ );
1835
+ })
1836
+ );
1047
1837
  ksCmd.command("delete <name>").description("Delete a knowledge source and all of its entries").option("--yes", "Do not ask for confirmation").action(
1048
1838
  (name, opts) => run(async () => {
1049
1839
  await initDb();
@@ -1060,30 +1850,61 @@ ksCmd.command("delete <name>").description("Delete a knowledge source and all of
1060
1850
  console.log(`Deleted knowledge source '${name}'`);
1061
1851
  })
1062
1852
  );
1063
- program.command("feed <ks>").description("Feed data into a knowledge source (from a file/folder via --path, or inline via --text/--stdin)").option("--path <path>", "File or folder to ingest (text, markdown, code, html)").option("--text <text>", "Inline text entry").option("--stdin", "Read entry text from stdin").option("--title <title>", "Title for manual entries").option("--source-name <name>", "Source name for manual entries (default 'manual')").option("--tags <tags>", "Comma-separated tags").option("--metadata <key=value...>", "Custom metadata entries", ((v, prev) => [...prev, v]), []).option("--url <url>", "Ollama base URL override for this run").option("--replace", "Replace existing chunks of the same source instead of deduplicating").option("--dry-run", "Parse and chunk only; do not embed or write").option("--chunk-size <n>", "Max chunk length in characters", parseInt).option("--overlap <n>", "Chunk overlap in characters", parseInt).action(
1853
+ program.command("feed <ks>").description("Feed data into a knowledge source (from a file/folder via --path, a website via --url, or inline via --text/--stdin)").option("--path <path>", "File or folder to ingest (text, markdown, code, html)").option("--url <url>", "Website URL to scrape and ingest (crawls same-host pages linked from the seed)").option("--crawler <crawler>", "Crawler backend for --url: auto, crawlee, playwright, puppeteer (default auto; playwright/puppeteer must be installed globally)", ((v) => v.toLowerCase())).option("--max-pages <n>", "Max pages to fetch when crawling --url (default 999)", parseInt).option("--depth <n>", "Max link depth from the seed URL (default 5; 0 = seed page only)", parseInt).option(
1854
+ "--url-filter <glob>",
1855
+ "Only follow --url links whose URL matches this glob (repeatable; a link is followed if it matches any glob, e.g. '**/docs/**')",
1856
+ ((v, prev) => [...prev, v]),
1857
+ []
1858
+ ).option(
1859
+ "--html-filter <selector>",
1860
+ "CSS selector for HTML content \u2014 any DOM selector works (e.g. 'article.post', '#article .content:first-child'); optionally chain steps with ' -> ' (e.g. '#article -> .content'); applies to --path HTML files and --url pages"
1861
+ ).option(
1862
+ "--sitemap <mode>",
1863
+ "Link discovery for --url: 'auto' (default; sitemap.xml, then robots.txt sitemaps, else follow links), 'sitemap-only' (only sitemap.xml / robots.txt sitemaps), 'html-only' (follow links), or a CSS selector to only follow links inside it (e.g. 'nav.docs a')"
1864
+ ).option("--text <text>", "Inline text entry").option("--stdin", "Read entry text from stdin").option("--title <title>", "Title for manual entries").option("--source-name <name>", "Source name for manual entries (default 'manual')").option("--tags <tags>", "Comma-separated tags").option("--metadata <key=value...>", "Custom metadata entries", ((v, prev) => [...prev, v]), []).option("--replace", "Replace existing chunks of the same source instead of deduplicating").option("--dry-run", "Parse and chunk only; do not embed or write").option("--chunk-size <n>", "Max chunk length in characters", parseInt).option("--overlap <n>", "Chunk overlap in characters", parseInt).action(
1064
1865
  (name, opts) => run(async () => {
1065
1866
  await initDb();
1066
1867
  const ks = requireKs(await getKs(name), name);
1067
- const modes = [opts.path, opts.text, opts.stdin].filter(Boolean).length;
1068
- if (modes !== 1) throw new Error("provide exactly one of --path, --text or --stdin");
1868
+ const modes = [opts.path, opts.url, opts.text, opts.stdin].filter(Boolean).length;
1869
+ if (modes !== 1) throw new Error("provide exactly one of --path, --url, --text or --stdin");
1870
+ const urlFilter = parseGlobs(opts.urlFilter);
1871
+ if (urlFilter && !opts.url) {
1872
+ console.error("warning: --url-filter has no effect without --url");
1873
+ }
1874
+ if (opts.htmlFilter) parseHtmlFilter(opts.htmlFilter);
1875
+ if (opts.htmlFilter && !opts.path && !opts.url) {
1876
+ console.error("warning: --html-filter has no effect without --path or --url");
1877
+ }
1878
+ let sitemap;
1879
+ if (opts.sitemap !== void 0) {
1880
+ sitemap = parseSitemapOption(opts.sitemap);
1881
+ if (sitemap.kind === "selector") validateCssSelector(sitemap.selector);
1882
+ if (!opts.url) console.error("warning: --sitemap has no effect without --url");
1883
+ }
1069
1884
  const common = {
1070
- title: opts.title,
1071
- url: opts.url,
1072
1885
  replace: opts.replace,
1073
1886
  dryRun: opts.dryRun,
1074
1887
  batchSize: 32,
1075
1888
  maxLen: opts.chunkSize && opts.chunkSize > 0 ? opts.chunkSize : void 0,
1076
1889
  overlap: opts.overlap,
1077
1890
  tags: parseTags(opts.tags),
1078
- metadata: parseKeyValue(opts.metadata ?? [])
1891
+ metadata: parseKeyValue(opts.metadata ?? []),
1892
+ htmlFilter: opts.htmlFilter
1079
1893
  };
1080
1894
  let summary;
1895
+ const since = await dbClockTimestamp();
1081
1896
  if (opts.path) {
1082
1897
  summary = await feedTarget(ks, opts.path, common);
1898
+ } else if (opts.url) {
1899
+ const crawler = opts.crawler;
1900
+ if (crawler && !CRAWLER_KINDS.includes(crawler)) {
1901
+ throw new Error(`unknown crawler '${crawler}' (use auto, crawlee, playwright or puppeteer)`);
1902
+ }
1903
+ summary = await feedUrl(ks, opts.url, { ...common, crawler, sitemap, maxPages: opts.maxPages, depth: opts.depth, urlFilter });
1083
1904
  } else {
1084
1905
  const text = opts.stdin ? await readStdin() : opts.text;
1085
1906
  if (!text.trim()) throw new Error("empty input text");
1086
- summary = await feedText(ks, text, { ...common, sourceName: opts.sourceName });
1907
+ summary = await feedText(ks, text, { ...common, title: opts.title, sourceName: opts.sourceName });
1087
1908
  }
1088
1909
  if (summary.dryRun) {
1089
1910
  console.log(`dry run: ${summary.documents} document(s), ${summary.chunks} chunk(s), ${summary.skippedFiles.length} file(s) skipped`);
@@ -1091,9 +1912,22 @@ program.command("feed <ks>").description("Feed data into a knowledge source (fro
1091
1912
  console.log(
1092
1913
  `fed '${ks.name}': ${summary.inserted} chunk(s) inserted` + (summary.skippedExisting ? `, ${summary.skippedExisting} unchanged chunk(s) skipped` : "") + (summary.deletedForReplace ? `, ${summary.deletedForReplace} old chunk(s) replaced` : "") + `, ${summary.skippedFiles.length} file(s) skipped`
1093
1914
  );
1915
+ await printRunStats(ks, since);
1094
1916
  }
1095
1917
  })
1096
1918
  );
1919
+ async function printRunStats(ks, since) {
1920
+ const stats = await ksStats(ks, since);
1921
+ if (stats.entries === 0) return;
1922
+ const size = (n) => n === null ? "-" : n.toLocaleString("en-US");
1923
+ console.log("");
1924
+ console.log(
1925
+ `this run: ${stats.entries.toLocaleString("en-US")} chunk(s) across ${stats.sources.toLocaleString("en-US")} source(s)`
1926
+ );
1927
+ console.log(
1928
+ ` content: ${stats.chars.toLocaleString("en-US")} chars (avg ${size(stats.avg_chunk_chars)}, min ${size(stats.min_chunk_chars)}, max ${size(stats.max_chunk_chars)} per chunk)`
1929
+ );
1930
+ }
1097
1931
  async function readStdin() {
1098
1932
  const chunks = [];
1099
1933
  for await (const chunk of stdin) chunks.push(chunk);