@gresmcp/mcp 1.0.0 → 1.1.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) {
@@ -421,10 +461,207 @@ function htmlToMarkdown(html) {
421
461
  markdown = markdown.replace(/\n{3,}/g, "\n\n").trim();
422
462
  return { markdown, title: title || void 0 };
423
463
  }
464
+ function metaContent(doc, selectors) {
465
+ for (const selector of selectors) {
466
+ const el = doc.querySelector(selector);
467
+ const value = (el?.getAttribute("content") ?? el?.textContent ?? "").trim();
468
+ if (value) return value;
469
+ }
470
+ return void 0;
471
+ }
472
+ function collectTags(doc) {
473
+ const tags = [];
474
+ const keywords = metaContent(doc, ['meta[name="keywords"]']);
475
+ if (keywords) {
476
+ for (const part of keywords.split(",")) {
477
+ const tag = part.trim();
478
+ if (tag) tags.push(tag);
479
+ }
480
+ }
481
+ doc.querySelectorAll('meta[property="og:article:tag"]').forEach((el) => {
482
+ const value = (el.getAttribute("content") ?? "").trim();
483
+ if (value) tags.push(value);
484
+ });
485
+ const seen = /* @__PURE__ */ new Set();
486
+ return tags.filter((t) => {
487
+ const key = t.toLowerCase();
488
+ if (seen.has(key)) return false;
489
+ seen.add(key);
490
+ return true;
491
+ });
492
+ }
493
+ function extractPage(html) {
494
+ const dom = new JSDOM(html);
495
+ const doc = dom.window.document;
496
+ const title = doc.title?.trim() || doc.querySelector('meta[property="og:title"]')?.getAttribute("content")?.trim() || doc.querySelector("h1")?.textContent?.trim() || void 0;
497
+ const description = metaContent(doc, [
498
+ 'meta[name="description"]',
499
+ 'meta[property="og:description"]'
500
+ ]);
501
+ const tags = collectTags(doc);
502
+ doc.querySelectorAll(REMOVE_SELECTORS).forEach((el) => el.remove());
503
+ let markdown = "";
504
+ try {
505
+ if (isProbablyReaderable(doc)) {
506
+ const article = new Readability(doc).parse();
507
+ if (article?.content) markdown = turndown().turndown(article.content);
508
+ }
509
+ } catch {
510
+ markdown = "";
511
+ }
512
+ if (!markdown.trim()) {
513
+ const root = doc.body ?? doc.documentElement;
514
+ try {
515
+ markdown = turndown().turndown(root.innerHTML);
516
+ } catch {
517
+ markdown = root.textContent ?? "";
518
+ }
519
+ }
520
+ markdown = markdown.replace(/\n{3,}/g, "\n\n").trim();
521
+ const likelyShell = markdown.length < SHELL_TEXT_THRESHOLD && SHELL_MARKER_RE.test(html);
522
+ return {
523
+ markdown,
524
+ title,
525
+ description,
526
+ tags: tags.length > 0 ? tags : void 0,
527
+ likelyShell
528
+ };
529
+ }
424
530
 
425
531
  // src/ingest.ts
426
532
  init_db();
427
- init_config();
533
+
534
+ // src/progress.ts
535
+ var BAR_WIDTH = 20;
536
+ var PLAIN_STEP = 32;
537
+ function fmt(n) {
538
+ return n.toLocaleString("en-US");
539
+ }
540
+ function bar(pct2) {
541
+ const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round(pct2 * BAR_WIDTH)));
542
+ return "#".repeat(filled) + ".".repeat(BAR_WIDTH - filled);
543
+ }
544
+ function pct(p) {
545
+ return `${Math.round(Math.max(0, Math.min(1, p)) * 100)}%`;
546
+ }
547
+ var FeedProgress = class {
548
+ write;
549
+ interactive;
550
+ total;
551
+ rejected;
552
+ filesDone = 0;
553
+ totalChars = 0;
554
+ current;
555
+ blockLines = 0;
556
+ finished = false;
557
+ constructor(opts) {
558
+ this.total = Math.max(0, Math.floor(opts.total));
559
+ this.rejected = Math.max(0, Math.floor(opts.rejected));
560
+ this.write = opts.out ?? ((s) => process.stderr.write(s));
561
+ this.interactive = opts.interactive ?? (opts.out === void 0 && !!process.stderr.isTTY);
562
+ }
563
+ get isInteractive() {
564
+ return this.interactive;
565
+ }
566
+ startFile(source, totalChunks, totalChars) {
567
+ if (this.finished) return;
568
+ this.current = { source, totalChunks, totalChars, doneChunks: 0, doneChars: 0, printedChunks: 0 };
569
+ this.render();
570
+ }
571
+ handled(chunks, chars) {
572
+ if (this.finished || !this.current || chunks <= 0) return;
573
+ const c = this.current;
574
+ c.doneChunks += chunks;
575
+ c.doneChars += chars;
576
+ this.totalChars += chars;
577
+ if (c.doneChunks >= c.totalChunks) {
578
+ this.filesDone++;
579
+ this.printFileLine();
580
+ return;
581
+ }
582
+ if (!this.interactive && c.doneChunks - c.printedChunks >= PLAIN_STEP) {
583
+ this.printFileLine();
584
+ } else if (this.interactive) {
585
+ this.render();
586
+ }
587
+ }
588
+ log(msg) {
589
+ if (this.finished) return;
590
+ this.clearBlock();
591
+ this.write(`${msg}
592
+ `);
593
+ }
594
+ finish() {
595
+ if (this.finished) return;
596
+ this.finished = true;
597
+ if (!this.current && this.total === 0 && this.rejected === 0) return;
598
+ if (this.interactive) {
599
+ this.redraw([this.fileLine(), this.totalLine()]);
600
+ this.write("\n");
601
+ this.blockLines = 0;
602
+ } else {
603
+ this.write(`${this.totalLine()}
604
+ `);
605
+ }
606
+ }
607
+ close() {
608
+ if (this.finished) return;
609
+ this.finished = true;
610
+ this.clearBlock();
611
+ }
612
+ render() {
613
+ if (!this.interactive) return;
614
+ this.redraw([this.fileLine(), this.totalLine()]);
615
+ }
616
+ printFileLine() {
617
+ if (!this.current) return;
618
+ this.current.printedChunks = this.current.doneChunks;
619
+ if (this.interactive) {
620
+ this.render();
621
+ } else {
622
+ this.write(`${this.fileLine()}
623
+ `);
624
+ }
625
+ }
626
+ fileLine() {
627
+ const c = this.current;
628
+ if (!c) return "";
629
+ const p = c.totalChunks > 0 ? c.doneChunks / c.totalChunks : 1;
630
+ return `[${bar(p)}] ${pct(p).padStart(4)} ${fmt(c.doneChunks)}/${fmt(c.totalChunks)} chunks ${fmt(c.doneChars)}/${fmt(c.totalChars)} chars ${c.source}`;
631
+ }
632
+ totalLine() {
633
+ const p = this.total > 0 ? this.filesDone / this.total : 0;
634
+ const rejected = this.rejected > 0 ? ` | rejected: ${this.rejected} (excluded)` : "";
635
+ return `total: ${this.filesDone}/${this.total} files (${pct(p)}) ${fmt(this.totalChars)} chars${rejected}`;
636
+ }
637
+ redraw(lines) {
638
+ let out = "";
639
+ if (this.blockLines > 1) out += `\x1B[${this.blockLines - 1}A`;
640
+ out += "\r";
641
+ for (let i = 0; i < lines.length; i++) {
642
+ out += `\x1B[2K${lines[i]}${i < lines.length - 1 ? "\n" : ""}`;
643
+ }
644
+ this.write(out);
645
+ this.blockLines = lines.length;
646
+ }
647
+ clearBlock() {
648
+ if (!this.interactive || this.blockLines === 0) {
649
+ this.blockLines = 0;
650
+ return;
651
+ }
652
+ let out = "";
653
+ if (this.blockLines > 1) out += `\x1B[${this.blockLines - 1}A`;
654
+ out += "\r";
655
+ for (let i = 0; i < this.blockLines; i++) {
656
+ out += `\x1B[2K${i < this.blockLines - 1 ? "\n" : ""}`;
657
+ }
658
+ out += "\n";
659
+ this.write(out);
660
+ this.blockLines = 0;
661
+ }
662
+ };
663
+
664
+ // src/ingest.ts
428
665
  var MD_EXT = /* @__PURE__ */ new Set([".md", ".markdown", ".mdx"]);
