@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.
Files changed (55) hide show
  1. package/README.md +28 -2
  2. package/agents/agents/salesforce-docs-researcher.agent.md +117 -0
  3. package/agents/hooks/README.md +62 -0
  4. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +22 -0
  5. package/agents/hooks/claude-code/settings.json +16 -0
  6. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +18 -0
  7. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +22 -0
  8. package/agents/hooks/codex/hooks.json +28 -0
  9. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +21 -0
  10. package/agents/hooks/cursor/hooks.json +12 -0
  11. package/agents/hooks/github-copilot/bonsai-web-fetch.json +23 -0
  12. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +19 -0
  13. package/agents/instructions/salesforce-research.instructions.md +57 -0
  14. package/agents/instructions/web-research.instructions.md +2 -1
  15. package/bin/cli.mjs +13 -3
  16. package/dist/base-command.d.ts +20 -0
  17. package/dist/base-command.js +27 -1
  18. package/dist/commands/config/get.js +2 -2
  19. package/dist/commands/config/list.js +2 -2
  20. package/dist/commands/config/set.js +2 -2
  21. package/dist/commands/config/unset.js +2 -2
  22. package/dist/commands/fetch.d.ts +5 -0
  23. package/dist/commands/fetch.js +81 -16
  24. package/dist/commands/import.d.ts +1 -0
  25. package/dist/commands/import.js +38 -6
  26. package/dist/commands/list.js +18 -3
  27. package/dist/commands/prune.js +22 -6
  28. package/dist/commands/search.js +21 -22
  29. package/dist/commands/status.js +10 -9
  30. package/dist/hooks/command-not-found/suggest.d.ts +9 -0
  31. package/dist/hooks/command-not-found/suggest.js +93 -0
  32. package/dist/lib/config/index.d.ts +1 -1
  33. package/dist/lib/config/index.js +1 -1
  34. package/dist/lib/config/resolve.d.ts +7 -0
  35. package/dist/lib/config/resolve.js +23 -0
  36. package/dist/lib/research/browser.d.ts +12 -0
  37. package/dist/lib/research/browser.js +40 -3
  38. package/dist/lib/research/docs/capabilities.d.ts +1 -1
  39. package/dist/lib/research/docs/discovery-index.d.ts +14 -0
  40. package/dist/lib/research/docs/discovery-index.js +112 -0
  41. package/dist/lib/research/docs/machine-readable.d.ts +2 -2
  42. package/dist/lib/research/docs/machine-readable.js +3 -7
  43. package/dist/lib/research/docs/remote-search-runner.js +61 -1
  44. package/dist/lib/research/docs/sections.d.ts +1 -0
  45. package/dist/lib/research/docs/sections.js +19 -1
  46. package/dist/lib/research/freshness.d.ts +9 -1
  47. package/dist/lib/research/freshness.js +24 -2
  48. package/dist/lib/research/keywords.d.ts +10 -3
  49. package/dist/lib/research/keywords.js +32 -5
  50. package/dist/lib/research/secret-scan.js +4 -2
  51. package/dist/lib/research/url.js +5 -2
  52. package/dist/lib/text.d.ts +33 -0
  53. package/dist/lib/text.js +63 -0
  54. package/oclif.manifest.json +8 -8
  55. 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 executable not found. Please set CHROME_PATH environment variable.');
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
- await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
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, but only when the engine/framework is known to serve one or the
19
- * page advertises one. Returns validated Markdown source for the page, or null.
18
+ * Probes a page-level `.md` route. The derived route is only a candidate: it must be same-origin,
19
+ * fetch successfully, and validate as non-HTML/non-error Markdown before being trusted.
20
20
  */
21
21
  export declare function probeRouteMarkdown(pageUrl: string, caps: SiteCapabilities, fetcher?: TextFetcher): Promise<MachineReadableResult | null>;
@@ -2,10 +2,6 @@ import { fetchText } from '../fetcher.js';
2
2
  import { isSameDocsOrigin, validateTextArtifact } from './validate.js';
3
3
  import { vitepressRouteMarkdown } from './source-map.js';
4
4
  const defaultFetcher = (url) => fetchText(url);
