@taurgis/bonsai 1.0.3 → 1.0.5

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.
Files changed (72) hide show
  1. package/README.md +38 -6
  2. package/agents/agents/salesforce-docs-researcher.agent.md +117 -0
  3. package/agents/hooks/README.md +62 -0
  4. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +22 -0
  5. package/agents/hooks/claude-code/settings.json +16 -0
  6. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +18 -0
  7. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +22 -0
  8. package/agents/hooks/codex/hooks.json +28 -0
  9. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +21 -0
  10. package/agents/hooks/cursor/hooks.json +12 -0
  11. package/agents/hooks/github-copilot/bonsai-web-fetch.json +23 -0
  12. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +19 -0
  13. package/agents/instructions/salesforce-research.instructions.md +57 -0
  14. package/agents/instructions/web-research.instructions.md +2 -1
  15. package/bin/cli.mjs +13 -3
  16. package/dist/base-command.d.ts +20 -0
  17. package/dist/base-command.js +27 -1
  18. package/dist/commands/config/get.js +2 -2
  19. package/dist/commands/config/list.js +2 -2
  20. package/dist/commands/config/set.js +2 -2
  21. package/dist/commands/config/unset.js +2 -2
  22. package/dist/commands/fetch.d.ts +5 -0
  23. package/dist/commands/fetch.js +98 -27
  24. package/dist/commands/import.d.ts +1 -0
  25. package/dist/commands/import.js +51 -11
  26. package/dist/commands/list.js +18 -3
  27. package/dist/commands/prune.js +22 -6
  28. package/dist/commands/search.js +31 -30
  29. package/dist/commands/status.js +10 -9
  30. package/dist/hooks/command-not-found/suggest.d.ts +9 -0
  31. package/dist/hooks/command-not-found/suggest.js +93 -0
  32. package/dist/lib/config/index.d.ts +8 -3
  33. package/dist/lib/config/index.js +19 -4
  34. package/dist/lib/config/resolve.d.ts +21 -1
  35. package/dist/lib/config/resolve.js +42 -1
  36. package/dist/lib/config/schema.d.ts +3 -0
  37. package/dist/lib/config/schema.js +11 -1
  38. package/dist/lib/research/artifact.d.ts +13 -2
  39. package/dist/lib/research/artifact.js +27 -7
  40. package/dist/lib/research/browser.d.ts +12 -0
  41. package/dist/lib/research/browser.js +40 -3
  42. package/dist/lib/research/compress.d.ts +19 -0
  43. package/dist/lib/research/compress.js +31 -0
  44. package/dist/lib/research/docs/capabilities.d.ts +1 -1
  45. package/dist/lib/research/docs/code-blocks.js +24 -0
  46. package/dist/lib/research/docs/discovery-index.d.ts +14 -0
  47. package/dist/lib/research/docs/discovery-index.js +112 -0
  48. package/dist/lib/research/docs/machine-readable.d.ts +2 -2
  49. package/dist/lib/research/docs/machine-readable.js +3 -7
  50. package/dist/lib/research/docs/remote-search-runner.js +61 -1
  51. package/dist/lib/research/docs/section-artifacts.d.ts +3 -2
  52. package/dist/lib/research/docs/section-artifacts.js +5 -5
  53. package/dist/lib/research/docs/sections.d.ts +1 -0
  54. package/dist/lib/research/docs/sections.js +19 -1
  55. package/dist/lib/research/freshness.d.ts +9 -1
  56. package/dist/lib/research/freshness.js +24 -2
  57. package/dist/lib/research/keywords.d.ts +24 -0
  58. package/dist/lib/research/keywords.js +247 -0
  59. package/dist/lib/research/revalidate.d.ts +3 -1
  60. package/dist/lib/research/revalidate.js +8 -4
  61. package/dist/lib/research/search-index.d.ts +18 -0
  62. package/dist/lib/research/search-index.js +109 -0
  63. package/dist/lib/research/secret-scan.js +4 -2
  64. package/dist/lib/research/storage.d.ts +1 -0
  65. package/dist/lib/research/storage.js +1 -1
  66. package/dist/lib/research/summarize.d.ts +17 -0
  67. package/dist/lib/research/summarize.js +400 -0
  68. package/dist/lib/research/url.js +5 -2
  69. package/dist/lib/text.d.ts +33 -0
  70. package/dist/lib/text.js +63 -0
  71. package/oclif.manifest.json +8 -8
  72. package/package.json +17 -3
