@taurgis/bonsai 1.0.4 → 1.0.6
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 +81 -2
- package/agents/agents/official-docs-researcher.agent.md +10 -1
- package/agents/agents/salesforce-docs-researcher.agent.md +126 -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 +59 -0
- package/agents/instructions/web-research.instructions.md +5 -2
- package/agents/skills/web-research/SKILL.md +8 -3
- package/bin/cli.mjs +35 -8
- package/dist/base-command.d.ts +23 -0
- package/dist/base-command.js +67 -8
- package/dist/commands/config/base.d.ts +2 -0
- package/dist/commands/config/base.js +15 -1
- package/dist/commands/config/get.d.ts +1 -1
- package/dist/commands/config/get.js +6 -7
- package/dist/commands/config/index.d.ts +12 -0
- package/dist/commands/config/index.js +34 -0
- package/dist/commands/config/list.js +4 -4
- package/dist/commands/config/set.js +20 -14
- package/dist/commands/config/unset.d.ts +1 -1
- package/dist/commands/config/unset.js +11 -8
- package/dist/commands/fetch.d.ts +5 -0
- package/dist/commands/fetch.js +122 -47
- package/dist/commands/import.d.ts +4 -0
- package/dist/commands/import.js +99 -37
- package/dist/commands/inspect.d.ts +4 -0
- package/dist/commands/inspect.js +24 -19
- package/dist/commands/list.d.ts +2 -2
- package/dist/commands/list.js +39 -16
- package/dist/commands/prune.d.ts +1 -1
- package/dist/commands/prune.js +50 -17
- package/dist/commands/search.d.ts +4 -0
- package/dist/commands/search.js +102 -41
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.js +58 -31
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +2 -0
- package/dist/hooks/command-not-found/suggest.d.ts +9 -0
- package/dist/hooks/command-not-found/suggest.js +122 -0
- package/dist/lib/argv.d.ts +10 -0
- package/dist/lib/argv.js +53 -0
- package/dist/lib/config/index.d.ts +1 -1
- package/dist/lib/config/index.js +1 -1
- package/dist/lib/config/resolve.d.ts +7 -0
- package/dist/lib/config/resolve.js +23 -0
- package/dist/lib/envelope.d.ts +36 -0
- package/dist/lib/envelope.js +76 -0
- package/dist/lib/json-meta.d.ts +10 -0
- package/dist/lib/json-meta.js +98 -0
- package/dist/lib/research/browser.d.ts +12 -0
- package/dist/lib/research/browser.js +40 -3
- package/dist/lib/research/docs/capabilities.d.ts +1 -1
- 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/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 +10 -3
- package/dist/lib/research/keywords.js +32 -5
- package/dist/lib/research/revalidate.js +3 -1
- package/dist/lib/research/schema.d.ts +5 -2
- package/dist/lib/research/schema.js +12 -1
- package/dist/lib/research/secret-scan.js +4 -2
- package/dist/lib/research/url.js +5 -2
- package/dist/lib/text.d.ts +35 -0
- package/dist/lib/text.js +65 -0
- package/dist/sites/algolia.d.ts +9 -0
- package/dist/sites/algolia.js +26 -0
- package/dist/sites/auto.d.ts +8 -0
- package/dist/sites/auto.js +30 -0
- package/dist/sites/index.js +17 -1
- package/dist/sites/nextjs/index.d.ts +2 -0
- package/dist/sites/nextjs/index.js +52 -0
- package/dist/sites/react/index.d.ts +2 -0
- package/dist/sites/react/index.js +17 -0
- package/dist/sites/tailwind/index.d.ts +2 -0
- package/dist/sites/tailwind/index.js +17 -0
- package/dist/sites/vue/index.d.ts +2 -0
- package/dist/sites/vue/index.js +17 -0
- package/oclif.manifest.json +154 -81
- package/package.json +24 -4
|
@@ -39,6 +39,18 @@ export interface CdpPage {
|
|
|
39
39
|
sessionId: string;
|
|
40
40
|
close: () => Promise<void>;
|
|
41
41
|
}
|
|
42
|
+
/** Status line of the main-frame document response, captured from Network.responseReceived. */
|
|
43
|
+
export interface MainDocumentResponse {
|
|
44
|
+
status: number;
|
|
45
|
+
statusText: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Rejects a rendered page whose main document returned an HTTP error, mirroring the static
|
|
49
|
+
* fetcher's `assertOk`. Without this, `--rendered` on a 404/500 would capture the server's (or
|
|
50
|
+
* the browser's) error page as if it were real content and cache it. A missing response (no
|
|
51
|
+
* Document event seen — e.g. data: or about: targets) is left to the caller's content checks.
|
|
52
|
+
*/
|
|
53
|
+
export declare function assertRenderedHttpOk(mainDoc: MainDocumentResponse | undefined): void;
|
|
42
54
|
/**
|
|
43
55
|
* Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
|
|
44
56
|
* and Runtime domains. The caller drives navigation and must call close() when finished.
|
|
@@ -90,7 +90,8 @@ export function findChromePath() {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
catch { }
|
|
93
|
-
throw new Error('Chrome
|
|
93
|
+
throw new Error('No Chrome or Chromium browser found for browser-based extraction. Install Chrome/Chromium or ' +
|
|
94
|
+
'set CHROME_PATH to the browser executable. Static (non-rendered) fetching does not require a browser.');
|
|
94
95
|
}
|
|
95
96
|
async function spawnChrome(chromePath) {
|
|
96
97
|
const chromeProcess = spawn(chromePath, [
|
|
@@ -220,6 +221,17 @@ export const BLOCKED_ASSET_URLS = [
|
|
|
220
221
|
'*.ico',
|
|
221
222
|
];
|
|
222
223
|
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
224
|
+
/**
|
|
225
|
+
* Rejects a rendered page whose main document returned an HTTP error, mirroring the static
|
|
226
|
+
* fetcher's `assertOk`. Without this, `--rendered` on a 404/500 would capture the server's (or
|
|
227
|
+
* the browser's) error page as if it were real content and cache it. A missing response (no
|
|
228
|
+
* Document event seen — e.g. data: or about: targets) is left to the caller's content checks.
|
|
229
|
+
*/
|
|
230
|
+
export function assertRenderedHttpOk(mainDoc) {
|
|
231
|
+
if (mainDoc && mainDoc.status >= 400) {
|
|
232
|
+
throw new Error(`Fetch failed with status ${mainDoc.status} ${mainDoc.statusText}`.trimEnd());
|
|
233
|
+
}
|
|
234
|
+
}
|
|
223
235
|
/**
|
|
224
236
|
* Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
|
|
225
237
|
* and Runtime domains. The caller drives navigation and must call close() when finished.
|
|
@@ -332,8 +344,33 @@ export async function fetchRenderedHtml(url, options = {}) {
|
|
|
332
344
|
const page = await openCdpPage();
|
|
333
345
|
try {
|
|
334
346
|
await page.client.send('Network.setBlockedURLs', { urls: BLOCKED_ASSET_URLS }, page.sessionId);
|
|
335
|
-
|
|
347
|
+
// Record the status of each frame's first Document response so we can reject HTTP errors after
|
|
348
|
+
// the page settles. Keyed by frameId; the navigated top frame is matched via the navigate result.
|
|
349
|
+
const documentResponses = new Map();
|
|
350
|
+
page.client.on(`${page.sessionId}:Network.responseReceived`, (params) => {
|
|
351
|
+
const { frameId } = params;
|
|
352
|
+
const status = params.response?.status;
|
|
353
|
+
// Only record real numeric statuses: a Document event without a usable status would
|
|
354
|
+
// otherwise store NaN, which silently slips past the `>= 400` guard and the `?? 200`
|
|
355
|
+
// fallback alike, leaking a NaN into BrowserFetchResult.status (declared `number`).
|
|
356
|
+
if (params.type !== 'Document' || !frameId || typeof status !== 'number')
|
|
357
|
+
return;
|
|
358
|
+
if (documentResponses.has(frameId))
|
|
359
|
+
return;
|
|
360
|
+
documentResponses.set(frameId, {
|
|
361
|
+
status,
|
|
362
|
+
statusText: params.response?.statusText ?? '',
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
const nav = await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
|
|
366
|
+
// A network-level failure (DNS, refused/closed connection, TLS) never fires a load event, so
|
|
367
|
+
// fail fast here instead of waiting out the full navigation timeout on a dead page.
|
|
368
|
+
if (nav.errorText) {
|
|
369
|
+
throw new Error(`Navigation failed: ${nav.errorText}`);
|
|
370
|
+
}
|
|
336
371
|
await waitForLoad(page.client, page.sessionId, timeout, options.settleMs ?? 1000);
|
|
372
|
+
const mainDoc = nav.frameId ? documentResponses.get(nav.frameId) : undefined;
|
|
373
|
+
assertRenderedHttpOk(mainDoc);
|
|
337
374
|
const evalResult = await page.client.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true }, page.sessionId);
|
|
338
375
|
const html = evalResult.result?.value;
|
|
339
376
|
if (typeof html !== 'string') {
|
|
@@ -344,7 +381,7 @@ export async function fetchRenderedHtml(url, options = {}) {
|
|
|
344
381
|
throw new Error(`Response body size limit exceeded. Rendered HTML is ${byteLength} bytes.`);
|
|
345
382
|
}
|
|
346
383
|
return {
|
|
347
|
-
status: 200,
|
|
384
|
+
status: mainDoc?.status ?? 200,
|
|
348
385
|
contentType: 'text/html',
|
|
349
386
|
etag: null,
|
|
350
387
|
lastModified: null,
|
|
@@ -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;
|
|
@@ -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
|
}
|
|
@@ -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
|
*/
|
|
@@ -8,10 +8,17 @@ export declare function tokenize(text: string): string[];
|
|
|
8
8
|
* deterministic (frequency desc, then alphabetical) so the same input always yields the same tags.
|
|
9
9
|
*/
|
|
10
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[];
|
|
11
17
|
/**
|
|
12
18
|
* Back-fills keyword tags for an artifact that has none, deriving them from its detailed content and
|
|
13
|
-
* recording a quality note.
|
|
14
|
-
*
|
|
15
|
-
* path (import, fetch, revalidation) uses,
|
|
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.
|
|
16
23
|
*/
|
|
17
24
|
export declare function applyAutoTags(artifact: ResearchArtifact): ResearchArtifact;
|
|
@@ -202,15 +202,42 @@ export function extractKeywords(text, max = 5) {
|
|
|
202
202
|
.slice(0, max)
|
|
203
203
|
.map(([term]) => term);
|
|
204
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Removes duplicate and blank tags, comparing case-insensitively (search/list filtering already
|
|
207
|
+
* matches tags case-insensitively, so "node" and "Node" are the same tag) while preserving
|
|
208
|
+
* first-seen order and the original casing of the kept tag. Whitespace is trimmed.
|
|
209
|
+
*/
|
|
210
|
+
export function dedupeTags(tags) {
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
const result = [];
|
|
213
|
+
for (const tag of tags) {
|
|
214
|
+
const trimmed = tag.trim();
|
|
215
|
+
const key = trimmed.toLowerCase();
|
|
216
|
+
if (!key || seen.has(key))
|
|
217
|
+
continue;
|
|
218
|
+
seen.add(key);
|
|
219
|
+
result.push(trimmed);
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
205
223
|
/**
|
|
206
224
|
* Back-fills keyword tags for an artifact that has none, deriving them from its detailed content and
|
|
207
|
-
* recording a quality note.
|
|
208
|
-
*
|
|
209
|
-
* path (import, fetch, revalidation) uses,
|
|
225
|
+
* recording a quality note. When tags are already present they win, but are still deduped/trimmed so
|
|
226
|
+
* caller-supplied tags do not persist blank or duplicate entries. Mutates and returns the same
|
|
227
|
+
* artifact. This is the single entry point every ingestion path (import, fetch, revalidation) uses,
|
|
228
|
+
* so tag hygiene lives in exactly one place.
|
|
210
229
|
*/
|
|
211
230
|
export function applyAutoTags(artifact) {
|
|
212
|
-
if (artifact.metadata.tags.length > 0)
|
|
213
|
-
|
|
231
|
+
if (artifact.metadata.tags.length > 0) {
|
|
232
|
+
const deduped = dedupeTags(artifact.metadata.tags);
|
|
233
|
+
// If the caller supplied only blank entries, deduped is empty — treat that as "no tags" and fall
|
|
234
|
+
// through to auto-tagging rather than persisting a tagless artifact.
|
|
235
|
+
if (deduped.length > 0) {
|
|
236
|
+
artifact.metadata.tags = deduped;
|
|
237
|
+
return artifact;
|
|
238
|
+
}
|
|
239
|
+
artifact.metadata.tags = [];
|
|
240
|
+
}
|
|
214
241
|
const tags = extractKeywords(artifact.detailed);
|
|
215
242
|
if (tags.length === 0)
|
|
216
243
|
return artifact;
|
|
@@ -178,7 +178,9 @@ export async function revalidateCache(dataDir, existing, currentTime, options) {
|
|
|
178
178
|
status: 'stale',
|
|
179
179
|
artifact: existing,
|
|
180
180
|
allowed: options.allowStale,
|
|
181
|
-
|
|
181
|
+
// Carry only the underlying cause; the command layer owns the user-facing framing
|
|
182
|
+
// (so the warning reads cleanly instead of stuttering "stale … stale", "failed … failed").
|
|
183
|
+
error: err.message,
|
|
182
184
|
};
|
|
183
185
|
}
|
|
184
186
|
throw new Error(`Revalidation failed and cache has expired beyond the grace period: ${err.message}`);
|
|
@@ -2,10 +2,13 @@ export interface TokenEstimate {
|
|
|
2
2
|
compressed: number | null;
|
|
3
3
|
detailed: number | null;
|
|
4
4
|
}
|
|
5
|
-
export
|
|
5
|
+
export declare const CAPTURE_METHODS: readonly ["static_fetch", "browser_fallback", "agent_supplied", "route_markdown", "github_source"];
|
|
6
|
+
export type CaptureMethod = (typeof CAPTURE_METHODS)[number];
|
|
7
|
+
export declare const ARTIFACT_TYPES: readonly ["source", "research_note", "index", "section"];
|
|
8
|
+
export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
|
|
6
9
|
export interface ResearchArtifactMetadata {
|
|
7
10
|
schema_version: number;
|
|
8
|
-
artifact_type:
|
|
11
|
+
artifact_type: ArtifactType;
|
|
9
12
|
source_url: string;
|
|
10
13
|
source_urls: string[];
|
|
11
14
|
normalized_url: string;
|
|
@@ -1 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
// Single source of truth for the capture-method enum. Command flag `options` lists derive from this
|
|
2
|
+
// so a new method can never drift out of sync with the filters that are supposed to expose it.
|
|
3
|
+
export const CAPTURE_METHODS = [
|
|
4
|
+
'static_fetch',
|
|
5
|
+
'browser_fallback',
|
|
6
|
+
'agent_supplied',
|
|
7
|
+
'route_markdown',
|
|
8
|
+
'github_source',
|
|
9
|
+
];
|
|
10
|
+
// Single source of truth for the artifact-type enum. `index` = navigation/hub or llms.txt site
|
|
11
|
+
// index; `section` = a heading-level child of a page. Command flag `options` derive from this.
|
|
12
|
+
export const ARTIFACT_TYPES = ['source', 'research_note', 'index', 'section'];
|
|
@@ -15,9 +15,11 @@ const SECRET_PATTERNS = [
|
|
|
15
15
|
re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/,
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
|
-
// Generic `api_key = "…"
|
|
18
|
+
// Generic credential assignments with a non-trivial value: `api_key = "…"`, `secret: …`, and
|
|
19
|
+
// UPPER_SNAKE env forms like `AWS_SECRET_ACCESS_KEY=…` / `DB_PASSWORD=…`. The keyword may be a
|
|
20
|
+
// segment inside a longer identifier (so `_SECRET_` matches), which `\b…\b` boundaries missed.
|
|
19
21
|
label: 'credential assignment',
|
|
20
|
-
re:
|
|
22
|
+
re: /(?:^|[^A-Za-z])(?:api[_-]?key|secret|token|password|passwd|client[_-]?secret|access[_-]?token)[A-Za-z0-9_-]*\s*[:=]\s*['"]?[A-Za-z0-9._\-/+]{16,}/i,
|
|
21
23
|
},
|
|
22
24
|
];
|
|
23
25
|
/** Return the label of the first secret pattern found in `text`, or null if none match. */
|
package/dist/lib/research/url.js
CHANGED
|
@@ -92,8 +92,11 @@ export function normalizeUrl(input) {
|
|
|
92
92
|
try {
|
|
93
93
|
url = new URL(input);
|
|
94
94
|
}
|
|
95
|
-
catch
|
|
96
|
-
|
|
95
|
+
catch {
|
|
96
|
+
// new URL() only ever throws the constant "Invalid URL" (no structured cause worth keeping),
|
|
97
|
+
// and every caller prefixes its own "Invalid URL:" — forwarding the native text would stutter.
|
|
98
|
+
// Drop the binding and emit an actionable, self-contained reason instead.
|
|
99
|
+
throw new Error(`Could not parse "${input}" as a URL; provide a full http(s) URL like https://example.com/page.`);
|
|
97
100
|
}
|
|
98
101
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
99
102
|
throw new Error(`Unsupported protocol "${url.protocol}". Only http: and https: are supported.`);
|