@lincoln504/pi-research 1.7.1 → 1.7.2

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.
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.7.1", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.7.2", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/dist/cli.mjs CHANGED
@@ -18704,6 +18704,7 @@ var BOT_PATTERNS = [
18704
18704
  ];
18705
18705
  var IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
18706
18706
  var MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
18707
+ var EMPTY_FRAGMENT_LINK_PATTERN = /\[\]\(#[^)\s]*(?:\s+"[^"\n]*")?\)/g;
18707
18708
 
18708
18709
  // src/web-research/types.ts
18709
18710
  var FETCH_LAYER_TIMEOUT = 1e4;
@@ -18924,6 +18925,51 @@ function isMappedLoopback(ip) {
18924
18925
  const mapped = extractMappedIpv4(ip.toLowerCase().replace(/^\[|\]$/g, ""));
18925
18926
  return mapped !== null && /^127\./.test(mapped);
18926
18927
  }
18928
+ function stripEmptyFragmentLinks(markdown) {
18929
+ return markdown.replace(EMPTY_FRAGMENT_LINK_PATTERN, "");
18930
+ }
18931
+ var FRONT_MATTER_SLOTS = [
18932
+ ["title", "meta-og:title", "meta-citation_title"],
18933
+ ["meta-description", "description", "meta-og:description", "meta-twitter:description"],
18934
+ ["meta-author", "meta-article:author", "meta-citation_author", "meta-dc.creator", "meta-dcterms.creator"],
18935
+ [
18936
+ "meta-article:published_time",
18937
+ "meta-citation_publication_date",
18938
+ "meta-citation_date",
18939
+ "meta-dcterms.date",
18940
+ "meta-dc.date",
18941
+ "meta-date",
18942
+ "meta-dcterms.created"
18943
+ ],
18944
+ ["meta-article:modified_time", "meta-dcterms.modified"]
18945
+ ];
18946
+ var MAX_FRONT_MATTER_CHARS = 2e4;
18947
+ var FRONT_MATTER_KEY = /^([\w.:-]+):(?:\s|$)/;
18948
+ function trimFrontMatter(markdown) {
18949
+ if (!markdown.startsWith("---\n")) return markdown;
18950
+ const close = markdown.indexOf("\n---\n", 3);
18951
+ if (close === -1 || close > MAX_FRONT_MATTER_CHARS) return markdown;
18952
+ const entries = [];
18953
+ for (const line of markdown.slice(4, close).split("\n")) {
18954
+ const m = FRONT_MATTER_KEY.exec(line);
18955
+ if (m) entries.push({ key: m[1].toLowerCase(), lines: [line] });
18956
+ else if (entries.length) entries[entries.length - 1].lines.push(line);
18957
+ }
18958
+ const kept = [];
18959
+ for (const slot of FRONT_MATTER_SLOTS) {
18960
+ const entry = slot.map((key) => entries.find((e) => e.key === key)).find((e) => e !== void 0);
18961
+ if (entry) kept.push(...entry.lines);
18962
+ }
18963
+ const rest = markdown.slice(close + 5).replace(/^\n+/, "");
18964
+ return kept.length ? `---
18965
+ ${kept.join("\n")}
18966
+ ---
18967
+
18968
+ ${rest}` : rest;
18969
+ }
18970
+ function cleanConvertedMarkdown(markdown) {
18971
+ return stripEmptyFragmentLinks(stripImageLinks(trimFrontMatter(markdown))).replace(/\n{3,}/g, "\n\n").trim();
18972
+ }
18927
18973
  function stripImageLinks(markdown) {
18928
18974
  return markdown.replace(MARKDOWN_IMAGE_PATTERN, "").replace(IMAGE_LINK_PATTERN, "$1").replace(/\n{3,}/g, "\n\n").trim();
18929
18975
  }
@@ -18937,7 +18983,7 @@ function createNativeMarkdownConverter(nativeModule) {
18937
18983
  codeBlockStyle: nativeModule.CodeBlockStyle.Backticks,
18938
18984
  wrap: false
18939
18985
  });