429
666
  var HTML_EXT = /* @__PURE__ */ new Set([".html", ".htm", ".xhtml"]);
430
667
  var TEXT_EXT = /* @__PURE__ */ new Set([
@@ -575,115 +812,408 @@ async function loadTarget(target, opts) {
575
812
  }
576
813
  return { docs, fileCount: files.length, skipped };
577
814
  }
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;
815
+ async function feedDocs(ks, docs, opts = {}) {
816
+ const log2 = opts.log ?? ((m) => console.log(m));
817
+ const progress = opts.progress;
818
+ const batchSize = opts.batchSize ?? 32;
819
+ const baseMeta = { ...opts.metadata ?? {} };
820
+ const cliTags = opts.tags && opts.tags.length > 0 ? opts.tags : void 0;
821
+ const say = (msg) => progress ? progress.log(msg) : log2(msg);
616
822
  let deletedForReplace = 0;
617
- const pending = [];
618
823
  let skippedExisting = 0;
619
- for (const doc of docs) {
620
- if (opts.replace) {
621
- deletedForReplace += await deleteBySource(ks, doc.source);
622
- }
623
- const existing = await sourceChunkHashes(ks, doc.source);
624
- for (const chunk of doc.chunks) {
625
- if (!opts.replace && existing.get(chunk.index) === contentHash(chunk.content)) {
626
- skippedExisting++;
824
+ let inserted = 0;
825
+ let announcedEmbed = false;
826
+ try {
827
+ for (const doc of docs) {
828
+ progress?.startFile(
829
+ doc.source,
830
+ doc.chunks.length,
831
+ doc.chunks.reduce((n, c) => n + c.content.length, 0)
832
+ );
833
+ if (opts.replace) {
834
+ deletedForReplace += await deleteBySource(ks, doc.source);
835
+ }
836
+ const existing = await sourceChunkHashes(ks, doc.source);
837
+ const meta = { ...baseMeta, ...doc.metadata ?? {} };
838
+ if (!("tags" in meta) && cliTags) meta.tags = cliTags;
839
+ const todo = [];
840
+ for (const chunk of doc.chunks) {
841
+ if (!opts.replace && existing.get(chunk.index) === contentHash(chunk.content)) {
842
+ skippedExisting++;
843
+ progress?.handled(1, chunk.content.length);
844
+ continue;
845
+ }
846
+ todo.push(chunk);
847
+ }
848
+ if (todo.length === 0) continue;
849
+ if (opts.dryRun) {
850
+ for (const chunk of todo) progress?.handled(1, chunk.content.length);
627
851
  continue;
628
852
  }
629
- pending.push({ doc, chunk, metadata: meta });
853
+ if (!announcedEmbed) {
854
+ say(`Embedding with ${ks.embedding_model} at ${ks.ollama_url} ...`);
855
+ announcedEmbed = true;
856
+ }
857
+ const contents = todo.map((c) => c.content);
858
+ const charLens = contents.map((c) => c.length);
859
+ let reported = 0;
860
+ const embeddings = await embedBatch(ks.ollama_url, ks.embedding_model, contents, batchSize, (done) => {
861
+ const delta = done - reported;
862
+ if (delta <= 0) return;
863
+ let chars = 0;
864
+ for (let i = reported; i < done; i++) chars += charLens[i];
865
+ reported = done;
866
+ progress?.handled(delta, chars);
867
+ });
868
+ const rows = todo.map((chunk, i) => ({
869
+ source: doc.source,
870
+ title: doc.title,
871
+ chunk_index: chunk.index,
872
+ content: chunk.content,
873
+ metadata: meta,
874
+ embedding: embeddings[i]
875
+ }));
876
+ inserted += await insertChunks(ks, rows);
630
877
  }
878
+ } catch (err) {
879
+ progress?.close();
880
+ throw err;
631
881
  }
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
- });
882
+ progress?.finish();
638
883
  return {
639
884
  documents: docs.length,
640
885
  chunks: docs.reduce((n, d) => n + d.chunks.length, 0),
641
886
  inserted,
642
887
  skippedExisting,
643
888
  deletedForReplace,
644
- skippedFiles: skipped,
889
+ skippedFiles: [],
645
890
  dryRun: opts.dryRun ?? false
646
891
  };
647
892
  }
893
+ async function feedTarget(ks, target, opts = {}) {
894
+ const loadOpts = { maxLen: opts.maxLen ?? 1200, overlap: opts.overlap ?? 180 };
895
+ const { docs, skipped } = await loadTarget(target, loadOpts);
896
+ for (const s of skipped) {
897
+ (opts.log ?? ((m) => console.log(m)))(`skipped ${s.path}: ${s.reason}`);
898
+ }
899
+ const progress = opts.progress ?? new FeedProgress({ total: docs.length, rejected: skipped.length });
900
+ const summary = await feedDocs(ks, docs, { ...opts, progress });
901
+ return { ...summary, skippedFiles: skipped };
902
+ }
648
903
  async function feedText(ks, text, opts = {}) {
649
- const log = opts.log ?? ((m) => console.log(m));
904
+ const log2 = opts.log ?? ((m) => console.log(m));
650
905
  const source = opts.sourceName?.trim() || "manual";
651
906
  const title = opts.title?.trim() || source;
652
907
  const chunks = chunkMarkdown(text, { maxLen: opts.maxLen ?? 1200, overlap: opts.overlap ?? 180 });
653
908
  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);
909
+ const progress = opts.progress ?? new FeedProgress({ total: 1, rejected: 0 });
910
+ return feedDocs(ks, [doc], { ...opts, progress });
911
+ }
912
+
913
+ // src/scrape.ts
914
+ import { mkdtempSync } from "fs";
915
+ import { tmpdir } from "os";
916
+ import path2 from "path";
917
+ import { randomUUID } from "crypto";
918
+ import { CheerioCrawler, PlaywrightCrawler, PuppeteerCrawler, RequestQueue, log } from "crawlee";
919
+ var CRAWLER_KINDS = ["auto", "crawlee", "playwright", "puppeteer"];
920
+ 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;
921
+ process.env.CRAWLEE_STORAGE_DIR ??= mkdtempSync(path2.join(tmpdir(), "gresmcp-crawl-"));
922
+ async function freshQueue() {
923
+ return RequestQueue.open(`gresmcp-${randomUUID()}`);
924
+ }
925
+ function normalizeSeedUrl(seed) {
926
+ let url;
927
+ try {
928
+ url = new URL(seed.trim());
929
+ } catch {
930
+ throw new Error(`invalid URL '${seed}'`);
662
931
  }
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;
932
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
933
+ throw new Error(`unsupported URL protocol '${url.protocol.replace(":", "")}' (use http or https)`);
934
+ }
935
+ url.hash = "";
936
+ return url.toString();
937
+ }
938
+ function responseStatus(response) {
939
+ if (!response || typeof response !== "object") return void 0;
940
+ const res = response;
941
+ if (typeof res.status === "number") return res.status;
942
+ if (typeof res.status === "function") {
943
+ const value = res.status();
944
+ return typeof value === "number" ? value : void 0;
945
+ }
946
+ if (typeof res.statusCode === "number") return res.statusCode;
947
+ return void 0;
948
+ }
949
+ function toText(body) {
950
+ return typeof body === "string" ? body : body.toString("utf-8");
951
+ }
952
+ function titleFor(url, fallback) {
953
+ const trimmed = fallback?.trim();
954
+ if (trimmed) return trimmed;
955
+ try {
956
+ const segment = new URL(url).pathname.split("/").filter(Boolean).pop();
957
+ const name = segment ? decodeURIComponent(segment) : "";
958
+ return name || url;
959
+ } catch {
960
+ return url;
961
+ }
962
+ }
963
+ function unionTags(...lists) {
964
+ const out = [];
965
+ const seen = /* @__PURE__ */ new Set();
966
+ for (const list of lists) {
967
+ for (const tag of list ?? []) {
968
+ const value = tag.trim();
969
+ if (!value) continue;
970
+ const key = value.toLowerCase();
971
+ if (seen.has(key)) continue;
972
+ seen.add(key);
973
+ out.push(value);
974
+ }
975
+ }
976
+ return out.length > 0 ? out : void 0;
977
+ }
978
+ function buildDoc(url, kind, text, page, deps) {
979
+ const chunkOpts = { maxLen: deps.maxLen, overlap: deps.overlap };
980
+ const chunks = kind === "plain" ? chunkPlain(text, chunkOpts) : chunkMarkdown(text, chunkOpts);
981
+ if (chunks.length === 0) return void 0;
982
+ const title = kind === "html" ? titleFor(url, page?.title) : kind === "md" ? mdTitle(text) ?? titleFor(url) : titleFor(url);
983
+ const metadata = { source_url: url, crawled_at: (/* @__PURE__ */ new Date()).toISOString() };
984
+ if (page?.description) metadata.description = page.description;
985
+ const tags = unionTags(page?.tags, deps.cliTags);
986
+ if (tags) metadata.tags = tags;
987
+ return { source: url, title, chunks, metadata };
988
+ }
989
+ function dedupeDocs(docs) {
990
+ const bySource = /* @__PURE__ */ new Map();
991
+ for (const doc of docs) {
992
+ if (!bySource.has(doc.source)) bySource.set(doc.source, doc);
993
+ }
994
+ return [...bySource.values()];
995
+ }
996
+ function shortReason(err) {
997
+ const message = (err?.message ?? String(err)).replace(/\s+/g, " ").trim();
998
+ const contentType = /served Content-Type ([^,\s]+),/.exec(message);
999
+ if (contentType) return `unsupported content-type '${contentType[1]}'`;
1000
+ return message.slice(0, 200);
1001
+ }
1002
+ async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1003
+ const crawler = new CheerioCrawler({
1004
+ requestQueue: queue,
1005
+ maxRequestsPerCrawl: Math.max(1, opts.maxPages),
1006
+ maxCrawlDepth: Math.max(0, opts.depth),
1007
+ additionalMimeTypes: ["text/plain", "text/markdown"],
1008
+ async requestHandler(ctx) {
1009
+ const { request, response, contentType, body } = ctx;
1010
+ const url = request.loadedUrl ?? request.url;
1011
+ const status = responseStatus(response);
1012
+ if (status === void 0 || status < 200 || status >= 300) {
1013
+ sink.skipped.push({ path: url, reason: status === void 0 ? "no HTTP response" : `HTTP ${status}` });
1014
+ return;
1015
+ }
1016
+ const type = contentType?.type ?? "";
1017
+ if (type === "text/html" || type === "application/xhtml+xml") {
1018
+ const html = typeof body === "string" ? body : toText(body);
1019
+ const page = extractPage(html);
1020
+ if (page.likelyShell) {
1021
+ sink.shellUrls?.push(url);
1022
+ sink.skipped.push({ path: url, reason: "likely JS-rendered shell" });
1023
+ return;
1024
+ }
1025
+ if (!page.markdown.trim()) {
1026
+ sink.skipped.push({ path: url, reason: "empty" });
1027
+ return;
1028
+ }
1029
+ const doc = buildDoc(url, "html", page.markdown, page, sinkDeps(opts));
1030
+ if (!doc) {
1031
+ sink.skipped.push({ path: url, reason: "no chunks produced" });
1032
+ return;
1033
+ }
1034
+ sink.docs.push(doc);
1035
+ await ctx.enqueueLinks({ strategy: "same-hostname", exclude: [BINARY_LINK_RE] });
1036
+ } else if (type === "text/markdown") {
1037
+ const doc = buildDoc(url, "md", toText(body), void 0, sinkDeps(opts));
1038
+ if (!doc) {
1039
+ sink.skipped.push({ path: url, reason: "no chunks produced" });
1040
+ return;
1041
+ }
1042
+ sink.docs.push(doc);
1043
+ } else if (type === "text/plain") {
1044
+ const doc = buildDoc(url, "plain", toText(body), void 0, sinkDeps(opts));
1045
+ if (!doc) {
1046
+ sink.skipped.push({ path: url, reason: "no chunks produced" });
1047
+ return;
1048
+ }
1049
+ sink.docs.push(doc);
1050
+ } else {
1051
+ sink.skipped.push({ path: url, reason: `unsupported content-type '${type}'` });
1052
+ }
1053
+ },
1054
+ async failedRequestHandler(ctx, error) {
1055
+ const url = ctx.request.loadedUrl ?? ctx.request.url;
1056
+ sink.skipped.push({ path: url, reason: shortReason(error) });
668
1057
  }
669
- pending.push({ doc, chunk, metadata: meta });
1058
+ });
1059
+ try {
1060
+ await crawler.run(seedUrls);
1061
+ } finally {
1062
+ await crawler.teardown().catch(() => {
1063
+ });
670
1064
  }