@@ -1,5 +1,24 @@
1
+ import { type SummaryLevel } from './summarize.js';
1
2
  /**
2
3
  * Compresses Markdown text by removing images, simplifying links from [text](url) to [text],
3
4
  * and collapsing multiple consecutive blank lines.
4
5
  */
5
6
  export declare function compressMarkdown(markdown: string): string;
7
+ /**
8
+ * Produces the `compressed` form of fetched content. Structural compression (strip images,
9
+ * simplify links, collapse blank lines) runs first, then the dependency-free extractive summarizer
10
+ * condenses prose to the chosen aggressiveness while keeping headings, code, tables, and lists
11
+ * intact. The summarizer self-guards: documents below the token floor, or with no prose to drop
12
+ * (e.g. pure structure once links/images are stripped), come back unchanged. Running it
13
+ * unconditionally — rather than only as a fallback when structural compression underperforms — is
14
+ * what makes the `summary` level matter even on link-heavy pages that structural compression
15
+ * already shrinks. This is the single place the "how do we build compressed" policy lives.
16
+ *
17
+ * A trust guard backs all of this: `compressed` is only useful if it is a non-empty, no-larger view
18
+ * of `detailed`. Both steps only ever remove content, so that should always hold — but rather than
19
+ * assume it, we verify and fall back through progressively safer forms (summarized → structural →
20
+ * detailed). This means the cache can never end up serving a `compressed` that is larger than
21
+ * `detailed`, or one that is empty while `detailed` has content — if the auto-compaction can't be
22
+ * trusted, the caller transparently gets the (verified) structural form or the detailed text itself.
23
+ */
24
+ export declare function buildCompressed(detailed: string, level: SummaryLevel): string;
@@ -1,3 +1,6 @@
1
+ import { summarizeMarkdown } from './summarize.js';
2
+ // Below this token count a page is never extractively summarized — there is nothing to gain.
3
+ const SUMMARY_FLOOR_TOKENS = 200;
1
4
  /**
2
5
  * Compresses Markdown text by removing images, simplifying links from [text](url) to [text],
3
6
  * and collapsing multiple consecutive blank lines.
@@ -15,3 +18,31 @@ export function compressMarkdown(markdown) {
15
18
  result = result.replace(/\n{3,}/g, '\n\n');
16
19
  return result.trim();
17
20
  }
21
+ /**
22
+ * Produces the `compressed` form of fetched content. Structural compression (strip images,
23
+ * simplify links, collapse blank lines) runs first, then the dependency-free extractive summarizer
24
+ * condenses prose to the chosen aggressiveness while keeping headings, code, tables, and lists
25
+ * intact. The summarizer self-guards: documents below the token floor, or with no prose to drop
26
+ * (e.g. pure structure once links/images are stripped), come back unchanged. Running it
27
+ * unconditionally — rather than only as a fallback when structural compression underperforms — is
28
+ * what makes the `summary` level matter even on link-heavy pages that structural compression
29
+ * already shrinks. This is the single place the "how do we build compressed" policy lives.
30
+ *
31
+ * A trust guard backs all of this: `compressed` is only useful if it is a non-empty, no-larger view
32
+ * of `detailed`. Both steps only ever remove content, so that should always hold — but rather than
33
+ * assume it, we verify and fall back through progressively safer forms (summarized → structural →
34
+ * detailed). This means the cache can never end up serving a `compressed` that is larger than
35
+ * `detailed`, or one that is empty while `detailed` has content — if the auto-compaction can't be
36
+ * trusted, the caller transparently gets the (verified) structural form or the detailed text itself.
37
+ */
38
+ export function buildCompressed(detailed, level) {
39
+ const structural = compressMarkdown(detailed);
40
+ const summarized = summarizeMarkdown(structural, { level, minTokens: SUMMARY_FLOOR_TOKENS });
41
+ // Trustworthy = not larger than detailed, and not empty unless detailed is itself empty.
42
+ const trustworthy = (candidate) => candidate.length <= detailed.length && (candidate.trim() !== '' || detailed.trim() === '');
43
+ if (trustworthy(summarized))
44
+ return summarized;
45
+ if (trustworthy(structural))
46
+ return structural;
47
+ return detailed;
48
+ }
@@ -15,7 +15,7 @@ export interface SourceHint {
15
15
  path?: string;
16
16
  evidence: Evidence;
17
17
  }