5
- // Frameworks/engines that the research observed serving page-level `.md` routes (T-24 evidence).
6
- function advertisesRouteMarkdown(caps) {
7
- return (caps.docsEngine === 'vitepress' || caps.framework === 'rspress' || caps.framework === 'gitbook');
8
- }
9
5
  function llmsTxtCandidates(pageUrl, caps) {
10
6
  const advertised = caps.machineReadable
11
7
  .filter((m) => m.type === 'llms.txt' || m.type === 'llms-full.txt')
@@ -52,14 +48,14 @@ export async function probeLlmsTxt(pageUrl, caps, fetcher = defaultFetcher) {
52
48
  return null;
53
49
  }
54
50
  /**
55
- * Probes a page-level `.md` route, but only when the engine/framework is known to serve one or the
56
- * page advertises one. Returns validated Markdown source for the page, or null.
51
+ * Probes a page-level `.md` route. The derived route is only a candidate: it must be same-origin,
52
+ * fetch successfully, and validate as non-HTML/non-error Markdown before being trusted.
57
53
  */
58
54
  export async function probeRouteMarkdown(pageUrl, caps, fetcher = defaultFetcher) {
59
55
  const advertised = caps.machineReadable
60
56
  .filter((m) => m.type === 'route-markdown')
61
57
  .map((m) => m.url);
62
- const derived = advertisesRouteMarkdown(caps) ? [vitepressRouteMarkdown(pageUrl)] : [];
58
+ const derived = [vitepressRouteMarkdown(pageUrl)];
63
59
  const candidates = [...new Set([...advertised, ...derived].filter((u) => !!u))];
64
60
  for (const candidate of candidates) {
65
61
  if (!isSameDocsOrigin(candidate, pageUrl))
@@ -1,5 +1,7 @@
1
1
  import { detectDocsEngine } from './detect.js';
2
+ import { probeLlmsTxt } from './machine-readable.js';
2
3
  import { searchMkDocsIndex, searchJekyllIndex, searchSphinxIndex } from './search-index.js';
4
+ import { parseSitemap, searchLlmsTxt, searchSitemap } from './discovery-index.js';
3
5
  import { extractAlgoliaConfig, algoliaQueryUrl, parseAlgoliaResponse } from './remote-search.js';
4
6
  async function getText(url, deps) {
5
7
  const res = await deps.fetchText(url);
@@ -7,6 +9,14 @@ async function getText(url, deps) {
7
9
  throw new Error(`empty search index at ${url}`);
8
10
  return res.content;
9
11
  }
12
+ function sameHost(url, base) {
13
+ try {
14
+ return new URL(url).hostname === new URL(base).hostname;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
10
20
  // Conventional static index path + parser per framework (verified by T-25 fixtures).
11
21
  async function staticIndexSearch(framework, origin, query, deps) {
12
22
  if (framework === 'mkdocs' || framework === 'material-mkdocs') {
@@ -32,6 +42,50 @@ async function staticIndexSearch(framework, origin, query, deps) {
32
42
  }
33
43
  return null;
34
44
  }
45
+ async function llmsTxtSearch(docsUrl, caps, query, deps) {
46
+ const llms = await probeLlmsTxt(docsUrl, caps, async (url) => {
47
+ const res = await deps.fetchText(url);
48
+ return { ...res, finalUrl: url, contentType: 'text/plain' };
49
+ });
50
+ if (!llms)
51
+ return null;
52
+ return {
53
+ provider: llms.artifact.type,
54
+ results: searchLlmsTxt(llms.body, llms.artifact.url, query),
55
+ };
56
+ }
57
+ async function sitemapSearch(pageUrl, query, deps) {
58
+ const origin = new URL(pageUrl).origin;
59
+ const firstSegment = new URL(pageUrl).pathname.split('/').filter(Boolean)[0];
60
+ const candidates = [
61
+ `${origin}/sitemap.xml`,
62
+ `${origin}/sitemap_index.xml`,
63
+ firstSegment ? `${origin}/${firstSegment}/sitemap.xml` : undefined,
64
+ ].filter((url) => !!url);
65
+ for (const sitemapUrl of [...new Set(candidates)]) {
66
+ try {
67
+ const body = await getText(sitemapUrl, deps);
68
+ const results = searchSitemap(body, sitemapUrl, query);
69
+ if (results.length > 0)
70
+ return { provider: 'sitemap', results };
71
+ const index = parseSitemap(body);
72
+ const childResults = [];
73
+ for (const childUrl of index.sitemapUrls.slice(0, 10)) {
74
+ if (!sameHost(childUrl, pageUrl))
75
+ continue;
76
+ const childBody = await getText(childUrl, deps);
77
+ childResults.push(...searchSitemap(childBody, childUrl, query));
78
+ }
79
+ if (childResults.length > 0) {
80
+ return { provider: 'sitemap', results: childResults.sort((a, b) => b.score - a.score) };
81
+ }
82
+ }
83
+ catch {
84
+ // Try the next conventional sitemap location.
85
+ }
86
+ }
87
+ return null;
88
+ }
35
89
  /**
36
90
  * Runs remote docs search for a documentation site. Detects the provider from the page, queries the
37
91
  * matching connector, and returns normalized results. Throws when no verified connector applies.
@@ -49,6 +103,12 @@ export async function runRemoteDocsSearch(docsUrl, query, deps) {
49
103
  const staticResult = await staticIndexSearch(caps.framework, origin, query, deps);
50
104
  if (staticResult)
51
105
  return staticResult;
106
+ const llmsResult = await llmsTxtSearch(page.finalUrl, caps, query, deps);
107
+ if (llmsResult)
108
+ return llmsResult;
109
+ const sitemapResult = await sitemapSearch(page.finalUrl, query, deps);
110
+ if (sitemapResult)
111
+ return sitemapResult;
52
112
  throw new Error(`No verified remote search connector for this site (detected: ${caps.framework ?? caps.docsEngine ?? 'unknown'}). ` +
53
- 'Supported: Algolia DocSearch, MkDocs/Material, Sphinx, Just the Docs.');
113
+ 'Supported: Algolia DocSearch, MkDocs/Material, Sphinx, Just the Docs, llms.txt, sitemap.xml.');
54
114
  }
@@ -4,6 +4,7 @@ export interface SectionChunk {
4
4
  level: number;
5
5
  content: string;
6
6
  }
7
+ export declare function headingPlainText(raw: string): string;
7
8
  export declare function slugifyHeading(text: string): string;
8
9
  /**
9
10
  * Splits Markdown into H2/H3 section chunks. Returns an empty array when the document has fewer
@@ -2,6 +2,24 @@
2
2
  // big API reference (e.g. Node `url.html`, ~17.5k tokens) becomes addressable section artifacts.
3
3
  // Pure string work: no I/O, no artifact construction. H4+ headings stay within their parent chunk.
4
4
  const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
5
+ // Reduce a heading's inline Markdown to the plain text GitHub anchors and breadcrumbs are built from.
6
+ // GitHub strips formatting (links, emphasis, code) and slugs the *rendered* text, so a heading like
7
+ // `## [API documentation](https://prismjs.com/extending.html#api-documentation)` must collapse to
8
+ // "API documentation" — not the raw link syntax, which otherwise leaks the URL into the anchor and
9
+ // breadcrumb. Confirmed against github-slugger, which documents that it operates on plain heading
10
+ // text rather than parsing Markdown: https://github.com/Flet/github-slugger
11
+ export function headingPlainText(raw) {
12
+ // The `\(…\)` part tolerates one level of balanced parens so a real-world target like
13
+ // `URL_(disambiguation)` doesn't leave a dangling ")" in the rendered breadcrumb text.
14
+ return raw
15
+ .replace(/!\[([^\]]*)\]\([^()]*(?:\([^()]*\)[^()]*)*\)/g, '$1') // images: ![alt](url) -> alt
16
+ .replace(/\[([^\]]*)\]\([^()]*(?:\([^()]*\)[^()]*)*\)/g, '$1') // inline links: [text](url) -> text
17
+ .replace(/\[([^\]]*)\]\[[^\]]*\]/g, '$1') // reference links: [text][ref] -> text
18
+ .replace(/`([^`]*)`/g, '$1') // inline code: `code` -> code
19
+ .replace(/[*_~]/g, '') // emphasis / strikethrough markers
20
+ .replace(/\s+/g, ' ')
21
+ .trim();
22
+ }
5
23
  // GitHub-style anchor slug: lowercase, drop punctuation, spaces -> hyphens.
6
24
  export function slugifyHeading(text) {
7
25
  return text
@@ -22,7 +40,7 @@ function findHeadings(lines) {
22
40
  continue;
23
41
  const match = lines[i].match(HEADING_RE);
24
42
  if (match)
25
- out.push({ index: i, level: match[1].length, text: match[2].trim() });
43
+ out.push({ index: i, level: match[1].length, text: headingPlainText(match[2]) });
26
44
  }
27
45
  return out;
28
46
  }
@@ -4,9 +4,17 @@ export interface FreshnessPolicy {
4
4
  graceWindowMs: number;
5
5
  }
6
6
  /**
7
- * Parses simple TTL duration strings (e.g. '24h', '7d', '30d') to milliseconds.
7
+ * Parses simple TTL duration strings to milliseconds. The unit `m` is months (not minutes); the
8
+ * smallest unit is hours, since sub-hour cache lifespans are not meaningful for research artifacts.
8
9
  */
9
10
  export declare function parseTtlToMs(ttl: string): number;
11
+ /**
12
+ * Validate a duration-valued flag. Returns an actionable error message that names the
13
+ * offending flag when the value is malformed, or null when the flag is absent or valid.
14
+ * Callers map a non-null message onto `this.error(msg, { exit: 2 })`, so every command
15
+ * reports the same parse failure while naming the exact flag the user passed.
16
+ */
17
+ export declare function durationFlagError(flag: string, value: string | undefined): string | null;
10
18
  /**
11
19
  * Derives the active freshness policy based on the tier and optional TTL override.
12
20
  */
@@ -1,12 +1,17 @@
1
1
  const HOUR_MS = 3600 * 1000;
2
2
  const DAY_MS = 24 * HOUR_MS;
3
3
  /**
4
- * Parses simple TTL duration strings (e.g. '24h', '7d', '30d') to milliseconds.
4
+ * Parses simple TTL duration strings to milliseconds. The unit `m` is months (not minutes); the
5
+ * smallest unit is hours, since sub-hour cache lifespans are not meaningful for research artifacts.
5
6
  */
6
7
  export function parseTtlToMs(ttl) {
7
8
  const match = ttl.match(/^(\d+)([hdwmy])$/);
8
9
  if (!match) {
9
- throw new Error(`Invalid TTL format "${ttl}". Use formats like '24h', '7d', '30d'.`);
10
+ // Deliberately does not start with "Invalid": durationFlagError wraps this as
11
+ // "Invalid <flag>: <message>", and a leading "Invalid" here produced a "Invalid --ttl:
12
+ // Invalid TTL format" stutter.
13
+ throw new Error(`Duration "${ttl}" is not a valid format. Use a whole number plus a unit: ` +
14
+ `h (hours), d (days), w (weeks), m (months), or y (years), e.g. '24h', '7d', '6m'.`);
10
15
  }
11
16
  const val = parseInt(match[1] || '', 10);
12
17
  const unit = match[2] || '';
@@ -25,6 +30,23 @@ export function parseTtlToMs(ttl) {
25
30
  return 0;
26
31
  }
27
32
  }
33
+ /**
34
+ * Validate a duration-valued flag. Returns an actionable error message that names the
35
+ * offending flag when the value is malformed, or null when the flag is absent or valid.
36
+ * Callers map a non-null message onto `this.error(msg, { exit: 2 })`, so every command
37
+ * reports the same parse failure while naming the exact flag the user passed.
38
+ */
39
+ export function durationFlagError(flag, value) {
40
+ if (!value)
41
+ return null;
42
+ try {
43
+ parseTtlToMs(value);
44
+ return null;
45
+ }
46
+ catch (err) {
47
+ return `Invalid ${flag}: ${err.message}`;
48
+ }
49
+ }
28
50
  /**
29
51
  * Derives the active freshness policy based on the tier and optional TTL override.
30
52
  */
@@ -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. No-op when tags are already present, so user- or caller-supplied tags
14
- * always win. Mutates and returns the same artifact. This is the single entry point every ingestion
15
- * path (import, fetch, revalidation) uses, so the auto-tag policy lives in exactly one place.
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. No-op when tags are already present, so user- or caller-supplied tags
208
- * always win. Mutates and returns the same artifact. This is the single entry point every ingestion
209
- * path (import, fetch, revalidation) uses, so the auto-tag policy lives in exactly one place.
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
- return artifact;
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 = "…"` / `secret: …` style assignments with a non-trivial value.
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: /\b(?:api[_-]?key|secret|token|password|passwd|client[_-]?secret|access[_-]?token)\b\s*[:=]\s*['"]?[A-Za-z0-9._\-/+]{16,}/i,
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. */
@@ -92,8 +92,11 @@ export function normalizeUrl(input) {
92
92
  try {
93
93
  url = new URL(input);
94
94
  }
95
- catch (err) {
96
- throw new Error(`Invalid URL "${input}": ${err.message}`);
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;