@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.
- package/README.md +38 -6
- package/agents/agents/salesforce-docs-researcher.agent.md +117 -0
- package/agents/hooks/README.md +62 -0
- package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +22 -0
- package/agents/hooks/claude-code/settings.json +16 -0
- package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +18 -0
- package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +22 -0
- package/agents/hooks/codex/hooks.json +28 -0
- package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +21 -0
- package/agents/hooks/cursor/hooks.json +12 -0
- package/agents/hooks/github-copilot/bonsai-web-fetch.json +23 -0
- package/agents/hooks/github-copilot/bonsai-web-fetch.sh +19 -0
- package/agents/instructions/salesforce-research.instructions.md +57 -0
- package/agents/instructions/web-research.instructions.md +2 -1
- package/bin/cli.mjs +13 -3
- package/dist/base-command.d.ts +20 -0
- package/dist/base-command.js +27 -1
- package/dist/commands/config/get.js +2 -2
- package/dist/commands/config/list.js +2 -2
- package/dist/commands/config/set.js +2 -2
- package/dist/commands/config/unset.js +2 -2
- package/dist/commands/fetch.d.ts +5 -0
- package/dist/commands/fetch.js +98 -27
- package/dist/commands/import.d.ts +1 -0
- package/dist/commands/import.js +51 -11
- package/dist/commands/list.js +18 -3
- package/dist/commands/prune.js +22 -6
- package/dist/commands/search.js +31 -30
- package/dist/commands/status.js +10 -9
- package/dist/hooks/command-not-found/suggest.d.ts +9 -0
- package/dist/hooks/command-not-found/suggest.js +93 -0
- package/dist/lib/config/index.d.ts +8 -3
- package/dist/lib/config/index.js +19 -4
- package/dist/lib/config/resolve.d.ts +21 -1
- package/dist/lib/config/resolve.js +42 -1
- package/dist/lib/config/schema.d.ts +3 -0
- package/dist/lib/config/schema.js +11 -1
- package/dist/lib/research/artifact.d.ts +13 -2
- package/dist/lib/research/artifact.js +27 -7
- package/dist/lib/research/browser.d.ts +12 -0
- package/dist/lib/research/browser.js +40 -3
- package/dist/lib/research/compress.d.ts +19 -0
- package/dist/lib/research/compress.js +31 -0
- package/dist/lib/research/docs/capabilities.d.ts +1 -1
- package/dist/lib/research/docs/code-blocks.js +24 -0
- package/dist/lib/research/docs/discovery-index.d.ts +14 -0
- package/dist/lib/research/docs/discovery-index.js +112 -0
- package/dist/lib/research/docs/machine-readable.d.ts +2 -2
- package/dist/lib/research/docs/machine-readable.js +3 -7
- package/dist/lib/research/docs/remote-search-runner.js +61 -1
- package/dist/lib/research/docs/section-artifacts.d.ts +3 -2
- package/dist/lib/research/docs/section-artifacts.js +5 -5
- package/dist/lib/research/docs/sections.d.ts +1 -0
- package/dist/lib/research/docs/sections.js +19 -1
- package/dist/lib/research/freshness.d.ts +9 -1
- package/dist/lib/research/freshness.js +24 -2
- package/dist/lib/research/keywords.d.ts +24 -0
- package/dist/lib/research/keywords.js +247 -0
- package/dist/lib/research/revalidate.d.ts +3 -1
- package/dist/lib/research/revalidate.js +8 -4
- package/dist/lib/research/search-index.d.ts +18 -0
- package/dist/lib/research/search-index.js +109 -0
- package/dist/lib/research/secret-scan.js +4 -2
- package/dist/lib/research/storage.d.ts +1 -0
- package/dist/lib/research/storage.js +1 -1
- package/dist/lib/research/summarize.d.ts +17 -0
- package/dist/lib/research/summarize.js +400 -0
- package/dist/lib/research/url.js +5 -2
- package/dist/lib/text.d.ts +33 -0
- package/dist/lib/text.js +63 -0
- package/oclif.manifest.json +8 -8
- 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
|
|
19
|
-
*
|
|
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
|
|
56
|
-
*
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|