18940
- return stripImageLinks(result.content ?? "");
18986
+ return cleanConvertedMarkdown(result.content ?? "");
18941
18987
  };
18942
18988
  }
18943
18989
  function createJsMarkdownConverter() {
@@ -18948,7 +18994,7 @@ function createJsMarkdownConverter() {
18948
18994
  });
18949
18995
  return async (html) => {
18950
18996
  const markdown = converter.translate(html);
18951
- return stripImageLinks(markdown);
18997
+ return cleanConvertedMarkdown(markdown);
18952
18998
  };
18953
18999
  }
18954
19000
  function validateContent(html, markdown, url) {
@@ -149,7 +149,7 @@ var init_user_agent = __esm({
149
149
  });
150
150
 
151
151
  // src/web-research/scraper-types.ts
152
- var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN;
152
+ var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN, EMPTY_FRAGMENT_LINK_PATTERN;
153
153
  var init_scraper_types = __esm({
154
154
  "src/web-research/scraper-types.ts"() {
155
155
  "use strict";
@@ -215,6 +215,7 @@ var init_scraper_types = __esm({
215
215
  ];
216
216
  IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
217
217
  MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
218
+ EMPTY_FRAGMENT_LINK_PATTERN = /\[\]\(#[^)\s]*(?:\s+"[^"\n]*")?\)/g;
218
219
  }
219
220
  });
220
221
 
@@ -1559,6 +1560,7 @@ var init_browser_error_utils = __esm({
1559
1560
  // src/web-research/scraper-utils.ts
1560
1561
  var scraper_utils_exports = {};
1561
1562
  __export(scraper_utils_exports, {
1563
+ cleanConvertedMarkdown: () => cleanConvertedMarkdown,
1562
1564
  createJsMarkdownConverter: () => createJsMarkdownConverter,
1563
1565
  createNativeMarkdownConverter: () => createNativeMarkdownConverter,
1564
1566
  disposeSsrfSafeFetcher: () => disposeSsrfSafeFetcher,
@@ -1569,7 +1571,9 @@ __export(scraper_utils_exports, {
1569
1571
  isBenignScrapeFailure: () => isBenignScrapeFailure,
1570
1572
  isSsrfBlockError: () => isSsrfBlockError,
1571
1573
  ssrfSafeLookup: () => ssrfSafeLookup,
1574
+ stripEmptyFragmentLinks: () => stripEmptyFragmentLinks,
1572
1575
  stripImageLinks: () => stripImageLinks,
1576
+ trimFrontMatter: () => trimFrontMatter,
1573
1577
  validateContent: () => validateContent,
1574
1578
  validateUrlForSSRF: () => validateUrlForSSRF,
1575
1579
  validateUrlForSSRFSync: () => validateUrlForSSRFSync
@@ -1785,6 +1789,34 @@ function isMappedLoopback(ip) {
1785
1789
  const mapped = extractMappedIpv4(ip.toLowerCase().replace(/^\[|\]$/g, ""));
1786
1790
  return mapped !== null && /^127\./.test(mapped);
1787
1791
  }
1792
+ function stripEmptyFragmentLinks(markdown) {
1793
+ return markdown.replace(EMPTY_FRAGMENT_LINK_PATTERN, "");
1794
+ }
1795
+ function trimFrontMatter(markdown) {
1796
+ if (!markdown.startsWith("---\n")) return markdown;
1797
+ const close = markdown.indexOf("\n---\n", 3);
1798
+ if (close === -1 || close > MAX_FRONT_MATTER_CHARS) return markdown;
1799
+ const entries = [];
1800
+ for (const line of markdown.slice(4, close).split("\n")) {
1801
+ const m = FRONT_MATTER_KEY.exec(line);
1802
+ if (m) entries.push({ key: m[1].toLowerCase(), lines: [line] });
1803
+ else if (entries.length) entries[entries.length - 1].lines.push(line);
1804
+ }
1805
+ const kept = [];
1806
+ for (const slot of FRONT_MATTER_SLOTS) {
1807
+ const entry = slot.map((key) => entries.find((e) => e.key === key)).find((e) => e !== void 0);
1808
+ if (entry) kept.push(...entry.lines);
1809
+ }
1810
+ const rest = markdown.slice(close + 5).replace(/^\n+/, "");
1811
+ return kept.length ? `---
1812
+ ${kept.join("\n")}
1813
+ ---
1814
+
1815
+ ${rest}` : rest;
1816
+ }
1817
+ function cleanConvertedMarkdown(markdown) {
1818
+ return stripEmptyFragmentLinks(stripImageLinks(trimFrontMatter(markdown))).replace(/\n{3,}/g, "\n\n").trim();
1819
+ }
1788
1820
  function stripImageLinks(markdown) {
1789
1821
  return markdown.replace(MARKDOWN_IMAGE_PATTERN, "").replace(IMAGE_LINK_PATTERN, "$1").replace(/\n{3,}/g, "\n\n").trim();
1790
1822
  }
@@ -1798,7 +1830,7 @@ function createNativeMarkdownConverter(nativeModule) {
1798
1830
  codeBlockStyle: nativeModule.CodeBlockStyle.Backticks,
1799
1831
  wrap: false
1800
1832
  });
1801
- return stripImageLinks(result.content ?? "");
1833
+ return cleanConvertedMarkdown(result.content ?? "");
1802
1834
  };
1803
1835
  }
1804
1836
  function createJsMarkdownConverter() {
@@ -1809,7 +1841,7 @@ function createJsMarkdownConverter() {
1809
1841
  });
1810
1842
  return async (html) => {
1811
1843
  const markdown = converter.translate(html);
1812
- return stripImageLinks(markdown);
1844
+ return cleanConvertedMarkdown(markdown);
1813
1845
  };
1814
1846
  }
1815
1847
  function validateContent(html, markdown, url) {
@@ -1848,7 +1880,7 @@ function validateContent(html, markdown, url) {
1848
1880
  throw error;
1849
1881
  }
1850
1882
  }
1851
- var cachedSsrfFetcher;
1883
+ var cachedSsrfFetcher, FRONT_MATTER_SLOTS, MAX_FRONT_MATTER_CHARS, FRONT_MATTER_KEY;
1852
1884
  var init_scraper_utils = __esm({
1853
1885
  "src/web-research/scraper-utils.ts"() {
1854
1886
  "use strict";
@@ -1858,6 +1890,23 @@ var init_scraper_utils = __esm({
1858
1890
  init_scraper_types();
1859
1891
  init_browser_error_utils();
1860
1892
  cachedSsrfFetcher = void 0;
1893
+ FRONT_MATTER_SLOTS = [
1894
+ ["title", "meta-og:title", "meta-citation_title"],
1895
+ ["meta-description", "description", "meta-og:description", "meta-twitter:description"],
1896
+ ["meta-author", "meta-article:author", "meta-citation_author", "meta-dc.creator", "meta-dcterms.creator"],
1897
+ [
1898
+ "meta-article:published_time",
1899
+ "meta-citation_publication_date",
1900
+ "meta-citation_date",
1901
+ "meta-dcterms.date",
1902
+ "meta-dc.date",
1903
+ "meta-date",
1904
+ "meta-dcterms.created"
1905
+ ],
1906
+ ["meta-article:modified_time", "meta-dcterms.modified"]
1907
+ ];
1908
+ MAX_FRONT_MATTER_CHARS = 2e4;
1909
+ FRONT_MATTER_KEY = /^([\w.:-]+):(?:\s|$)/;
1861
1910
  }
1862
1911
  });
1863
1912
 
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.7.2] - 2026-09-27
9
+
10
+ ### Changed
11
+
12
+ - **Scraped pages keep only the useful part of the converter's front matter.** The native converter turns every `<meta>` tag into a front-matter line: 54 lines on a GitHub page (analytics and error-reporting URLs, verification hashes, nonces, app ids), and on arXiv the abstract three times over. Nothing parses these lines; researchers read them and the knowledge store indexes them. `trimFrontMatter` (`src/web-research/scraper-utils.ts`) now keeps the title, description, author and publication/modification dates (first key present per slot: plain, Open Graph, citation or Dublin Core names) and drops the rest; a page with none of them loses the block. GitHub pages shrink by about 11%, arXiv abstract pages by about a third. Side effect: an empty page shell whose front matter alone used to pass the stub check is now treated as a stub, so the browser fallback gets a chance.
13
+ - **Scraped pages no longer carry empty same-page links.** Documentation generators such as Quarto and nbdev put an empty anchor before every code line (`<a href="#cb2-1"></a>`) and next to headings; the native HTML→Markdown converter turned each into `[](#cb2-1)`, so every code block in a research scrape (and in the knowledge store) started each line with one. Both converters now drop links that have no text and point to the same page (`cleanConvertedMarkdown` in `src/web-research/scraper-utils.ts`, run after the existing image-link stripping so heading anchors that wrap an icon — GitHub's `[![](link.svg)](#title)` — go too). Links with text, and empty links to other pages, are kept. On an nbdev documentation page (fastcore.fast.ai/docments.html) this removes 583 such links, 16% of the scraped text.
14
+
8
15
  ## [1.7.1] - 2026-09-27
9
16
 
10
17
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
@@ -149,7 +149,7 @@ var init_user_agent = __esm({
149
149
  });
150
150
 
151
151
  // src/web-research/scraper-types.ts
152
- var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN;
152
+ var MAX_HTML_SIZE, MAX_PDF_SIZE, INTERNAL_NETWORK_PATTERNS, FILTERED_TAGS, BOT_PATTERNS, IMAGE_LINK_PATTERN, MARKDOWN_IMAGE_PATTERN, EMPTY_FRAGMENT_LINK_PATTERN;
153
153
  var init_scraper_types = __esm({
154
154
  "src/web-research/scraper-types.ts"() {
155
155
  "use strict";
@@ -215,6 +215,7 @@ var init_scraper_types = __esm({
215
215
  ];
216
216
  IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
217
217
  MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
218
+ EMPTY_FRAGMENT_LINK_PATTERN = /\[\]\(#[^)\s]*(?:\s+"[^"\n]*")?\)/g;
218
219
  }
219
220
  });
220
221
 
@@ -1559,6 +1560,7 @@ var init_browser_error_utils = __esm({
1559
1560
  // src/web-research/scraper-utils.ts
1560
1561
  var scraper_utils_exports = {};
1561
1562
  __export(scraper_utils_exports, {
1563
+ cleanConvertedMarkdown: () => cleanConvertedMarkdown,
1562
1564
  createJsMarkdownConverter: () => createJsMarkdownConverter,
1563
1565
  createNativeMarkdownConverter: () => createNativeMarkdownConverter,
1564
1566
  disposeSsrfSafeFetcher: () => disposeSsrfSafeFetcher,
@@ -1569,7 +1571,9 @@ __export(scraper_utils_exports, {
1569
1571
  isBenignScrapeFailure: () => isBenignScrapeFailure,
1570
1572
  isSsrfBlockError: () => isSsrfBlockError,
1571
1573
  ssrfSafeLookup: () => ssrfSafeLookup,
1574
+ stripEmptyFragmentLinks: () => stripEmptyFragmentLinks,
1572
1575
  stripImageLinks: () => stripImageLinks,
1576
+ trimFrontMatter: () => trimFrontMatter,
1573
1577
  validateContent: () => validateContent,
1574
1578
  validateUrlForSSRF: () => validateUrlForSSRF,
1575
1579
  validateUrlForSSRFSync: () => validateUrlForSSRFSync
@@ -1785,6 +1789,34 @@ function isMappedLoopback(ip) {
1785
1789
  const mapped = extractMappedIpv4(ip.toLowerCase().replace(/^\[|\]$/g, ""));
1786
1790
  return mapped !== null && /^127\./.test(mapped);
1787
1791
  }
1792
+ function stripEmptyFragmentLinks(markdown) {
1793
+ return markdown.replace(EMPTY_FRAGMENT_LINK_PATTERN, "");
1794
+ }
1795
+ function trimFrontMatter(markdown) {
1796
+ if (!markdown.startsWith("---\n")) return markdown;
1797
+ const close = markdown.indexOf("\n---\n", 3);
1798
+ if (close === -1 || close > MAX_FRONT_MATTER_CHARS) return markdown;
1799
+ const entries = [];
1800
+ for (const line of markdown.slice(4, close).split("\n")) {
1801
+ const m = FRONT_MATTER_KEY.exec(line);
1802
+ if (m) entries.push({ key: m[1].toLowerCase(), lines: [line] });
1803
+ else if (entries.length) entries[entries.length - 1].lines.push(line);
1804
+ }
1805
+ const kept = [];
1806
+ for (const slot of FRONT_MATTER_SLOTS) {
1807
+ const entry = slot.map((key) => entries.find((e) => e.key === key)).find((e) => e !== void 0);
1808
+ if (entry) kept.push(...entry.lines);
1809
+ }
1810
+ const rest = markdown.slice(close + 5).replace(/^\n+/, "");
1811
+ return kept.length ? `---
1812
+ ${kept.join("\n")}
1813
+ ---
1814
+
1815
+ ${rest}` : rest;
1816
+ }
1817
+ function cleanConvertedMarkdown(markdown) {
1818
+ return stripEmptyFragmentLinks(stripImageLinks(trimFrontMatter(markdown))).replace(/\n{3,}/g, "\n\n").trim();
1819
+ }
1788
1820
  function stripImageLinks(markdown) {
1789
1821
  return markdown.replace(MARKDOWN_IMAGE_PATTERN, "").replace(IMAGE_LINK_PATTERN, "$1").replace(/\n{3,}/g, "\n\n").trim();
1790
1822
  }
@@ -1798,7 +1830,7 @@ function createNativeMarkdownConverter(nativeModule) {
1798
1830
  codeBlockStyle: nativeModule.CodeBlockStyle.Backticks,
1799
1831
  wrap: false
1800
1832
  });
1801
- return stripImageLinks(result.content ?? "");
1833
+ return cleanConvertedMarkdown(result.content ?? "");
1802
1834
  };
1803
1835
  }
1804
1836
  function createJsMarkdownConverter() {
@@ -1809,7 +1841,7 @@ function createJsMarkdownConverter() {
1809
1841
  });
1810
1842
  return async (html) => {
1811
1843
  const markdown = converter.translate(html);
1812
- return stripImageLinks(markdown);
1844
+ return cleanConvertedMarkdown(markdown);
1813
1845
  };
1814
1846
  }
1815
1847
  function validateContent(html, markdown, url) {
@@ -1848,7 +1880,7 @@ function validateContent(html, markdown, url) {
1848
1880
  throw error;
1849
1881
  }
1850
1882
  }
1851
- var cachedSsrfFetcher;
1883
+ var cachedSsrfFetcher, FRONT_MATTER_SLOTS, MAX_FRONT_MATTER_CHARS, FRONT_MATTER_KEY;
1852
1884
  var init_scraper_utils = __esm({
1853
1885
  "src/web-research/scraper-utils.ts"() {
1854
1886
  "use strict";
@@ -1858,6 +1890,23 @@ var init_scraper_utils = __esm({
1858
1890
  init_scraper_types();
1859
1891
  init_browser_error_utils();
1860
1892
  cachedSsrfFetcher = void 0;
1893
+ FRONT_MATTER_SLOTS = [
1894
+ ["title", "meta-og:title", "meta-citation_title"],
1895
+ ["meta-description", "description", "meta-og:description", "meta-twitter:description"],
1896
+ ["meta-author", "meta-article:author", "meta-citation_author", "meta-dc.creator", "meta-dcterms.creator"],
1897
+ [
1898
+ "meta-article:published_time",
1899
+ "meta-citation_publication_date",
1900
+ "meta-citation_date",
1901
+ "meta-dcterms.date",
1902
+ "meta-dc.date",
1903
+ "meta-date",
1904
+ "meta-dcterms.created"
1905
+ ],
1906
+ ["meta-article:modified_time", "meta-dcterms.modified"]
1907
+ ];
1908
+ MAX_FRONT_MATTER_CHARS = 2e4;
1909
+ FRONT_MATTER_KEY = /^([\w.:-]+):(?:\s|$)/;
1861
1910
  }
1862
1911
  });
1863
1912
 
@@ -54,6 +54,13 @@ export const BOT_PATTERNS: ReadonlyArray<[string, string]> = [
54
54
 
55
55
  export const IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
56
56
  export const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
57
+ /**
58
+ * `[](#fragment)` — a link with no text to a spot on the same page (optional
59
+ * title). Documentation generators put one before every code line (Quarto,
60
+ * nbdev: `<a href="#cb2-1" aria-hidden="true"></a>`) and next to headings; the
61
+ * converters turn each into `[](#cb2-1)`, which carries no text.
62
+ */
63
+ export const EMPTY_FRAGMENT_LINK_PATTERN = /\[\]\(#[^)\s]*(?:\s+"[^"\n]*")?\)/g;
57
64
 
58
65
  export type { ScrapeLayerResult } from './types.ts';
59
66
 
@@ -15,6 +15,7 @@ import {
15
15
  FILTERED_TAGS,
16
16
  IMAGE_LINK_PATTERN,
17
17
  MARKDOWN_IMAGE_PATTERN,
18
+ EMPTY_FRAGMENT_LINK_PATTERN,
18
19
  BOT_PATTERNS,
19
20
  INTERNAL_NETWORK_PATTERNS,
20
21
  type NativeHtmlToMarkdownModule,
@@ -485,6 +486,76 @@ function isMappedLoopback(ip: string): boolean {
485
486
  return mapped !== null && /^127\./.test(mapped);
486
487
  }
487
488
 
489
+ /**
490
+ * Drop empty same-page links (`[](#cb2-1)`): per-line code anchors and heading
491
+ * anchors that documentation generators emit. They carry no text, but repeat on
492
+ * every code line and every heading, so they cost tokens and add noise to the
493
+ * knowledge store index. Links with text, and empty links to other pages, stay.
494
+ */
495
+ export function stripEmptyFragmentLinks(markdown: string): string {
496
+ return markdown.replace(EMPTY_FRAGMENT_LINK_PATTERN, '');
497
+ }
498
+
499
+ /**
500
+ * Front-matter keys kept by trimFrontMatter, one slot per piece of information;
501
+ * the first key present in a slot wins (the native converter prefixes <meta>
502
+ * names with `meta-`, keeping Open Graph / Dublin Core / citation prefixes).
503
+ */
504
+ const FRONT_MATTER_SLOTS: ReadonlyArray<readonly string[]> = [
505
+ ['title', 'meta-og:title', 'meta-citation_title'],
506
+ ['meta-description', 'description', 'meta-og:description', 'meta-twitter:description'],
507
+ ['meta-author', 'meta-article:author', 'meta-citation_author', 'meta-dc.creator', 'meta-dcterms.creator'],
508
+ ['meta-article:published_time', 'meta-citation_publication_date', 'meta-citation_date', 'meta-dcterms.date',
509
+ 'meta-dc.date', 'meta-date', 'meta-dcterms.created'],
510
+ ['meta-article:modified_time', 'meta-dcterms.modified'],
511
+ ];
512
+ /** Front matter longer than this is not the converter's (e.g. a Markdown file's own); leave it. */
513
+ const MAX_FRONT_MATTER_CHARS = 20_000;
514
+ /** A front-matter entry starts with `key:`; other lines continue the previous value. */
515
+ const FRONT_MATTER_KEY = /^([\w.:-]+):(?:\s|$)/;
516
+
517
+ /**
518
+ * Trim the front matter the native converter builds from <meta> tags to the
519
+ * lines a reader can use: title, description, author, publication and
520
+ * modification date. The rest (analytics and error-reporting URLs, verification
521
+ * hashes, nonces, app ids, Open Graph / Twitter duplicates of the title and
522
+ * description, viewport, robots…) is noise: 54 lines on a GitHub page. Nothing
523
+ * in pi-research parses these lines. The block is dropped when none of the kept
524
+ * keys is present.
525
+ */
526
+ export function trimFrontMatter(markdown: string): string {
527
+ if (!markdown.startsWith('---\n')) return markdown;
528
+ const close = markdown.indexOf('\n---\n', 3);
529
+ if (close === -1 || close > MAX_FRONT_MATTER_CHARS) return markdown;
530
+
531
+ const entries: Array<{ key: string; lines: string[] }> = [];
532
+ for (const line of markdown.slice(4, close).split('\n')) {
533
+ const m = FRONT_MATTER_KEY.exec(line);
534
+ if (m) entries.push({ key: m[1]!.toLowerCase(), lines: [line] });
535
+ else if (entries.length) entries[entries.length - 1]!.lines.push(line);
536
+ }
537
+ const kept: string[] = [];
538
+ for (const slot of FRONT_MATTER_SLOTS) {
539
+ const entry = slot.map((key) => entries.find((e) => e.key === key)).find((e) => e !== undefined);
540
+ if (entry) kept.push(...entry.lines);
541
+ }
542
+ const rest = markdown.slice(close + 5).replace(/^\n+/, '');
543
+ return kept.length ? `---\n${kept.join('\n')}\n---\n\n${rest}` : rest;
544
+ }
545
+
546
+ /**
547
+ * Converter post-processing, shared by the native and the JS converter. The
548
+ * front matter is trimmed first. Images go before empty links: a heading anchor
549
+ * often wraps an icon (GitHub: `[![](link.svg)](#title)`) and only becomes an
550
+ * empty link once the image is gone. Removing a link that stood on its own line
551
+ * leaves extra blank lines, so they are collapsed again.
552
+ */
553
+ export function cleanConvertedMarkdown(markdown: string): string {
554
+ return stripEmptyFragmentLinks(stripImageLinks(trimFrontMatter(markdown)))
555
+ .replace(/\n{3,}/g, '\n\n')
556
+ .trim();
557
+ }
558
+
488
559
  /**
489
560
  * Strip image links from markdown
490
561
  */
@@ -511,7 +582,7 @@ export function createNativeMarkdownConverter(
511
582
  codeBlockStyle: nativeModule.CodeBlockStyle.Backticks,
512
583
  wrap: false,
513
584
  });
514
- return stripImageLinks(result.content ?? '');
585
+ return cleanConvertedMarkdown(result.content ?? '');
515
586
  };
516
587
  }
517
588
 
@@ -527,7 +598,7 @@ export function createJsMarkdownConverter(): (html: string) => Promise<string> {
527
598
 
528
599
  return async (html: string): Promise<string> => {
529
600
  const markdown = converter.translate(html);
530
- return stripImageLinks(markdown);
601
+ return cleanConvertedMarkdown(markdown);
531
602
  };
532
603
  }
533
604