@taurgis/bonsai 1.0.4 → 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 +28 -2
- 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 +81 -16
- package/dist/commands/import.d.ts +1 -0
- package/dist/commands/import.js +38 -6
- package/dist/commands/list.js +18 -3
- package/dist/commands/prune.js +22 -6
- package/dist/commands/search.js +21 -22
- 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 +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/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/secret-scan.js +4 -2
- 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
|
@@ -2,6 +2,13 @@ import type { ConfigValues, StorageMode, SummaryLevel } from './schema.js';
|
|
|
2
2
|
/** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
|
|
3
3
|
export declare const STORAGE_ENV_VAR = "BONSAI_STORAGE";
|
|
4
4
|
export declare const SUMMARY_ENV_VAR = "BONSAI_SUMMARY";
|
|
5
|
+
/**
|
|
6
|
+
* Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
|
|
7
|
+
* dropped during resolution (falling back to config/default), which would otherwise silently mask
|
|
8
|
+
* a typo like `BONSAI_SUMMARY=agressive`. Callers surface these on stderr so the misconfiguration
|
|
9
|
+
* is visible without breaking the run. Returns one message per offending variable, or empty.
|
|
10
|
+
*/
|
|
11
|
+
export declare function invalidEnvOverrideWarnings(env: Record<string, string | undefined>): string[];
|
|
5
12
|
export declare function parseEnvStorage(env: Record<string, string | undefined>): StorageMode | undefined;
|
|
6
13
|
export declare function parseEnvSummary(env: Record<string, string | undefined>): SummaryLevel | undefined;
|
|
7
14
|
export interface ResolveStorageInput {
|
|
@@ -2,6 +2,29 @@ import { BUILT_IN_DEFAULTS, STORAGE_MODES, SUMMARY_LEVELS } from './schema.js';
|
|
|
2
2
|
/** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
|
|
3
3
|
export const STORAGE_ENV_VAR = 'BONSAI_STORAGE';
|
|
4
4
|
export const SUMMARY_ENV_VAR = 'BONSAI_SUMMARY';
|
|
5
|
+
/**
|
|
6
|
+
* Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
|
|
7
|
+
* dropped during resolution (falling back to config/default), which would otherwise silently mask
|
|
8
|
+
* a typo like `BONSAI_SUMMARY=agressive`. Callers surface these on stderr so the misconfiguration
|
|
9
|
+
* is visible without breaking the run. Returns one message per offending variable, or empty.
|
|
10
|
+
*/
|
|
11
|
+
export function invalidEnvOverrideWarnings(env) {
|
|
12
|
+
const checks = [
|
|
13
|
+
[STORAGE_ENV_VAR, STORAGE_MODES],
|
|
14
|
+
[SUMMARY_ENV_VAR, SUMMARY_LEVELS],
|
|
15
|
+
];
|
|
16
|
+
const warnings = [];
|
|
17
|
+
for (const [name, valid] of checks) {
|
|
18
|
+
// After trim, an empty/whitespace-only value is falsy and treated as "unset" (no warning),
|
|
19
|
+
// matching parseEnv*; matching is case-sensitive, so a wrong-case value is invalid and warns.
|
|
20
|
+
const raw = env[name]?.trim();
|
|
21
|
+
if (raw && !valid.includes(raw)) {
|
|
22
|
+
warnings.push(`Ignoring ${name}="${raw}": not one of ${valid.join(', ')}. ` +
|
|
23
|
+
`Using the configured value or default instead.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return warnings;
|
|
27
|
+
}
|
|
5
28
|
export function parseEnvStorage(env) {
|
|
6
29
|
const raw = env[STORAGE_ENV_VAR]?.trim();
|
|
7
30
|
return STORAGE_MODES.includes(raw ?? '')
|
|
@@ -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;
|
|
@@ -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.`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick the singular or plural noun for a count so human-readable output stays grammatical
|
|
3
|
+
* ("1 entry" / "2 entries"). English has irregular plurals, so the plural is passed explicitly
|
|
4
|
+
* rather than derived.
|
|
5
|
+
*/
|
|
6
|
+
export declare function pluralize(count: number, singular: string, plural: string): string;
|
|
7
|
+
/** How a command labels a result listing, so its heading and truncation notice stay consistent. */
|
|
8
|
+
export interface ResultListLabels {
|
|
9
|
+
/** Noun phrase after the count, e.g. "cached research"; the "entry/entries" plural is appended. */
|
|
10
|
+
noun: string;
|
|
11
|
+
/** Ordering word shown when truncated: "first" (recency order) or "top" (ranked order). */
|
|
12
|
+
order: 'first' | 'top';
|
|
13
|
+
/** The command's --limit ceiling, surfaced in the truncation hint. */
|
|
14
|
+
maxLimit: number;
|
|
15
|
+
}
|
|
16
|
+
/** Heading for a result listing; notes truncation inline when more matched than are shown. */
|
|
17
|
+
export declare function resultListHeading(total: number, shown: number, labels: ResultListLabels): string;
|
|
18
|
+
/**
|
|
19
|
+
* One-line truncation notice for stderr, or null when nothing was cut. Used under --json, where the
|
|
20
|
+
* stdout heading is suppressed and an agent would otherwise get no signal that results were capped.
|
|
21
|
+
*/
|
|
22
|
+
export declare function truncationNotice(total: number, shown: number, labels: ResultListLabels): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Levenshtein edit distance: the minimum number of single-character insertions, deletions, or
|
|
25
|
+
* substitutions to turn `s1` into `s2`. Shared by fuzzy search ranking and command-typo suggestion.
|
|
26
|
+
*/
|
|
27
|
+
export declare function levenshtein(s1: string, s2: string): number;
|
|
28
|
+
/**
|
|
29
|
+
* The candidate nearest to `input` by edit distance, or null when even the closest is farther than
|
|
30
|
+
* `maxDistance`. The threshold keeps suggestions to plausible typos — input that resembles nothing
|
|
31
|
+
* gets no correction rather than an absurd one.
|
|
32
|
+
*/
|
|
33
|
+
export declare function closestMatch(input: string, candidates: readonly string[], maxDistance: number): string | null;
|