671
- const { inserted } = await embedAndInsert(ks, pending, {
672
- url: ollamaUrl(opts.url),
673
- dryRun: opts.dryRun ?? false,
674
- batchSize: opts.batchSize ?? 32,
675
- log
1065
+ }
1066
+ function sinkDeps(opts) {
1067
+ return { maxLen: opts.maxLen, overlap: opts.overlap, cliTags: opts.tags };
1068
+ }
1069
+ async function importOptional(name) {
1070
+ try {
1071
+ return await import(name);
1072
+ } catch {
1073
+ return void 0;
1074
+ }
1075
+ }
1076
+ async function requireBrowserEngine(kind) {
1077
+ const moduleName = kind === "playwright" ? "playwright" : "puppeteer";
1078
+ const mod = await importOptional(moduleName);
1079
+ if (!mod) {
1080
+ const install = kind === "playwright" ? "npm i -g playwright && npx playwright install chromium" : "npm i -g puppeteer";
1081
+ throw new Error(`--crawler ${kind} requires ${moduleName}, which is not installed (install it with: ${install})`);
1082
+ }
1083
+ }
1084
+ async function handleBrowserPage(ctx, deps) {
1085
+ const request = ctx.request;
1086
+ const url = request.loadedUrl ?? request.url;
1087
+ const status = responseStatus(ctx.response);
1088
+ if (status === void 0 || status < 200 || status >= 300) {
1089
+ deps.sink.skipped.push({ path: url, reason: status === void 0 ? "no HTTP response" : `HTTP ${status}` });
1090
+ return;
1091
+ }
1092
+ const html = await ctx.page.content();
1093
+ const page = extractPage(html);
1094
+ if (!page.markdown.trim()) {
1095
+ deps.sink.skipped.push({ path: url, reason: "empty after render" });
1096
+ return;
1097
+ }
1098
+ const doc = buildDoc(url, "html", page.markdown, page, deps);
1099
+ if (!doc) {
1100
+ deps.sink.skipped.push({ path: url, reason: "no chunks produced" });
1101
+ return;
1102
+ }
1103
+ deps.sink.docs.push(doc);
1104
+ await ctx.enqueueLinks({ strategy: "same-hostname", exclude: [BINARY_LINK_RE] });
1105
+ }
1106
+ async function crawlWithBrowser(kind, queue, seedUrls, opts, sink) {
1107
+ const crawler = kind === "playwright" ? new PlaywrightCrawler({
1108
+ requestQueue: queue,
1109
+ maxRequestsPerCrawl: Math.max(1, opts.maxPages),
1110
+ maxCrawlDepth: Math.max(0, opts.depth),
1111
+ preNavigationHooks: [
1112
+ (_ctx, gotoOptions) => {
1113
+ gotoOptions.waitUntil = "networkidle";
1114
+ gotoOptions.timeout = 3e4;
1115
+ }
1116
+ ],
1117
+ requestHandler: (ctx) => handleBrowserPage(ctx, { ...sinkDeps(opts), sink }),
1118
+ failedRequestHandler: (ctx, error) => {
1119
+ const request = ctx.request;
1120
+ sink.skipped.push({ path: request.loadedUrl ?? request.url, reason: shortReason(error) });
1121
+ }
1122
+ }) : new PuppeteerCrawler({
1123
+ requestQueue: queue,
1124
+ maxRequestsPerCrawl: Math.max(1, opts.maxPages),
1125
+ maxCrawlDepth: Math.max(0, opts.depth),
1126
+ preNavigationHooks: [
1127
+ (_ctx, gotoOptions) => {
1128
+ gotoOptions.waitUntil = "networkidle2";
1129
+ gotoOptions.timeout = 3e4;
1130
+ }
1131
+ ],
1132
+ requestHandler: (ctx) => handleBrowserPage(ctx, { ...sinkDeps(opts), sink }),
1133
+ failedRequestHandler: (ctx, error) => {
1134
+ const request = ctx.request;
1135
+ sink.skipped.push({ path: request.loadedUrl ?? request.url, reason: shortReason(error) });
1136
+ }
676
1137
  });
1138
+ try {
1139
+ await crawler.run(seedUrls);
1140
+ } finally {
1141
+ await crawler.teardown().catch(() => {
1142
+ });
1143
+ }
1144
+ }
1145
+ async function crawlWebsite(seed, opts) {
1146
+ const seedUrl = normalizeSeedUrl(seed);
1147
+ const kind = opts.crawler;
1148
+ if (kind === "playwright" || kind === "puppeteer") {
1149
+ await requireBrowserEngine(kind);
1150
+ const sink2 = { docs: [], skipped: [] };
1151
+ const queue = await freshQueue();
1152
+ await crawlWithBrowser(kind, queue, [seedUrl], opts, sink2);
1153
+ return { docs: dedupeDocs(sink2.docs), skipped: sink2.skipped };
1154
+ }
1155
+ const shellUrls = [];
1156
+ const sink = { docs: [], skipped: [], shellUrls };
1157
+ const staticQueue = await freshQueue();
1158
+ await crawlWithCheerio(staticQueue, [seedUrl], opts, sink);
1159
+ if (kind !== "auto" || shellUrls.length === 0) {
1160
+ return { docs: dedupeDocs(sink.docs), skipped: sink.skipped };
1161
+ }
1162
+ opts.log(
1163
+ `${shellUrls.length} page(s) look JS-rendered; retrying with playwright (install: npm i -g playwright && npx playwright install chromium) ...`
1164
+ );
1165
+ const mod = await importOptional("playwright");
1166
+ if (!mod) {
1167
+ return { docs: dedupeDocs(sink.docs), skipped: sink.skipped };
1168
+ }
1169
+ const browserSink = { docs: [], skipped: [] };
1170
+ const browserQueue = await freshQueue();
1171
+ await crawlWithBrowser("playwright", browserQueue, shellUrls, opts, browserSink);
677
1172
  return {
678
- documents: 1,
679
- chunks: chunks.length,
680
- inserted,
681
- skippedExisting,
682
- deletedForReplace,
683
- skippedFiles: [],
684
- dryRun: opts.dryRun ?? false
1173
+ docs: dedupeDocs([...sink.docs, ...browserSink.docs]),
1174
+ skipped: [...sink.skipped, ...browserSink.skipped]
685
1175
  };
686
1176
  }
1177
+ async function feedUrl(ks, url, opts = {}) {
1178
+ const log2 = opts.log ?? ((m) => console.log(m));
1179
+ const kind = opts.crawler ?? "auto";
1180
+ if (!CRAWLER_KINDS.includes(kind)) {
1181
+ throw new Error(`unknown crawler '${kind}' (use auto, crawlee, playwright or puppeteer)`);
1182
+ }
1183
+ const maxPages = opts.maxPages && opts.maxPages > 0 ? Math.floor(opts.maxPages) : 999;
1184
+ const depth = typeof opts.depth === "number" && Number.isFinite(opts.depth) && opts.depth >= 0 ? Math.floor(opts.depth) : 5;
1185
+ const maxLen = opts.maxLen ?? 1200;
1186
+ const overlap = opts.overlap ?? 180;
1187
+ const { docs, skipped } = await crawlWebsite(url, {
1188
+ crawler: kind,
1189
+ maxPages,
1190
+ depth,
1191
+ maxLen,
1192
+ overlap,
1193
+ tags: opts.tags,
1194
+ log: log2
1195
+ });
1196
+ for (const s of skipped) {
1197
+ log2(`skipped ${s.path}: ${s.reason}`);
1198
+ }
1199
+ const progress = opts.progress ?? new FeedProgress({ total: docs.length, rejected: skipped.length });
1200
+ const summary = await feedDocs(ks, docs, {
1201
+ replace: opts.replace,
1202
+ dryRun: opts.dryRun,
1203
+ batchSize: opts.batchSize,
1204
+ tags: opts.tags,
1205
+ metadata: opts.metadata,
1206
+ log: log2,
1207
+ progress
1208
+ });
1209
+ return { ...summary, skippedFiles: [...summary.skippedFiles, ...skipped] };
1210
+ }
1211
+ try {
1212
+ if (typeof log?.setLevel === "function" && log.LEVELS) {
1213
+ log.setLevel(log.LEVELS.ERROR);
1214
+ }
1215
+ } catch {
1216
+ }
687
1217
 
688
1218
  // src/check.ts
689
1219
  init_config();
@@ -754,7 +1284,7 @@ async function checkOneModel(id, model, baseUrl, names, ks, probe) {
754
1284
  }
755
1285
  return { id, status: "ok", detail: `model '${model}' available at ${baseUrl}${scope}` };
756
1286
  }
