@gresmcp/mcp 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -325,8 +325,8 @@ async function probeModel(baseUrl, model) {
325
325
  init_config();
326
326
 
327
327
  // src/ingest.ts
328
- import { promises as fs } from "fs";
329
- import path from "path";
328
+ import { promises as fs2 } from "fs";
329
+ import path2 from "path";
330
330
 
331
331
  // src/chunk.ts
332
332
  var HEADING_RE = /^(#{1,6})\s+(.+)$/;
@@ -390,8 +390,8 @@ function chunkMarkdown(text, opts = {}) {
390
390
  const m = HEADING_RE.exec(line);
391
391
  if (m) {
392
392
  const level = m[1].length;
393
- const path3 = sections[sections.length - 1].path;
394
- const parent = path3.slice(0, level - 1);
393
+ const path4 = sections[sections.length - 1].path;
394
+ const parent = path4.slice(0, level - 1);
395
395
  sections.push({ path: [...parent, m[2].trim()], body: [line] });
396
396
  } else {
397
397
  sections[sections.length - 1].body.push(line);
@@ -576,9 +576,337 @@ function extractPage(html, opts = {}) {
576
576
  };
577
577
  }
578
578
 
579
+ // src/doc.ts
580
+ import mammoth from "mammoth";
581
+ import {
582
+ Doc97File,
583
+ InvalidKeyError,
584
+ OfficeFile,
585
+ OleFileIO,
586
+ isEncrypted
587
+ } from "office-crypto";
588
+ import WordExtractor from "word-extractor";
589
+ var DocError = class extends Error {
590
+ constructor(message, kind) {
591
+ super(message);
592
+ this.kind = kind;
593
+ this.name = "DocError";
594
+ }
595
+ kind;
596
+ };
597
+ var OLE_MAGIC = [208, 207, 17, 224, 161, 177, 26, 225];
598
+ function hasMagic(data, magic) {
599
+ if (data.length < magic.length) return false;
600
+ return magic.every((byte, i) => data[i] === byte);
601
+ }
602
+ function isOle(data) {
603
+ return hasMagic(data, OLE_MAGIC);
604
+ }
605
+ function isZip(data) {
606
+ return data.length >= 4 && data[0] === 80 && data[1] === 75 && data[2] === 3 && data[3] === 4;
607
+ }
608
+ function toDocError(err, prefix) {
609
+ if (err instanceof DocError) return err;
610
+ if (err instanceof InvalidKeyError) {
611
+ return new DocError("wrong password for encrypted Word document", "password");
612
+ }
613
+ const message = err instanceof Error ? err.message : String(err);
614
+ return new DocError(`${prefix}: ${message.slice(0, 200)}`, "corrupt");
615
+ }
616
+ function requirePassword() {
617
+ return new DocError("password-protected Word document (use --file-password)", "password");
618
+ }
619
+ function oleHasStream(data, name) {
620
+ try {
621
+ return new OleFileIO(data).exists(name);
622
+ } catch {
623
+ return false;
624
+ }
625
+ }
626
+ function decryptOoxml(data, password) {
627
+ try {
628
+ if (!password) throw requirePassword();
629
+ const file = OfficeFile(data);
630
+ file.loadKey({ password, verifyPassword: true });
631
+ return file.decrypt();
632
+ } catch (err) {
633
+ throw toDocError(err, "cannot decrypt Word document");
634
+ }
635
+ }
636
+ function decryptDoc97(data, password) {
637
+ try {
638
+ if (!password) throw requirePassword();
639
+ const file = new Doc97File(new OleFileIO(data));
640
+ file.loadKey({ password });
641
+ return file.decrypt();
642
+ } catch (err) {
643
+ throw toDocError(err, "cannot decrypt Word document");
644
+ }
645
+ }
646
+ async function docxToMarkdown(data, encrypted) {
647
+ let html;
648
+ try {
649
+ const result = await mammoth.convertToHtml({ buffer: Buffer.from(data) });
650
+ html = result.value;
651
+ } catch (err) {
652
+ throw toDocError(err, "cannot parse docx");
653
+ }
654
+ html = html.replace(/<img\b[^>]*>/gi, "").replace(
655
+ /<t([dh])\b([^>]*)>([\s\S]*?)<\/t\1>/gi,
656
+ (_m, letter, attrs, inner) => `<t${letter}${attrs}>${inner.replace(/<\/?p(?:\s[^>]*)?>/gi, "").replace(/<br(?:\s[^>]*)?>/gi, " ")}</t${letter}>`
657
+ ).replace(
658
+ /<table\b[^>]*>([\s\S]*?)<\/table>/gi,
659
+ (m, inner) => m.replace(
660
+ /(<tr\b[^>]*>)([\s\S]*?)(<\/tr>)/i,
661
+ (row, open, cells) => open + cells.replace(/<td\b/gi, "<th").replace(/<\/td>/gi, "</th>") + "</tr>"
662
+ )
663
+ );
664
+ const { markdown, title } = htmlToMarkdown(html);
665
+ return {
666
+ markdown: markdown.replace(/\n{3,}/g, "\n\n").trim(),
667
+ title: title || void 0,
668
+ format: "docx",
669
+ ...encrypted ? { encrypted: true } : {}
670
+ };
671
+ }
672
+ async function docToText(data, encrypted) {
673
+ try {
674
+ const doc = await new WordExtractor().extract(Buffer.from(data));
675
+ const normalize = (s) => s.replace(/\r\n?/g, "\n").replace(/\f/g, "\n\n").replace(/\n{3,}/g, "\n\n").trim();
676
+ const parts = [doc.getBody(), doc.getTextboxes(), doc.getFootnotes(), doc.getEndnotes()].map(normalize).filter(Boolean);
677
+ return { markdown: parts.join("\n\n"), format: "doc", ...encrypted ? { encrypted: true } : {} };
678
+ } catch (err) {
679
+ throw toDocError(err, "cannot parse doc");
680
+ }
681
+ }
682
+ async function docToDoc(data, opts = {}) {
683
+ if (isZip(data)) return docxToMarkdown(data, false);
684
+ if (isOle(data)) {
685
+ if (oleHasStream(data, "EncryptionInfo")) {
686
+ if (oleHasStream(data, "Workbook") || oleHasStream(data, "PowerPoint Document")) {
687
+ throw new DocError("not a Word document (encrypted Excel/PowerPoint package)", "corrupt");
688
+ }
689
+ const plain = decryptOoxml(data, opts.password);
690
+ return isZip(plain) ? docxToMarkdown(plain, true) : docToText(plain, true);
691
+ }
692
+ if (oleHasStream(data, "WordDocument") || oleHasStream(data, "wordDocument")) {
693
+ if (isEncrypted(data)) {
694
+ const plain = decryptDoc97(data, opts.password);
695
+ return docToText(plain, true);
696
+ }
697
+ return docToText(data, false);
698
+ }
699
+ throw new DocError("not a Word document (unsupported OLE compound file)", "corrupt");
700
+ }
701
+ throw new DocError("not a Word document (missing docx/doc signature)", "corrupt");
702
+ }
703
+
704
+ // src/epub.ts
705
+ import { promises as fs } from "fs";
706
+ import { mkdtempSync } from "fs";
707
+ import { tmpdir } from "os";
708
+ import path from "path";
709
+ import { EPub } from "epub2";
710
+ var EpubError = class extends Error {
711
+ constructor(message, kind) {
712
+ super(message);
713
+ this.kind = kind;
714
+ this.name = "EpubError";
715
+ }
716
+ kind;
717
+ };
718
+ var REWRITE_ROOT_RE = /^\/(?:links|images)\//i;
719
+ var ENCRYPTION_ENTRY = "meta-inf/encryption.xml";
720
+ function toEpubError(err) {
721
+ if (err instanceof EpubError) return err;
722
+ const message = err instanceof Error ? err.message : String(err);
723
+ return new EpubError(`cannot parse EPUB: ${message.slice(0, 200)}`, "corrupt");
724
+ }
725
+ function hasEncryptionEntry(names) {
726
+ return (names ?? []).some((n) => n.toLowerCase() === ENCRYPTION_ENTRY);
727
+ }
728
+ function normalizeHref(href) {
729
+ const raw = href.split("#")[0].trim();
730
+ if (!raw) return "";
731
+ let decoded = raw;
732
+ try {
733
+ decoded = decodeURIComponent(raw);
734
+ } catch {
735
+ }
736
+ return path.posix.normalize(decoded).toLowerCase();
737
+ }
738
+ function cleanChapterHtml(html) {
739
+ return html.replace(/<img\b[^>]*>/gi, "").replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (m, attrs, inner) => {
740
+ const href = /\shref\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs);
741
+ const url = (href?.[1] ?? href?.[2] ?? "").trim();
742
+ return REWRITE_ROOT_RE.test(url) ? inner : m;
743
+ });
744
+ }
745
+ function tocTitleMap(epub) {
746
+ const titles = /* @__PURE__ */ new Map();
747
+ for (const entry of epub.toc ?? []) {
748
+ const title = entry?.title?.trim();
749
+ if (!title) continue;
750
+ if (entry.id && epub.manifest[entry.id]) {
751
+ const idKey = `id:${entry.id}`;
752
+ if (!titles.has(idKey)) titles.set(idKey, title);
753
+ continue;
754
+ }
755
+ if (entry.href) {
756
+ const hrefKey = normalizeHref(entry.href);
757
+ if (hrefKey && !titles.has(hrefKey)) titles.set(hrefKey, title);
758
+ }
759
+ }
760
+ return titles;
761
+ }
762
+ async function epubToDoc(filePath) {
763
+ let epub;
764
+ try {
765
+ epub = await EPub.createAsync(filePath);
766
+ } catch (err) {
767
+ throw toEpubError(err);
768
+ }
769
+ const encrypted = hasEncryptionEntry(epub.zip?.names);
770
+ const titles = tocTitleMap(epub);
771
+ const parts = [];
772
+ for (const item of epub.flow ?? []) {
773
+ const id = item?.id;
774
+ if (!id) continue;
775
+ let html;
776
+ try {
777
+ html = await epub.getChapterAsync(id);
778
+ } catch {
779
+ continue;
780
+ }
781
+ const { markdown: markdown2 } = htmlToMarkdown(cleanChapterHtml(html));
782
+ if (!markdown2.trim()) continue;
783
+ const heading = titles.get(`id:${id}`) ?? (item.href ? titles.get(normalizeHref(item.href)) : void 0);
784
+ parts.push(heading ? `## ${heading}
785
+
786
+ ${markdown2}` : markdown2);
787
+ }
788
+ const markdown = parts.join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
789
+ if (!markdown) {
790
+ throw new EpubError(
791
+ encrypted ? "DRM-protected EPUB (encryption.xml; no readable text)" : "no extractable text from EPUB",
792
+ encrypted ? "drm" : "corrupt"
793
+ );
794
+ }
795
+ return {
796
+ markdown,
797
+ title: epub.metadata?.title?.trim() || void 0,
798
+ author: epub.metadata?.creator?.trim() || void 0,
799
+ language: epub.metadata?.language?.trim() || void 0,
800
+ chapters: parts.length,
801
+ ...encrypted ? { encrypted: true } : {}
802
+ };
803
+ }
804
+ async function epubFromBuffer(data) {
805
+ const dir = mkdtempSync(path.join(tmpdir(), "gresmcp-epub-"));
806
+ try {
807
+ const file = path.join(dir, "book.epub");
808
+ await fs.writeFile(file, data);
809
+ return await epubToDoc(file);
810
+ } finally {
811
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {
812
+ });
813
+ }
814
+ }
815
+
579
816
  // src/ingest.ts