18
- export type SearchProvider = 'algolia-docsearch' | 'vitepress-local' | 'pagefind' | 'docusaurus-local' | 'mkdocs-local' | 'sphinx-searchindex' | 'jekyll-json' | 'readme-algolia' | 'custom';
18
+ export type SearchProvider = 'algolia-docsearch' | 'vitepress-local' | 'pagefind' | 'docusaurus-local' | 'mkdocs-local' | 'sphinx-searchindex' | 'jekyll-json' | 'sitemap' | 'llms.txt' | 'readme-algolia' | 'custom';
19
19
  export interface SearchCapability {
20
20
  provider: SearchProvider;
21
21
  evidence: Evidence;
@@ -42,6 +42,12 @@ function reconstructLines(code) {
42
42
  function removeCopyButtons(document) {
43
43
  const candidates = document.querySelectorAll('button, [class*="copy" i], [class*="Copy"], [aria-label*="copy" i]');
44
44
  for (const el of candidates) {
45
+ // Never remove a container that wraps a code block. Some highlighters put a copy-related class
46
+ // on the <pre>'s wrapper (e.g. Elementor's `<div class="copy-to-clipboard"><pre>…`); removing
47
+ // it would delete the entire code example, not just the button. Guard on <pre> specifically: a
48
+ // real copy button never contains a <pre>, but may contain a decorative <code> icon.
49
+ if (el.querySelector?.('pre'))
50
+ continue;
45
51
  const cls = (el.getAttribute('class') || '').toString();
46
52
  const aria = el.getAttribute('aria-label') || '';
47
53
  const text = (el.textContent || '').trim();
@@ -50,6 +56,23 @@ function removeCopyButtons(document) {
50
56
  }
51
57
  }
52
58
  }
59
+ // Some highlighters (e.g. Elementor's code-highlight widget) wrap the authored source in a
60
+ // deprecated <xmp>/<plaintext> element and pretty-print the markup, so <code> is not <pre>'s first
61
+ // child. Turndown's fenced-code rule requires <code> as the first child; without this the block
62
+ // collapses onto a single inline span. Flatten to a clean <pre><code>…</code></pre>.
63
+ function flattenCodeStructure(pre, code) {
64
+ if (code === pre)
65
+ return;
66
+ const rawHolder = code.querySelector?.('xmp, plaintext');
67
+ const raw = rawHolder?.textContent ?? '';
68
+ if (raw)
69
+ code.textContent = raw;
70
+ if (pre.firstChild !== code || pre.childNodes.length > 1) {
71
+ while (pre.firstChild)
72
+ pre.removeChild(pre.firstChild);
73
+ pre.appendChild(code);
74
+ }
75
+ }
53
76
  /**
54
77
  * Reconstructs newline-delimited text for every highlighted code block in the document and tags it
55
78
  * with a `language-x` class when one is detectable. Idempotent and a no-op on plain `<pre>` blocks.
@@ -65,5 +88,6 @@ export function normalizeCodeBlocks(document) {
65
88
  const existing = (code.getAttribute('class') || '').replace(/language-[\w-]+/gi, '').trim();
66
89
  code.setAttribute('class', `${existing} language-${language}`.trim());
67
90
  }
91
+ flattenCodeStructure(pre, code);
68
92
  }
69
93
  }
@@ -0,0 +1,14 @@
1
+ import type { DocsSearchResult } from './search-index.js';
2
+ interface DiscoveryEntry {
3
+ title: string;
4
+ url: string;
5
+ body?: string;
6
+ }
7
+ export interface SitemapParseResult {
8
+ entries: DiscoveryEntry[];
9
+ sitemapUrls: string[];
10
+ }
11
+ export declare function parseSitemap(body: string): SitemapParseResult;
12
+ export declare function searchSitemap(body: string, indexUrl: string, query: string): DocsSearchResult[];
13
+ export declare function searchLlmsTxt(body: string, indexUrl: string, query: string): DocsSearchResult[];
14
+ export {};
@@ -0,0 +1,112 @@
1
+ import { DOMParser } from 'linkedom';
2
+ function queryTerms(query) {
3
+ return query.toLowerCase().split(/\s+/).filter(Boolean);
4
+ }
5
+ function scoreEntry(entry, terms) {
6
+ const haystack = `${entry.title} ${entry.url} ${entry.body ?? ''}`.toLowerCase();
7
+ let score = 0;
8
+ for (const term of terms) {
9
+ if (entry.title.toLowerCase().includes(term))
10
+ score += 10;
11
+ if (entry.url.toLowerCase().includes(term))
12
+ score += 5;
13
+ score += Math.min(haystack.split(term).length - 1, 5);
14
+ }
15
+ return score;
16
+ }
17
+ function snippetFrom(entry) {
18
+ return (entry.body || entry.url).replace(/\s+/g, ' ').trim().slice(0, 150);
19
+ }
20
+ function titleFromUrl(url) {
21
+ try {
22
+ const parsed = new URL(url);
23
+ const last = parsed.pathname.split('/').filter(Boolean).pop();
24
+ return last ? last.replace(/\.[a-z0-9]+$/i, '').replace(/[-_]+/g, ' ') : parsed.hostname;
25
+ }
26
+ catch {
27
+ return url;
28
+ }
29
+ }
30
+ function toResults(entries, indexUrl, provider, query) {
31
+ const terms = queryTerms(query);
32
+ return entries
33
+ .map((entry) => ({ entry, score: scoreEntry(entry, terms) }))
34
+ .filter(({ score }) => score > 0)
35
+ .map(({ entry, score }) => ({
36
+ title: entry.title,
37
+ url: entry.url,
38
+ snippet: snippetFrom(entry),
39
+ provider,
40
+ score,
41
+ indexUrl,
42
+ }))
43
+ .sort((a, b) => b.score - a.score);
44
+ }
45
+ function textContent(element) {
46
+ const text = element?.textContent?.trim();
47
+ return text || undefined;
48
+ }
49
+ export function parseSitemap(body) {
50
+ const trimmed = body.trim();
51
+ if (!trimmed)
52
+ return { entries: [], sitemapUrls: [] };
53
+ if (!trimmed.startsWith('<')) {
54
+ const entries = trimmed
55
+ .split(/\r?\n/)
56
+ .map((line) => line.trim())
57
+ .filter((line) => /^https?:\/\//i.test(line))
58
+ .map((url) => ({ title: titleFromUrl(url), url }));
59
+ return { entries, sitemapUrls: [] };
60
+ }
61
+ const doc = new DOMParser().parseFromString(trimmed, 'text/xml');
62
+ const sitemapUrls = Array.from(doc.querySelectorAll('sitemap > loc'))
63
+ .map((loc) => textContent(loc))
64
+ .filter((url) => !!url);
65
+ const entries = Array.from(doc.querySelectorAll('url'))
66
+ .map((urlElement) => {
67
+ const url = textContent(urlElement.querySelector('loc'));
68
+ if (!url)
69
+ return null;
70
+ const lastmod = textContent(urlElement.querySelector('lastmod'));
71
+ return { title: titleFromUrl(url), url, body: lastmod ? `Last modified ${lastmod}` : '' };
72
+ })
73
+ .filter((entry) => !!entry);
74
+ return { entries, sitemapUrls };
75
+ }
76
+ export function searchSitemap(body, indexUrl, query) {
77
+ return toResults(parseSitemap(body).entries, indexUrl, 'sitemap', query);
78
+ }
79
+ function referenceDefinitions(body) {
80
+ const refs = new Map();
81
+ for (const match of body.matchAll(/^\s*\[([^\]]+)\]:\s*(\S+)/gm)) {
82
+ refs.set(match[1].toLowerCase(), match[2]);
83
+ }
84
+ return refs;
85
+ }
86
+ export function searchLlmsTxt(body, indexUrl, query) {
87
+ const refs = referenceDefinitions(body);
88
+ const entries = [];
89
+ let section = '';
90
+ for (const line of body.split(/\r?\n/)) {
91
+ const heading = line.match(/^##\s+(.+?)\s*$/);
92
+ if (heading)
93
+ section = heading[1];
94
+ const inline = line.match(/^\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*:?\s*(.*)$/);
95
+ const ref = line.match(/^\s*[-*]\s+\[([^\]]+)\]\s*:?\s*(.*)$/);
96
+ const title = inline?.[1] ?? ref?.[1];
97
+ const url = inline?.[2] ?? (title ? refs.get(title.toLowerCase()) : undefined);
98
+ if (!title || !url)
99
+ continue;
100
+ try {
101
+ entries.push({
102
+ title,
103
+ url: new URL(url, indexUrl).toString(),
104
+ body: [section, inline?.[3] ?? ref?.[2]].filter(Boolean).join(' - '),
105
+ });
106
+ }
107
+ catch {
108
+ // Ignore malformed links in untrusted machine-readable text.
109
+ }
110
+ }
111
+ return toResults(entries, indexUrl, 'llms.txt', query);
112
+ }
@@ -15,7 +15,7 @@ export type TextFetcher = (url: string) => Promise<{
15
15
  */
16
16
  export declare function probeLlmsTxt(pageUrl: string, caps: SiteCapabilities, fetcher?: TextFetcher): Promise<MachineReadableResult | null>;
17
17
  /**
18
- * Probes a page-level `.md` route, but only when the engine/framework is known to serve one or the
19
- * page advertises one. Returns validated Markdown source for the page, or null.
18
+ * Probes a page-level `.md` route. The derived route is only a candidate: it must be same-origin,
19
+ * fetch successfully, and validate as non-HTML/non-error Markdown before being trusted.
20
20
  */
21
21
  export declare function probeRouteMarkdown(pageUrl: string, caps: SiteCapabilities, fetcher?: TextFetcher): Promise<MachineReadableResult | null>;
@@ -2,10 +2,6 @@ import { fetchText } from '../fetcher.js';
2
2
  import { isSameDocsOrigin, validateTextArtifact } from './validate.js';
3
3
  import { vitepressRouteMarkdown } from './source-map.js';
4
4
  const defaultFetcher = (url) => fetchText(url);
5
- // Frameworks/engines that the research observed serving page-level `.md` routes (T-24 evidence).
6
- function advertisesRouteMarkdown(caps) {
7
- return (caps.docsEngine === 'vitepress' || caps.framework === 'rspress' || caps.framework === 'gitbook');
8
- }
9
5
  function llmsTxtCandidates(pageUrl, caps) {
10
6
  const advertised = caps.machineReadable
11
7
  .filter((m) => m.type === 'llms.txt' || m.type === 'llms-full.txt')
@@ -52,14 +48,14 @@ export async function probeLlmsTxt(pageUrl, caps, fetcher = defaultFetcher) {
52
48
  return null;
53
49
  }
54
50
  /**
55
- * Probes a page-level `.md` route, but only when the engine/framework is known to serve one or the
56
- * page advertises one. Returns validated Markdown source for the page, or null.
51
+ * Probes a page-level `.md` route. The derived route is only a candidate: it must be same-origin,
52
+ * fetch successfully, and validate as non-HTML/non-error Markdown before being trusted.
57
53
  */
58
54
  export async function probeRouteMarkdown(pageUrl, caps, fetcher = defaultFetcher) {
59
55
  const advertised = caps.machineReadable
60
56
  .filter((m) => m.type === 'route-markdown')
61
57
  .map((m) => m.url);
62
- const derived = advertisesRouteMarkdown(caps) ? [vitepressRouteMarkdown(pageUrl)] : [];
58
+ const derived = [vitepressRouteMarkdown(pageUrl)];
63
59
  const candidates = [...new Set([...advertised, ...derived].filter((u) => !!u))];
64
60
  for (const candidate of candidates) {
65
61
  if (!isSameDocsOrigin(candidate, pageUrl))
@@ -1,5 +1,7 @@
1
1
  import { detectDocsEngine } from './detect.js';
2
+ import { probeLlmsTxt } from './machine-readable.js';
2
3
  import { searchMkDocsIndex, searchJekyllIndex, searchSphinxIndex } from './search-index.js';
4
+ import { parseSitemap, searchLlmsTxt, searchSitemap } from './discovery-index.js';
3
5
  import { extractAlgoliaConfig, algoliaQueryUrl, parseAlgoliaResponse } from './remote-search.js';
4
6
  async function getText(url, deps) {
5
7
  const res = await deps.fetchText(url);
@@ -7,6 +9,14 @@ async function getText(url, deps) {
7
9
  throw new Error(`empty search index at ${url}`);
8
10
  return res.content;
9
11
  }
12
+ function sameHost(url, base) {
13
+ try {
14
+ return new URL(url).hostname === new URL(base).hostname;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
10
20
  // Conventional static index path + parser per framework (verified by T-25 fixtures).
11
21
  async function staticIndexSearch(framework, origin, query, deps) {
12
22
  if (framework === 'mkdocs' || framework === 'material-mkdocs') {
@@ -32,6 +42,50 @@ async function staticIndexSearch(framework, origin, query, deps) {
32
42
  }
33
43
  return null;
34
44
  }
45
+ async function llmsTxtSearch(docsUrl, caps, query, deps) {
46
+ const llms = await probeLlmsTxt(docsUrl, caps, async (url) => {
47
+ const res = await deps.fetchText(url);
48
+ return { ...res, finalUrl: url, contentType: 'text/plain' };
49
+ });
50
+ if (!llms)
51
+ return null;
52
+ return {
53
+ provider: llms.artifact.type,
54
+ results: searchLlmsTxt(llms.body, llms.artifact.url, query),
55
+ };
56
+ }
57
+ async function sitemapSearch(pageUrl, query, deps) {
58
+ const origin = new URL(pageUrl).origin;
59
+ const firstSegment = new URL(pageUrl).pathname.split('/').filter(Boolean)[0];
60
+ const candidates = [
61
+ `${origin}/sitemap.xml`,
62
+ `${origin}/sitemap_index.xml`,
63
+ firstSegment ? `${origin}/${firstSegment}/sitemap.xml` : undefined,
64
+ ].filter((url) => !!url);
65
+ for (const sitemapUrl of [...new Set(candidates)]) {
66
+ try {
67
+ const body = await getText(sitemapUrl, deps);
68
+ const results = searchSitemap(body, sitemapUrl, query);
69
+ if (results.length > 0)
70
+ return { provider: 'sitemap', results };
71
+ const index = parseSitemap(body);
72
+ const childResults = [];
73
+ for (const childUrl of index.sitemapUrls.slice(0, 10)) {
74
+ if (!sameHost(childUrl, pageUrl))
75
+ continue;
76
+ const childBody = await getText(childUrl, deps);
77
+ childResults.push(...searchSitemap(childBody, childUrl, query));
78
+ }
79
+ if (childResults.length > 0) {
80
+ return { provider: 'sitemap', results: childResults.sort((a, b) => b.score - a.score) };
81
+ }
82
+ }
83
+ catch {
84
+ // Try the next conventional sitemap location.
85
+ }
86
+ }
87
+ return null;
88
+ }
35
89
  /**
36
90
  * Runs remote docs search for a documentation site. Detects the provider from the page, queries the
37
91
  * matching connector, and returns normalized results. Throws when no verified connector applies.
@@ -49,6 +103,12 @@ export async function runRemoteDocsSearch(docsUrl, query, deps) {
49
103
  const staticResult = await staticIndexSearch(caps.framework, origin, query, deps);
50
104
  if (staticResult)
51
105
  return staticResult;
106
+ const llmsResult = await llmsTxtSearch(page.finalUrl, caps, query, deps);
107
+ if (llmsResult)
108
+ return llmsResult;
109
+ const sitemapResult = await sitemapSearch(page.finalUrl, query, deps);
110
+ if (sitemapResult)
111
+ return sitemapResult;
52
112
  throw new Error(`No verified remote search connector for this site (detected: ${caps.framework ?? caps.docsEngine ?? 'unknown'}). ` +
53
- 'Supported: Algolia DocSearch, MkDocs/Material, Sphinx, Just the Docs.');
113
+ 'Supported: Algolia DocSearch, MkDocs/Material, Sphinx, Just the Docs, llms.txt, sitemap.xml.');
54
114
  }
@@ -1,12 +1,13 @@
1
1
  import type { ResearchArtifact } from '../schema.js';
2
+ import type { SummaryLevel } from '../../config/schema.js';
2
3
  /**
3
4
  * Derives section child artifacts from a parent page artifact. Returns [] when the page is short
4
5
  * or has too few headings to chunk.
5
6
  */
6
- export declare function buildSectionArtifacts(parent: ResearchArtifact, currentTime: Date): ResearchArtifact[];
7
+ export declare function buildSectionArtifacts(parent: ResearchArtifact, currentTime: Date, summaryLevel: SummaryLevel): ResearchArtifact[];
7
8
  /**
8
9
  * Regenerates a page's section artifacts: writes fresh children and archives any prior child whose
9
10
  * heading no longer exists, so a revalidated parent never leaves orphaned sections active. Returns
10
11
  * the number of active section children written.
11
12
  */
12
- export declare function persistSectionArtifacts(dataDir: string, parent: ResearchArtifact, currentTime: Date): number;
13
+ export declare function persistSectionArtifacts(dataDir: string, parent: ResearchArtifact, currentTime: Date, summaryLevel: SummaryLevel): number;
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { join } from 'node:path';
3
- import { compressMarkdown } from '../compress.js';
3
+ import { buildCompressed } from '../compress.js';
4
4
  import { estimateTokens } from '../token-estimate.js';
5
5
  import { scanCacheDir, writeArtifact } from '../storage.js';
6
6
  import { splitMarkdownSections } from './sections.js';
@@ -17,7 +17,7 @@ function childKey(parentKey, anchor) {
17
17
  * Derives section child artifacts from a parent page artifact. Returns [] when the page is short
18
18
  * or has too few headings to chunk.
19
19
  */
20
- export function buildSectionArtifacts(parent, currentTime) {
20
+ export function buildSectionArtifacts(parent, currentTime, summaryLevel) {
21
21
  if (estimateTokens(parent.detailed) <= SECTION_TOKEN_THRESHOLD)
22
22
  return [];
23
23
  const sections = splitMarkdownSections(parent.detailed);
@@ -25,7 +25,7 @@ export function buildSectionArtifacts(parent, currentTime) {
25
25
  return sections.map((section) => {
26
26
  const key = childKey(pm.cache_key, section.anchor);
27
27
  const detailed = section.content;
28
- const compressed = compressMarkdown(detailed);
28
+ const compressed = buildCompressed(detailed, summaryLevel);
29
29
  return {
30
30
  metadata: {
31
31
  ...pm,
@@ -59,8 +59,8 @@ function activeChildrenOf(dataDir, parentKey) {
59
59
  * heading no longer exists, so a revalidated parent never leaves orphaned sections active. Returns
60
60
  * the number of active section children written.
61
61
  */
62
- export function persistSectionArtifacts(dataDir, parent, currentTime) {
63
- const children = buildSectionArtifacts(parent, currentTime);
62
+ export function persistSectionArtifacts(dataDir, parent, currentTime, summaryLevel) {
63
+ const children = buildSectionArtifacts(parent, currentTime, summaryLevel);
64
64
  const freshKeys = new Set(children.map((c) => c.metadata.cache_key));
65
65
  for (const orphan of activeChildrenOf(dataDir, parent.metadata.cache_key)) {
66
66
  if (!freshKeys.has(orphan.metadata.cache_key)) {
@@ -4,6 +4,7 @@ export interface SectionChunk {
4
4
  level: number;
5
5
  content: string;
6
6
  }
7
+ export declare function headingPlainText(raw: string): string;
7
8
  export declare function slugifyHeading(text: string): string;
8
9
  /**
9
10
  * Splits Markdown into H2/H3 section chunks. Returns an empty array when the document has fewer
@@ -2,6 +2,24 @@
2
2
  // big API reference (e.g. Node `url.html`, ~17.5k tokens) becomes addressable section artifacts.
3
3
  // Pure string work: no I/O, no artifact construction. H4+ headings stay within their parent chunk.
4
4
  const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
5
+ // Reduce a heading's inline Markdown to the plain text GitHub anchors and breadcrumbs are built from.
6
+ // GitHub strips formatting (links, emphasis, code) and slugs the *rendered* text, so a heading like
7
+ // `## [API documentation](https://prismjs.com/extending.html#api-documentation)` must collapse to
8
+ // "API documentation" — not the raw link syntax, which otherwise leaks the URL into the anchor and
9
+ // breadcrumb. Confirmed against github-slugger, which documents that it operates on plain heading
10
+ // text rather than parsing Markdown: https://github.com/Flet/github-slugger
11
+ export function headingPlainText(raw) {
12
+ // The `\(…\)` part tolerates one level of balanced parens so a real-world target like
13
+ // `URL_(disambiguation)` doesn't leave a dangling ")" in the rendered breadcrumb text.
14
+ return raw
15
+ .replace(/!\[([^\]]*)\]\([^()]*(?:\([^()]*\)[^()]*)*\)/g, '$1') // images: ![alt](url) -> alt
16
+ .replace(/\[([^\]]*)\]\([^()]*(?:\([^()]*\)[^()]*)*\)/g, '$1') // inline links: [text](url) -> text
17
+ .replace(/\[([^\]]*)\]\[[^\]]*\]/g, '$1') // reference links: [text][ref] -> text
18
+ .replace(/`([^`]*)`/g, '$1') // inline code: `code` -> code
19
+ .replace(/[*_~]/g, '') // emphasis / strikethrough markers
20
+ .replace(/\s+/g, ' ')
21
+ .trim();
22
+ }
5
23
  // GitHub-style anchor slug: lowercase, drop punctuation, spaces -> hyphens.
6
24
  export function slugifyHeading(text) {
7
25
  return text
@@ -22,7 +40,7 @@ function findHeadings(lines) {
22
40
  continue;
23
41
  const match = lines[i].match(HEADING_RE);
24
42
  if (match)
25
- out.push({ index: i, level: match[1].length, text: match[2].trim() });
43
+ out.push({ index: i, level: match[1].length, text: headingPlainText(match[2]) });
26
44
  }
27
45
  return out;
28
46
  }
@@ -4,9 +4,17 @@ export interface FreshnessPolicy {
4
4
  graceWindowMs: number;
5
5
  }
6
6
  /**
7
- * Parses simple TTL duration strings (e.g. '24h', '7d', '30d') to milliseconds.
7
+ * Parses simple TTL duration strings to milliseconds. The unit `m` is months (not minutes); the
8
+ * smallest unit is hours, since sub-hour cache lifespans are not meaningful for research artifacts.
8
9
  */
9
10
  export declare function parseTtlToMs(ttl: string): number;
11
+ /**
12
+ * Validate a duration-valued flag. Returns an actionable error message that names the
13
+ * offending flag when the value is malformed, or null when the flag is absent or valid.
14
+ * Callers map a non-null message onto `this.error(msg, { exit: 2 })`, so every command
15
+ * reports the same parse failure while naming the exact flag the user passed.
16
+ */
17
+ export declare function durationFlagError(flag: string, value: string | undefined): string | null;
10
18
  /**
11
19
  * Derives the active freshness policy based on the tier and optional TTL override.
12
20
  */
@@ -1,12 +1,17 @@
1
1
  const HOUR_MS = 3600 * 1000;
2
2
  const DAY_MS = 24 * HOUR_MS;
3
3
  /**
4
- * Parses simple TTL duration strings (e.g. '24h', '7d', '30d') to milliseconds.
4
+ * Parses simple TTL duration strings to milliseconds. The unit `m` is months (not minutes); the
5
+ * smallest unit is hours, since sub-hour cache lifespans are not meaningful for research artifacts.
5
6
  */
6
7
  export function parseTtlToMs(ttl) {
7
8
  const match = ttl.match(/^(\d+)([hdwmy])$/);
8
9
  if (!match) {
9
- throw new Error(`Invalid TTL format "${ttl}". Use formats like '24h', '7d', '30d'.`);
10
+ // Deliberately does not start with "Invalid": durationFlagError wraps this as
11
+ // "Invalid <flag>: <message>", and a leading "Invalid" here produced a "Invalid --ttl:
12
+ // Invalid TTL format" stutter.
13
+ throw new Error(`Duration "${ttl}" is not a valid format. Use a whole number plus a unit: ` +
14
+ `h (hours), d (days), w (weeks), m (months), or y (years), e.g. '24h', '7d', '6m'.`);
10
15
  }
11
16
  const val = parseInt(match[1] || '', 10);
12
17
  const unit = match[2] || '';
@@ -25,6 +30,23 @@ export function parseTtlToMs(ttl) {
25
30
  return 0;
26
31
  }
27
32
  }
33
+ /**
34
+ * Validate a duration-valued flag. Returns an actionable error message that names the
35
+ * offending flag when the value is malformed, or null when the flag is absent or valid.
36
+ * Callers map a non-null message onto `this.error(msg, { exit: 2 })`, so every command
37
+ * reports the same parse failure while naming the exact flag the user passed.
38
+ */
39
+ export function durationFlagError(flag, value) {
40
+ if (!value)
41
+ return null;
42
+ try {
43
+ parseTtlToMs(value);
44
+ return null;
45
+ }
46
+ catch (err) {
47
+ return `Invalid ${flag}: ${err.message}`;
48
+ }
49
+ }
28
50
  /**
29
51
  * Derives the active freshness policy based on the tier and optional TTL override.
30
52
  */
@@ -0,0 +1,24 @@
1
+ import type { ResearchArtifact } from './schema.js';
2
+ export declare const STOPWORDS: Set<string>;
3
+ export declare function tokenize(text: string): string[];
4
+ /**
5
+ * Derives up to `max` keyword tags from free-form Markdown/text by term frequency.
6
+ * Code blocks, inline code, and URLs are stripped first; stopwords, pure numbers, and very short
7
+ * tokens are dropped; terms appearing in Markdown headings get a frequency boost. Ranking is
8
+ * deterministic (frequency desc, then alphabetical) so the same input always yields the same tags.
9
+ */
10
+ export declare function extractKeywords(text: string, max?: number): string[];
11
+ /**
12
+ * Removes duplicate and blank tags, comparing case-insensitively (search/list filtering already
13
+ * matches tags case-insensitively, so "node" and "Node" are the same tag) while preserving
14
+ * first-seen order and the original casing of the kept tag. Whitespace is trimmed.
15
+ */
16
+ export declare function dedupeTags(tags: string[]): string[];
17
+ /**
18
+ * Back-fills keyword tags for an artifact that has none, deriving them from its detailed content and
19
+ * recording a quality note. When tags are already present they win, but are still deduped/trimmed so
20
+ * caller-supplied tags do not persist blank or duplicate entries. Mutates and returns the same
21
+ * artifact. This is the single entry point every ingestion path (import, fetch, revalidation) uses,
22
+ * so tag hygiene lives in exactly one place.
23
+ */
24
+ export declare function applyAutoTags(artifact: ResearchArtifact): ResearchArtifact;