757
- async function runChecks(opts = {}) {
1287
+ async function runChecks(opts) {
758
1288
  const results = [];
759
1289
  const major = Number(process.versions.node.split(".")[0]);
760
1290
  results.push({
@@ -876,44 +1406,35 @@ async function runChecks(opts = {}) {
876
1406
  results.push(
877
1407
  "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
1408
  );
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
- }
1409
+ const tags = await getTags(defaultUrl);
1410
+ if ("error" in tags) {
1411
+ results.push({
1412
+ id: "model",
1413
+ status: "skip",
1414
+ detail: `Ollama unreachable at ${defaultUrl} \u2014 cannot verify model '${opts.model}'`
1415
+ });
1416
+ } else {
1417
+ results.push(await checkOneModel("model", opts.model, defaultUrl, tags.names, void 0, opts.probe ?? false));
890
1418
  }
891
1419
  for (const ks of kss) {
892
- const tags = await getTags(ks.ollama_url);
893
- if ("error" in tags) {
1420
+ const tags2 = await getTags(ks.ollama_url);
1421
+ if ("error" in tags2) {
894
1422
  results.push({
895
1423
  id: `model:${ks.name}`,
896
1424
  status: "fail",
897
- detail: `Ollama not reachable at ${ks.ollama_url} (required by ks '${ks.name}') \u2014 ${tags.error}`
1425
+ detail: `Ollama not reachable at ${ks.ollama_url} (required by ks '${ks.name}') \u2014 ${tags2.error}`
898
1426
  });
899
1427
  continue;
900
1428
  }
901
1429
  results.push(
902
- await checkOneModel(`model:${ks.name}`, ks.embedding_model, ks.ollama_url, tags.names, ks, opts.probe ?? false)
1430
+ await checkOneModel(`model:${ks.name}`, ks.embedding_model, ks.ollama_url, tags2.names, ks, opts.probe ?? false)
903
1431
  );
904
1432
  }
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
1433
  return results;
913
1434
  }
914
1435
 
915
1436
  // src/version.ts
916
- var VERSION = "1.0.0";
1437
+ var VERSION = "1.1.0";
917
1438
 
918
1439
  // src/cli.ts
919
1440
  async function run(fn) {
@@ -966,7 +1487,7 @@ program.command("init").description("Initialize the database schema (extension,
966
1487
  console.log("database ready");
967
1488
  })
968
1489
  );
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(
1490
+ 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
1491
  (opts) => run(async () => {
971
1492
  const results = await runChecks({ model: opts.model, url: opts.url, probe: opts.probe });
972
1493
  if (opts.json) {
@@ -1044,6 +1565,34 @@ ksCmd.command("list").description("List knowledge sources").option("--json", "Ou
1044
1565
  }
1045
1566
  })
1046
1567
  );
1568
+ ksCmd.command("stats <name>").description("Show statistics for a knowledge source (entries, sources, content volume)").option("--json", "Output as JSON").action(
1569
+ (name, opts) => run(async () => {
1570
+ await initDb();
1571
+ const ks = requireKs(await getKs(name), name);
1572
+ const stats = await ksStats(ks);
1573
+ if (opts.json) {
1574
+ console.log(JSON.stringify(stats, null, 2));
1575
+ return;
1576
+ }
1577
+ console.log(
1578
+ `Knowledge source '${stats.ks.name}' (model=${stats.ks.embedding_model}@${stats.ks.ollama_url}, dim=${stats.ks.embedding_dim})`
1579
+ );
1580
+ if (stats.ks.description) console.log(` description: ${stats.ks.description}`);
1581
+ console.log(` created: ${stats.ks.created_at.toISOString()}`);
1582
+ console.log(` last fed: ${stats.last_fed_at ? stats.last_fed_at.toISOString() : "never"}`);
1583
+ console.log("");
1584
+ if (stats.entries === 0) {
1585
+ console.log(` no entries yet; feed it with: gresmcp feed ${stats.ks.name}`);
1586
+ return;
1587
+ }
1588
+ const size = (n) => n === null ? "-" : n.toLocaleString("en-US");
1589
+ console.log(` entries (chunks): ${stats.entries.toLocaleString("en-US")}`);
1590
+ console.log(` distinct sources: ${stats.sources.toLocaleString("en-US")}`);
1591
+ console.log(
1592
+ ` 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)`
1593
+ );
1594
+ })
1595
+ );
1047
1596
  ksCmd.command("delete <name>").description("Delete a knowledge source and all of its entries").option("--yes", "Do not ask for confirmation").action(
1048
1597
  (name, opts) => run(async () => {
1049
1598
  await initDb();
@@ -1060,15 +1609,13 @@ ksCmd.command("delete <name>").description("Delete a knowledge source and all of
1060
1609
  console.log(`Deleted knowledge source '${name}'`);
1061
1610
  })
1062
1611
  );
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(
1612
+ 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("--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
1613
  (name, opts) => run(async () => {
1065
1614
  await initDb();
1066
1615
  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");
1616
+ const modes = [opts.path, opts.url, opts.text, opts.stdin].filter(Boolean).length;
1617
+ if (modes !== 1) throw new Error("provide exactly one of --path, --url, --text or --stdin");
1069
1618
  const common = {
1070
- title: opts.title,
1071
- url: opts.url,
1072
1619
  replace: opts.replace,
1073
1620
  dryRun: opts.dryRun,
1074
1621
  batchSize: 32,
@@ -1078,12 +1625,19 @@ program.command("feed <ks>").description("Feed data into a knowledge source (fro
1078
1625
  metadata: parseKeyValue(opts.metadata ?? [])
1079
1626
  };
1080
1627
  let summary;
1628
+ const since = await dbClockTimestamp();
1081
1629
  if (opts.path) {
1082
1630
  summary = await feedTarget(ks, opts.path, common);
1631
+ } else if (opts.url) {
1632
+ const crawler = opts.crawler;
1633
+ if (crawler && !CRAWLER_KINDS.includes(crawler)) {
1634
+ throw new Error(`unknown crawler '${crawler}' (use auto, crawlee, playwright or puppeteer)`);
1635
+ }
1636
+ summary = await feedUrl(ks, opts.url, { ...common, crawler, maxPages: opts.maxPages, depth: opts.depth });
1083
1637
  } else {
1084
1638
  const text = opts.stdin ? await readStdin() : opts.text;
1085
1639
  if (!text.trim()) throw new Error("empty input text");
1086
- summary = await feedText(ks, text, { ...common, sourceName: opts.sourceName });
1640
+ summary = await feedText(ks, text, { ...common, title: opts.title, sourceName: opts.sourceName });
1087
1641
  }
1088
1642
  if (summary.dryRun) {
1089
1643
  console.log(`dry run: ${summary.documents} document(s), ${summary.chunks} chunk(s), ${summary.skippedFiles.length} file(s) skipped`);
@@ -1091,9 +1645,22 @@ program.command("feed <ks>").description("Feed data into a knowledge source (fro
1091
1645
  console.log(
1092
1646
  `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
1647
  );
1648
+ await printRunStats(ks, since);
1094
1649
  }
1095
1650
  })
1096
1651
  );
1652
+ async function printRunStats(ks, since) {
1653
+ const stats = await ksStats(ks, since);
1654
+ if (stats.entries === 0) return;
1655
+ const size = (n) => n === null ? "-" : n.toLocaleString("en-US");
1656
+ console.log("");
1657
+ console.log(
1658
+ `this run: ${stats.entries.toLocaleString("en-US")} chunk(s) across ${stats.sources.toLocaleString("en-US")} source(s)`
1659
+ );
1660
+ console.log(
1661
+ ` 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)`
1662
+ );
1663
+ }
1097
1664
  async function readStdin() {
1098
1665
  const chunks = [];
1099
1666
  for await (const chunk of stdin) chunks.push(chunk);