580
817
  init_db();
581
818
 
819
+ // src/pdf.ts
820
+ import pdf2md from "@opendocsg/pdf2md";
821
+ import { extractText, getMeta } from "unpdf";
822
+ var PAGE_BREAK_MARK = "<!-- PAGE_BREAK -->";
823
+ var PdfError = class extends Error {
824
+ constructor(message, kind) {
825
+ super(message);
826
+ this.kind = kind;
827
+ this.name = "PdfError";
828
+ }
829
+ kind;
830
+ };
831
+ function isPasswordException(err) {
832
+ return err instanceof Error && err.name === "PasswordException";
833
+ }
834
+ function toPdfError(err) {
835
+ if (err instanceof PdfError) return err;
836
+ if (isPasswordException(err)) {
837
+ return new PdfError("wrong password for encrypted PDF", "password");
838
+ }
839
+ const message = err instanceof Error ? err.message : String(err);
840
+ return new PdfError(`cannot parse PDF: ${message.slice(0, 200)}`, "corrupt");
841
+ }
842
+ function titleFromInfo(info) {
843
+ const raw = info?.Title;
844
+ const value = typeof raw === "string" ? raw.trim() : "";
845
+ return value || void 0;
846
+ }
847
+ function imageOnlyFromPages(pages) {
848
+ const out = [];
849
+ for (const page of pages) {
850
+ const hasText = page.items.some((it) => String(it.text ?? "").trim().length > 0);
851
+ if (!hasText) out.push(page.index + 1);
852
+ }
853
+ return out;
854
+ }
855
+ async function extractEncrypted(data, password) {
856
+ try {
857
+ const extractOpts = { mergePages: false, password };
858
+ const metaOpts = { password };
859
+ const [result, meta] = await Promise.all([
860
+ extractText(data, extractOpts),
861
+ getMeta(data, metaOpts).catch(() => void 0)
862
+ ]);
863
+ const pages = result.text;
864
+ const imageOnlyPages = [];
865
+ pages.forEach((text, i) => {
866
+ if (!text.trim()) imageOnlyPages.push(i + 1);
867
+ });
868
+ const markdown = pages.map((p) => p.trim()).filter(Boolean).join("\n\n").trim();
869
+ return {
870
+ markdown,
871
+ title: titleFromInfo(meta?.info),
872
+ pageCount: result.totalPages,
873
+ imageOnlyPages
874
+ };
875
+ } catch (err) {
876
+ throw toPdfError(err);
877
+ }
878
+ }
879
+ async function pdfToDoc(data, opts = {}) {
880
+ let title;
881
+ let pages = [];
882
+ let markdown;
883
+ try {
884
+ markdown = await pdf2md(data, {
885
+ metadataParsed: (meta) => {
886
+ title = titleFromInfo(meta.info);
887
+ },
888
+ pageParsed: (parsed) => {
889
+ pages = parsed;
890
+ }
891
+ });
892
+ } catch (err) {
893
+ if (isPasswordException(err)) {
894
+ if (opts.password) {
895
+ return extractEncrypted(data, opts.password);
896
+ }
897
+ throw new PdfError("password-protected PDF (use --file-password)", "password");
898
+ }
899
+ throw toPdfError(err);
900
+ }
901
+ markdown = markdown.split(PAGE_BREAK_MARK).map((part) => part.replace(/\s+$/, "")).join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
902
+ return {
903
+ markdown,
904
+ title,
905
+ pageCount: pages.length,
906
+ imageOnlyPages: imageOnlyFromPages(pages)
907
+ };
908
+ }
909
+
582
910
  // src/progress.ts
583
911
  var BAR_WIDTH = 20;
584
912
  var PLAIN_STEP = 32;
@@ -718,6 +1046,9 @@ var FeedProgress = class {
718
1046
  // src/ingest.ts
719
1047
  var MD_EXT = /* @__PURE__ */ new Set([".md", ".markdown", ".mdx"]);
720
1048
  var HTML_EXT = /* @__PURE__ */ new Set([".html", ".htm", ".xhtml"]);
1049
+ var PDF_EXT = /* @__PURE__ */ new Set([".pdf"]);
1050
+ var DOC_EXT = /* @__PURE__ */ new Set([".doc", ".docx"]);
1051
+ var EPUB_EXT = /* @__PURE__ */ new Set([".epub"]);
721
1052
  var TEXT_EXT = /* @__PURE__ */ new Set([
722
1053
  ".txt",
723
1054
  ".text",
@@ -805,13 +1136,13 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
805
1136
  ".cache"
806
1137
  ]);
807
1138
  function isAllowed(ext) {
808
- return MD_EXT.has(ext) || HTML_EXT.has(ext) || TEXT_EXT.has(ext) || CODE_EXT.has(ext);
1139
+ return MD_EXT.has(ext) || HTML_EXT.has(ext) || TEXT_EXT.has(ext) || CODE_EXT.has(ext) || PDF_EXT.has(ext) || DOC_EXT.has(ext) || EPUB_EXT.has(ext);
809
1140
  }
810
1141
  async function walk(root) {
811
1142
  const out = [];
812
- const entries = await fs.readdir(root, { withFileTypes: true });
1143
+ const entries = await fs2.readdir(root, { withFileTypes: true });
813
1144
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
814
- const full = path.join(root, entry.name);
1145
+ const full = path2.join(root, entry.name);
815
1146
  if (entry.isDirectory()) {
816
1147
  if (SKIP_DIRS.has(entry.name)) continue;
817
1148
  out.push(...await walk(full));
@@ -822,50 +1153,110 @@ async function walk(root) {
822
1153
  return out;
823
1154
  }
824
1155
  async function loadTarget(target, opts) {
825
- const stat = await fs.stat(target);
1156
+ const stat = await fs2.stat(target);
826
1157
  const files = stat.isDirectory() ? await walk(target) : [target];
827
1158
  const docs = [];
828
1159
  const skipped = [];
829
1160
  for (const file of files) {
830
- const ext = path.extname(file).toLowerCase();
1161
+ const ext = path2.extname(file).toLowerCase();
831
1162
  if (!isAllowed(ext)) {
832
1163
  skipped.push({ path: file, reason: `unsupported extension '${ext || "(none)"}'` });
833
1164
  continue;
834
1165
  }
835
- let raw;
1166
+ let buf;
836
1167
  try {
837
- raw = await fs.readFile(file, "utf8");
1168
+ buf = await fs2.readFile(file);
838
1169
  } catch {
839
1170
  skipped.push({ path: file, reason: "unreadable" });
840
1171
  continue;
841
1172
  }
842
- if (raw.includes("\0")) {
843
- skipped.push({ path: file, reason: "binary content" });
844
- continue;
845
- }
846
- let text = raw;
1173
+ let text = "";
847
1174
  let title = "";
848
- if (HTML_EXT.has(ext)) {
849
- const conv = htmlToMarkdown(raw, { filter: opts.htmlFilter });
850
- text = conv.markdown;
851
- title = conv.title ?? "";
852
- } else if (MD_EXT.has(ext)) {
853
- title = mdTitle(raw) ?? "";
854
- }
855
- if (!text.trim()) {
856
- skipped.push({
857
- path: file,
858
- reason: HTML_EXT.has(ext) && opts.htmlFilter ? `html filter '${opts.htmlFilter}' matched nothing` : "empty"
859
- });
860
- continue;
1175
+ let docMeta;
1176
+ let chunks = [];
1177
+ if (PDF_EXT.has(ext)) {
1178
+ let pdf;
1179
+ try {
1180
+ pdf = await pdfToDoc(new Uint8Array(buf), { password: opts.filePassword });
1181
+ } catch (err) {
1182
+ skipped.push({ path: file, reason: err instanceof Error ? err.message : "PDF parse failed" });
1183
+ continue;
1184
+ }
1185
+ text = pdf.markdown;
1186
+ title = pdf.title ?? "";
1187
+ if (pdf.pageCount > 0) docMeta = { pdf_pages: pdf.pageCount };
1188
+ if (pdf.imageOnlyPages.length > 0) {
1189
+ docMeta = { ...docMeta, pdf_image_only_pages: pdf.imageOnlyPages.length };
1190
+ }
1191
+ if (!text.trim()) {
1192
+ skipped.push({
1193
+ path: file,
1194
+ reason: pdf.pageCount > 0 && pdf.imageOnlyPages.length === pdf.pageCount ? "image-only PDF (no text layer; likely scanned)" : "no extractable text from PDF"
1195
+ });
1196
+ continue;
1197
+ }
1198
+ chunks = chunkMarkdown(text, opts);
1199
+ } else if (DOC_EXT.has(ext)) {
1200
+ let word;
1201
+ try {
1202
+ word = await docToDoc(new Uint8Array(buf), { password: opts.filePassword });
1203
+ } catch (err) {
1204
+ skipped.push({ path: file, reason: err instanceof Error ? err.message : "Word document parse failed" });
1205
+ continue;
1206
+ }
1207
+ text = word.markdown;
1208
+ title = word.title ?? "";
1209
+ docMeta = { doc_format: word.format };
1210
+ if (word.encrypted) docMeta.doc_encrypted = true;
1211
+ if (!text.trim()) {
1212
+ skipped.push({ path: file, reason: "no extractable text from Word document" });
1213
+ continue;
1214
+ }
1215
+ chunks = word.format === "docx" ? chunkMarkdown(text, opts) : chunkPlain(text, opts);
1216
+ } else if (EPUB_EXT.has(ext)) {
1217
+ let epub;
1218
+ try {
1219
+ epub = await epubToDoc(file);
1220
+ } catch (err) {
1221
+ skipped.push({ path: file, reason: err instanceof Error ? err.message : "EPUB parse failed" });
1222
+ continue;
1223
+ }
1224
+ text = epub.markdown;
1225
+ title = epub.title ?? "";
1226
+ docMeta = { epub_chapters: epub.chapters };
1227
+ if (epub.author) docMeta.epub_author = epub.author;
1228
+ if (epub.language) docMeta.epub_language = epub.language;
1229
+ if (epub.encrypted) docMeta.epub_encrypted = true;
1230
+ chunks = chunkMarkdown(text, opts);
1231
+ } else {
1232
+ const raw = buf.toString("utf8");
1233
+ if (raw.includes("\0")) {
1234
+ skipped.push({ path: file, reason: "binary content" });
1235
+ continue;
1236
+ }
1237
+ text = raw;
1238
+ if (HTML_EXT.has(ext)) {
1239
+ const conv = htmlToMarkdown(raw, { filter: opts.htmlFilter });
1240
+ text = conv.markdown;
1241
+ title = conv.title ?? "";
1242
+ } else if (MD_EXT.has(ext)) {
1243
+ title = mdTitle(raw) ?? "";
1244
+ }
1245
+ if (!text.trim()) {
1246
+ skipped.push({
1247
+ path: file,
1248
+ reason: HTML_EXT.has(ext) && opts.htmlFilter ? `html filter '${opts.htmlFilter}' matched nothing` : "empty"
1249
+ });
1250
+ continue;
1251
+ }
1252
+ chunks = HTML_EXT.has(ext) || MD_EXT.has(ext) ? chunkMarkdown(text, opts) : chunkPlain(text, opts);
861
1253
  }
862
- if (!title) title = path.basename(file);
863
- const chunks = HTML_EXT.has(ext) || MD_EXT.has(ext) ? chunkMarkdown(text, opts) : chunkPlain(text, opts);
864
1254
  if (chunks.length === 0) {
865
1255
  skipped.push({ path: file, reason: "no chunks produced" });
866
1256
  continue;
867
1257
  }
868
- docs.push({ source: path.resolve(file), title, chunks });
1258
+ if (!title) title = path2.basename(file);
1259
+ docs.push({ source: path2.resolve(file), title, chunks, metadata: docMeta });
869
1260
  }
870
1261
  return { docs, fileCount: files.length, skipped };
871
1262
  }
@@ -966,7 +1357,7 @@ async function feedDocs(ks, docs, opts = {}) {
966
1357
  };
967
1358
  }
968
1359
  async function feedTarget(ks, target, opts = {}) {
969
- const loadOpts = { maxLen: opts.maxLen ?? 1200, overlap: opts.overlap ?? 180, htmlFilter: opts.htmlFilter };
1360
+ const loadOpts = { maxLen: opts.maxLen ?? 1200, overlap: opts.overlap ?? 180, htmlFilter: opts.htmlFilter, filePassword: opts.filePassword };
970
1361
  const { docs, skipped } = await loadTarget(target, loadOpts);
971
1362
  for (const s of skipped) {
972
1363
  (opts.log ?? ((m) => console.log(m)))(`skipped ${s.path}: ${s.reason}`);
@@ -986,9 +1377,9 @@ async function feedText(ks, text, opts = {}) {
986
1377
  }
987
1378
 
988
1379
  // src/scrape.ts
989
- import { mkdtempSync } from "fs";
990
- import { tmpdir } from "os";
991
- import path2 from "path";
1380
+ import { mkdtempSync as mkdtempSync2 } from "fs";
1381
+ import { tmpdir as tmpdir2 } from "os";
1382
+ import path3 from "path";
992
1383
  import { randomUUID } from "crypto";
993
1384
  import {
994
1385
  CheerioCrawler,
@@ -1014,8 +1405,11 @@ function parseSitemapOption(value) {
1014
1405
  if (key === "html-only") return { kind: "html-only" };
1015
1406
  return { kind: "selector", selector: trimmed };
1016
1407
  }
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-"));
1408
+ var BINARY_LINK_RE = /\.(css|js|mjs|cjs|map|png|jpe?g|gif|svg|webp|avif|ico|bmp|tiff?|woff2?|ttf|otf|eot|zip|gz|tgz|tar|rar|7z|bz2|xz|zst|mp3|mp4|m4a|webm|mov|avi|wmv|flv|xlsx?|pptx?|odt|ods|odp|rtf|dmg|exe|msi|apk|iso|bin|wasm|deb|rpm)(\?.*)?$/i;
1409
+ var PDF_URL_RE = /\.pdf(\?|#|$)/i;
1410
+ var DOC_URL_RE = /\.docx?(\?|#|$)/i;
1411
+ var EPUB_URL_RE = /\.epub(\?|#|$)/i;
1412
+ process.env.CRAWLEE_STORAGE_DIR ??= mkdtempSync2(path3.join(tmpdir2(), "gresmcp-crawl-"));
1019
1413
  async function freshQueue() {
1020
1414
  return RequestQueue.open(`gresmcp-${randomUUID()}`);
1021
1415
  }
@@ -1090,18 +1484,84 @@ function dedupeDocs(docs) {
1090
1484
  }
1091
1485
  return [...bySource.values()];
1092
1486
  }
1487
+ async function buildPdfDoc(url, data, deps) {
1488
+ try {
1489
+ const pdf = await pdfToDoc(data, { password: deps.filePassword });
1490
+ if (!pdf.markdown.trim()) {
1491
+ const reason = pdf.pageCount > 0 && pdf.imageOnlyPages.length === pdf.pageCount ? "image-only PDF (no text layer; likely scanned)" : "no extractable text from PDF";
1492
+ return { reason };
1493
+ }
1494
+ const chunks = chunkMarkdown(pdf.markdown, { maxLen: deps.maxLen, overlap: deps.overlap });
1495
+ if (chunks.length === 0) return { reason: "no chunks produced" };
1496
+ const metadata = { source_url: url, crawled_at: (/* @__PURE__ */ new Date()).toISOString() };
1497
+ if (pdf.pageCount > 0) metadata.pdf_pages = pdf.pageCount;
1498
+ if (pdf.imageOnlyPages.length > 0) metadata.pdf_image_only_pages = pdf.imageOnlyPages.length;
1499
+ const tags = unionTags(void 0, deps.cliTags);
1500
+ if (tags) metadata.tags = tags;
1501
+ return { doc: { source: url, title: pdf.title?.trim() || titleFor(url), chunks, metadata } };
1502
+ } catch (err) {
1503
+ return { reason: err instanceof Error ? err.message : "PDF parse failed" };
1504
+ }
1505
+ }
1506
+ async function buildEpubDoc(url, data, deps) {
1507
+ try {
1508
+ const epub = await epubFromBuffer(data);
1509
+ if (!epub.markdown.trim()) {
1510
+ return {
1511
+ reason: epub.encrypted ? "DRM-protected EPUB (encryption.xml; no readable text)" : "no extractable text from EPUB"
1512
+ };
1513
+ }
1514
+ const chunks = chunkMarkdown(epub.markdown, { maxLen: deps.maxLen, overlap: deps.overlap });
1515
+ if (chunks.length === 0) return { reason: "no chunks produced" };
1516
+ const metadata = {
1517
+ source_url: url,
1518
+ crawled_at: (/* @__PURE__ */ new Date()).toISOString(),
1519
+ epub_chapters: epub.chapters
1520
+ };
1521
+ if (epub.author) metadata.epub_author = epub.author;
1522
+ if (epub.language) metadata.epub_language = epub.language;
1523
+ if (epub.encrypted) metadata.epub_encrypted = true;
1524
+ const tags = unionTags(void 0, deps.cliTags);
1525
+ if (tags) metadata.tags = tags;
1526
+ return { doc: { source: url, title: epub.title?.trim() || titleFor(url), chunks, metadata } };
1527
+ } catch (err) {
1528
+ return { reason: err instanceof Error ? err.message : "EPUB parse failed" };
1529
+ }
1530
+ }
1093
1531
  function shortReason(err) {
1094
1532
  const message = (err?.message ?? String(err)).replace(/\s+/g, " ").trim();
1095
1533
  const contentType = /served Content-Type ([^,\s]+),/.exec(message);
1096
1534
  if (contentType) return `unsupported content-type '${contentType[1]}'`;
1097
1535
  return message.slice(0, 200);
1098
1536
  }
1537
+ async function buildDocDoc(url, data, deps) {
1538
+ try {
1539
+ const word = await docToDoc(data, { password: deps.filePassword });
1540
+ if (!word.markdown.trim()) {
1541
+ return { reason: "no extractable text from Word document" };
1542
+ }
1543
+ const chunkOpts = { maxLen: deps.maxLen, overlap: deps.overlap };
1544
+ const chunks = word.format === "docx" ? chunkMarkdown(word.markdown, chunkOpts) : chunkPlain(word.markdown, chunkOpts);
1545
+ if (chunks.length === 0) return { reason: "no chunks produced" };
1546
+ const metadata = {
1547
+ source_url: url,
1548
+ crawled_at: (/* @__PURE__ */ new Date()).toISOString(),
1549
+ doc_format: word.format
1550
+ };
1551
+ if (word.encrypted) metadata.doc_encrypted = true;
1552
+ const tags = unionTags(void 0, deps.cliTags);
1553
+ if (tags) metadata.tags = tags;
1554
+ return { doc: { source: url, title: word.title?.trim() || titleFor(url), chunks, metadata } };
1555
+ } catch (err) {
1556
+ return { reason: err instanceof Error ? err.message : "Word document parse failed" };
1557
+ }
1558
+ }
1099
1559
  async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1100
1560
  const crawler = new CheerioCrawler({
1101
1561
  requestQueue: queue,
1102
1562
  maxRequestsPerCrawl: Math.max(1, opts.maxPages),
1103
1563
  maxCrawlDepth: Math.max(0, opts.depth),
1104
- additionalMimeTypes: ["text/plain", "text/markdown"],
1564
+ additionalMimeTypes: ["text/plain", "text/markdown", "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/epub+zip", "application/zip", "application/octet-stream"],
1105
1565
  async requestHandler(ctx) {
1106
1566
  const { request, response, contentType, body, $ } = ctx;
1107
1567
  const url = request.loadedUrl ?? request.url;
@@ -1169,6 +1629,47 @@ async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1169
1629
  return;
1170
1630
  }
1171
1631
  sink.docs.push(doc);
1632
+ } else if (type === "application/pdf") {
1633
+ const data = typeof body === "string" ? new TextEncoder().encode(body) : new Uint8Array(body);
1634
+ const { doc, reason } = await buildPdfDoc(url, data, sinkDeps(opts));
1635
+ if (reason || !doc) {
1636
+ sink.skipped.push({ path: url, reason: reason ?? "PDF produced no document" });
1637
+ return;
1638
+ }
1639
+ sink.docs.push(doc);
1640
+ } else if (type === "application/msword" || type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") {
1641
+ const data = typeof body === "string" ? new TextEncoder().encode(body) : new Uint8Array(body);
1642
+ const { doc, reason } = await buildDocDoc(url, data, sinkDeps(opts));
1643
+ if (reason || !doc) {
1644
+ sink.skipped.push({ path: url, reason: reason ?? "Word document produced no document" });
1645
+ return;
1646
+ }
1647
+ sink.docs.push(doc);
1648
+ } else if (type === "application/epub+zip" || type === "application/zip") {
1649
+ const data = typeof body === "string" ? new TextEncoder().encode(body) : new Uint8Array(body);
1650
+ const { doc, reason } = await buildEpubDoc(url, data, sinkDeps(opts));
1651
+ if (reason || !doc) {
1652
+ sink.skipped.push({ path: url, reason: reason ?? "EPUB produced no document" });
1653
+ return;
1654
+ }
1655
+ sink.docs.push(doc);
1656
+ } else if (type === "application/octet-stream") {
1657
+ const data = typeof body === "string" ? new TextEncoder().encode(body) : new Uint8Array(body);
1658
+ if (EPUB_URL_RE.test(url)) {
1659
+ const { doc: doc2, reason: reason2 } = await buildEpubDoc(url, data, sinkDeps(opts));
1660
+ if (reason2 || !doc2) {
1661
+ sink.skipped.push({ path: url, reason: reason2 ?? "EPUB produced no document" });
1662
+ return;
1663
+ }
1664
+ sink.docs.push(doc2);
1665
+ return;
1666
+ }
1667
+ const { doc, reason } = await buildDocDoc(url, data, sinkDeps(opts));
1668
+ if (reason || !doc) {
1669
+ sink.skipped.push({ path: url, reason: reason ?? "Word document produced no document" });
1670
+ return;
1671
+ }
1672
+ sink.docs.push(doc);
1172
1673
  } else {
1173
1674
  sink.skipped.push({ path: url, reason: `unsupported content-type '${type}'` });
1174
1675
  }
@@ -1192,6 +1693,7 @@ function sinkDeps(opts) {
1192
1693
  cliTags: opts.tags,
1193
1694
  urlFilter: opts.urlFilter,
1194
1695
  htmlFilter: opts.htmlFilter,
1696
+ filePassword: opts.filePassword,
1195
1697
  followLinks: opts.followLinks,
1196
1698
  linkSelector: opts.linkSelector,
1197
1699
  selectorStats: opts.selectorStats
@@ -1212,9 +1714,75 @@ async function requireBrowserEngine(kind) {
1212
1714
  throw new Error(`--crawler ${kind} requires ${moduleName}, which is not installed (install it with: ${install})`);
1213
1715
  }
1214
1716
  }
1717
+ async function handleBrowserPdf(url, deps) {
1718
+ try {
1719
+ const res = await fetch(url, { redirect: "follow" });
1720
+ if (!res.ok) {
1721
+ deps.sink.skipped.push({ path: url, reason: `HTTP ${res.status}` });
1722
+ return;
1723
+ }
1724
+ const data = new Uint8Array(await res.arrayBuffer());
1725
+ const { doc, reason } = await buildPdfDoc(url, data, deps);
1726
+ if (reason || !doc) {
1727
+ deps.sink.skipped.push({ path: url, reason: reason ?? "PDF produced no document" });
1728
+ return;
1729
+ }
1730
+ deps.sink.docs.push(doc);
1731
+ } catch (err) {
1732
+ deps.sink.skipped.push({ path: url, reason: err instanceof Error ? err.message : "PDF fetch failed" });
1733
+ }
1734
+ }
1735
+ async function handleBrowserDoc(url, deps) {
1736
+ try {
1737
+ const res = await fetch(url, { redirect: "follow" });
1738
+ if (!res.ok) {
1739
+ deps.sink.skipped.push({ path: url, reason: `HTTP ${res.status}` });
1740
+ return;
1741
+ }
1742
+ const data = new Uint8Array(await res.arrayBuffer());
1743
+ const { doc, reason } = await buildDocDoc(url, data, deps);
1744
+ if (reason || !doc) {
1745
+ deps.sink.skipped.push({ path: url, reason: reason ?? "Word document produced no document" });
1746
+ return;
1747
+ }
1748
+ deps.sink.docs.push(doc);
1749
+ } catch (err) {
1750
+ deps.sink.skipped.push({ path: url, reason: err instanceof Error ? err.message : "Word document fetch failed" });
1751
+ }
1752
+ }
1753
+ async function handleBrowserEpub(url, deps) {
1754
+ try {
1755
+ const res = await fetch(url, { redirect: "follow" });
1756
+ if (!res.ok) {
1757
+ deps.sink.skipped.push({ path: url, reason: `HTTP ${res.status}` });
1758
+ return;
1759
+ }
1760
+ const data = new Uint8Array(await res.arrayBuffer());
1761
+ const { doc, reason } = await buildEpubDoc(url, data, deps);
1762
+ if (reason || !doc) {
1763
+ deps.sink.skipped.push({ path: url, reason: reason ?? "EPUB produced no document" });
1764
+ return;
1765
+ }
1766
+ deps.sink.docs.push(doc);
1767
+ } catch (err) {
1768
+ deps.sink.skipped.push({ path: url, reason: err instanceof Error ? err.message : "EPUB fetch failed" });
1769
+ }
1770
+ }
1215
1771
  async function handleBrowserPage(ctx, deps) {
1216
1772
  const request = ctx.request;
1217
1773
  const url = request.loadedUrl ?? request.url;
1774
+ if (PDF_URL_RE.test(url)) {
1775
+ await handleBrowserPdf(url, deps);
1776
+ return;
1777
+ }
1778
+ if (DOC_URL_RE.test(url)) {
1779
+ await handleBrowserDoc(url, deps);
1780
+ return;
1781
+ }
1782
+ if (EPUB_URL_RE.test(url)) {
1783
+ await handleBrowserEpub(url, deps);
1784
+ return;
1785
+ }
1218
1786
  const status = responseStatus(ctx.response);
1219
1787
  if (status === void 0 || status < 200 || status >= 300) {
1220
1788
  deps.sink.skipped.push({ path: url, reason: status === void 0 ? "no HTTP response" : `HTTP ${status}` });
@@ -1426,6 +1994,7 @@ async function feedUrl(ks, url, opts = {}) {
1426
1994
  tags: opts.tags,
1427
1995
  urlFilter: opts.urlFilter,
1428
1996
  htmlFilter: opts.htmlFilter,
1997
+ filePassword: opts.filePassword,
1429
1998
  sitemap: opts.sitemap,
1430
1999
  log: log2
1431
2000
  });
@@ -1670,7 +2239,7 @@ async function runChecks(opts) {
1670
2239
  }
1671
2240
 
1672
2241
  // src/version.ts
1673
- var VERSION = "1.2.0";
2242
+ var VERSION = "1.3.0";
1674
2243
 
1675
2244
  // src/cli.ts
1676
2245
  async function run(fn) {
@@ -1850,7 +2419,7 @@ ksCmd.command("delete <name>").description("Delete a knowledge source and all of
1850
2419
  console.log(`Deleted knowledge source '${name}'`);
1851
2420
  })
1852
2421
  );
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(
2422
+ 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, pdf, doc, docx, epub)").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
2423
  "--url-filter <glob>",
1855
2424
  "Only follow --url links whose URL matches this glob (repeatable; a link is followed if it matches any glob, e.g. '**/docs/**')",
1856
2425
  ((v, prev) => [...prev, v]),
@@ -1861,6 +2430,9 @@ program.command("feed <ks>").description("Feed data into a knowledge source (fro
1861
2430
  ).option(
1862
2431
  "--sitemap <mode>",
1863
2432
  "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')"
2433
+ ).option(
2434
+ "--file-password <password>",
2435
+ "Password for encrypted files (PDF, DOC/DOCX); applies to --path and --url ingestion. Owner-restricted PDFs without a user password are read without it"
1864
2436
  ).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(
1865
2437
  (name, opts) => run(async () => {
1866
2438
  await initDb();
@@ -1889,7 +2461,8 @@ program.command("feed <ks>").description("Feed data into a knowledge source (fro
1889
2461
  overlap: opts.overlap,
1890
2462
  tags: parseTags(opts.tags),
1891
2463
  metadata: parseKeyValue(opts.metadata ?? []),
1892
- htmlFilter: opts.htmlFilter
2464
+ htmlFilter: opts.htmlFilter,
2465
+ filePassword: opts.filePassword
1893
2466
  };
1894
2467
  let summary;
1895
2468
  const since = await dbClockTimestamp();