@taurgis/bonsai 4.0.0 → 4.1.1

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 (85) hide show
  1. package/README.md +24 -3
  2. package/agents/agents/official-docs-researcher.agent.md +102 -0
  3. package/agents/agents/salesforce-docs-researcher.agent.md +16 -14
  4. package/agents/hooks/README.md +18 -2
  5. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +1 -1
  6. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +1 -1
  7. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +1 -1
  8. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +1 -1
  9. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +1 -1
  10. package/agents/instructions/salesforce-research.instructions.md +5 -4
  11. package/agents/instructions/web-research.instructions.md +45 -0
  12. package/agents/skills/web-research/SKILL.md +139 -0
  13. package/bin/cli.mjs +0 -11
  14. package/dist/base-command.d.ts +5 -0
  15. package/dist/base-command.js +9 -0
  16. package/dist/commands/config/get.d.ts +1 -1
  17. package/dist/commands/config/get.js +1 -1
  18. package/dist/commands/config/list.d.ts +1 -1
  19. package/dist/commands/config/list.js +1 -1
  20. package/dist/commands/config/set.d.ts +1 -1
  21. package/dist/commands/config/set.js +1 -1
  22. package/dist/commands/config/unset.d.ts +1 -1
  23. package/dist/commands/config/unset.js +1 -1
  24. package/dist/commands/context.d.ts +20 -0
  25. package/dist/commands/context.js +65 -0
  26. package/dist/commands/list.d.ts +12 -3
  27. package/dist/commands/list.js +61 -100
  28. package/dist/commands/search.d.ts +43 -0
  29. package/dist/commands/search.js +233 -0
  30. package/dist/commands/setup.d.ts +27 -0
  31. package/dist/commands/setup.js +148 -0
  32. package/dist/commands.d.ts +6 -0
  33. package/dist/commands.js +6 -0
  34. package/dist/lib/argv.js +4 -1
  35. package/dist/lib/cli-flag-manifest.d.ts +2 -2
  36. package/dist/lib/cli-flag-manifest.js +15 -7
  37. package/dist/lib/cli-presentation.d.ts +19 -0
  38. package/dist/lib/cli-presentation.js +27 -0
  39. package/dist/lib/cli-result-types.d.ts +59 -0
  40. package/dist/lib/color.d.ts +2 -0
  41. package/dist/lib/color.js +6 -0
  42. package/dist/lib/common-metadata-filter-flags.d.ts +14 -0
  43. package/dist/lib/common-metadata-filter-flags.js +37 -0
  44. package/dist/{commands/config/base.d.ts → lib/config-command.d.ts} +7 -9
  45. package/dist/{commands/config/base.js → lib/config-command.js} +9 -20
  46. package/dist/lib/next-command.d.ts +15 -0
  47. package/dist/lib/next-command.js +40 -0
  48. package/dist/lib/research/context-dashboard.d.ts +19 -0
  49. package/dist/lib/research/context-dashboard.js +43 -0
  50. package/dist/lib/research/fetch-command-service.js +10 -0
  51. package/dist/lib/research/fetch-result.d.ts +1 -0
  52. package/dist/lib/research/fetch-result.js +4 -0
  53. package/dist/lib/research/fetcher.d.ts +11 -0
  54. package/dist/lib/research/fetcher.js +26 -0
  55. package/dist/lib/research/list-row.d.ts +15 -0
  56. package/dist/lib/research/list-row.js +31 -0
  57. package/dist/lib/research/metadata-filters.d.ts +22 -1
  58. package/dist/lib/research/metadata-filters.js +34 -0
  59. package/dist/lib/research/regression.js +6 -1
  60. package/dist/lib/research/schema.d.ts +2 -0
  61. package/dist/lib/research/schema.js +4 -0
  62. package/dist/lib/research/search-match.d.ts +39 -0
  63. package/dist/lib/research/search-match.js +134 -0
  64. package/dist/lib/scope-flags.d.ts +19 -0
  65. package/dist/lib/scope-flags.js +27 -0
  66. package/dist/lib/setup/agents.d.ts +16 -0
  67. package/dist/lib/setup/agents.js +20 -0
  68. package/dist/lib/setup/bin-command.d.ts +14 -0
  69. package/dist/lib/setup/bin-command.js +42 -0
  70. package/dist/lib/setup/hook-file-io.d.ts +37 -0
  71. package/dist/lib/setup/hook-file-io.js +80 -0
  72. package/dist/lib/text.d.ts +6 -0
  73. package/dist/lib/text.js +11 -0
  74. package/dist/lib/value-flags.d.ts +2 -0
  75. package/dist/lib/value-flags.js +11 -3
  76. package/dist/sites/salesforce-developer/fetch-page.d.ts +1 -1
  77. package/dist/sites/salesforce-developer/fetch-page.js +3 -1
  78. package/dist/sites/salesforce-developer/route-markdown.d.ts +3 -2
  79. package/dist/sites/salesforce-developer/route-markdown.js +8 -6
  80. package/dist/sites/salesforce-doc-fetch.d.ts +23 -11
  81. package/dist/sites/salesforce-doc-fetch.js +89 -27
  82. package/dist/sites/salesforce-dom-probe.d.ts +6 -0
  83. package/dist/sites/salesforce-dom-probe.js +40 -0
  84. package/oclif.manifest.json +428 -54
  85. package/package.json +8 -13
@@ -1,9 +1,10 @@
1
1
  import { openCdpPage, waitForLoad, ResponseCapture, describeNavigationFailure, } from '../lib/research/browser.js';
2
- import { checkDnsSafety } from '../lib/research/fetcher.js';
2
+ import { checkDnsSafety, fetchStatus } from '../lib/research/fetcher.js';
3
3
  import { htmlToMarkdown } from '../lib/research/markdown.js';
4
4
  import { sanitizePromptInjection } from '../lib/research/prompt-injection.js';
5
5
  import { contentMetrics, contentRicherThan } from '../lib/research/regression.js';
6
- import { SALESFORCE_SHADOW_DOM_HELPERS, buildPriorityContainerPickBlock, pollSalesforceContentReady, } from './salesforce-dom-probe.js';
6
+ import { SALESFORCE_SHADOW_DOM_HELPERS, buildPriorityContainerPickBlock, pollSalesforceContentReady, looksLikeSalesforceError, } from './salesforce-dom-probe.js';
7
+ export { looksLikeSalesforceError };
7
8
  // Both help.salesforce.com and developer.salesforce.com are LWR sites that render doc content
8
9
  // client-side inside web components (shadow DOM). document.documentElement.outerHTML captures
9
10
  // only the "Loading…" shell, and Readability prunes the structured result to a single block —
@@ -19,20 +20,11 @@ const BODY_LIMIT_BYTES = 6 * 1024 * 1024;
19
20
  /** Grace period after DOM settle for late /docs/get_document_content/ responses on guide pages. */
20
21
  const DOCS_API_GRACE_MS = 500;
21
22
  const ACCEPT_CONSENT_EXPRESSION = "(() => { const b = document.querySelector('#onetrust-accept-btn-handler'); if (b) b.click(); })()";
22
- const ERROR_PATTERNS = [
23
- /page\s*(not\s*found|doesn'?t\s*exist|can'?t\s*be\s*found)/i,
24
- /we\s*couldn'?t\s*find/i,
25
- /we looked high and low/i,
26
- /sorry to interrupt/i,
27
- ];
28
- /** True when the extracted text is an error/not-found/loading shell rather than real content.
29
- *
30
- * @param text - Extracted Markdown text to check.
31
- * @returns True when the text matches a Salesforce error/not-found pattern.
32
- */
33
- export function looksLikeSalesforceError(text) {
34
- return ERROR_PATTERNS.some((re) => re.test(text));
35
- }
23
+ // Salesforce's WAF answers 403 unless the User-Agent leads with a recognized HTTP-tool token.
24
+ // Same token the Markdown probe sends, so a missing-doc check sees the real status.
25
+ const DOC_PROBE_HEADERS = {
26
+ 'user-agent': 'curl/8.7.1 bonsai-research-cache (+https://bonsai.rhino-inquisitor.com)',
27
+ };
36
28
  // Chrome present on every LWR doc page; stripped before serialization. Per-site noise (e.g.
37
29
  // Help's article-feedback widget) is appended via SalesforceDocOptions.removeSelectors.
38
30
  const BASE_REMOVE_SELECTORS = [
@@ -69,25 +61,28 @@ function confidenceFor(length) {
69
61
  // page. Help wraps it in a removable component (see fetch-page.ts), but Developer renders it
70
62
  // as bare divs, so we also strip its lines from the converted Markdown for both sites.
71
63
  const BOILERPLATE_LINE = /(did this article solve your issue|let us know so we can improve|share your feedback)/i;
72
- // Toolbar of the "View as Markdown" feature on Developer articles. When the .md route probe
73
- // succeeds the browser never runs, but rollout/network asymmetries can still land such a page
74
- // here, and the toolbar labels serialize as bare paragraph lines.
75
- const MARKDOWN_TOOLBAR_LINE = /^(copy as markdown|view as markdown|copy url to markdown)$/i;
64
+ // Toolbar labels that serialize as their own paragraph: "View as Markdown" on articles that
65
+ // missed the .md probe, and the llms.txt / "Open in ChatGPT" cluster on the newer guide chrome.
66
+ const DOC_CHROME_LINE = /^(copy as markdown|view as markdown|copy url to markdown|view llms\.txt|copy url to llms\.txt|open in chatgpt|open in claude|open in perplexity|this page|all documentation|more actions)$/i;
76
67
  // A link with empty anchor text — e.g. an icon-only "home" logo link. It carries no readable
77
68
  // content and renders as a bare `[](url)`. (Images `![](url)` are intentionally not matched.)
78
69
  const EMPTY_LINK_LINE = /^\[\]\([^)]*\)$/;
70
+ // Atlas books render a release switcher as one link per season. Turndown splits the anchor
71
+ // text across lines, so the link is not a single line. Drop the whole link; the article follows.
72
+ const VERSION_PICKER_LINK = /\[\s*(?:Spring|Summer|Winter|Fall)\s+'\d{2}\s+\(API version\s+[\d.]+\)\s*\]\([^)\n]*\)/gi;
79
73
  /**
80
- * Removes known boilerplate lines from converted Salesforce Markdown: the feedback widget text,
81
- * "View as Markdown" toolbar labels, and empty icon-anchor lines.
74
+ * Removes known boilerplate from converted Salesforce Markdown: the feedback widget, doc-chrome
75
+ * toolbar labels, the atlas release switcher, and empty icon-anchor lines.
82
76
  *
83
77
  * @param markdown - Markdown output from the DOM or content API conversion.
84
- * @returns Markdown with boilerplate lines stripped and excess blank lines collapsed.
78
+ * @returns Markdown with boilerplate stripped and excess blank lines collapsed.
85
79
  */
86
80
  export function stripBoilerplate(markdown) {
87
81
  return markdown
82
+ .replace(VERSION_PICKER_LINK, '')
88
83
  .split('\n')
89
84
  .filter((line) => !BOILERPLATE_LINE.test(line) &&
90
- !MARKDOWN_TOOLBAR_LINE.test(line.trim()) &&
85
+ !DOC_CHROME_LINE.test(line.trim()) &&
91
86
  !EMPTY_LINK_LINE.test(line.trim()))
92
87
  .join('\n')
93
88
  .replace(/\n{3,}/g, '\n\n')
@@ -286,6 +281,65 @@ function buildCaptureScript(contentSelectors, extraRemove) {
286
281
  return { html: serialize(container), title };
287
282
  })()`;
288
283
  }
284
+ /** Article identity for a Salesforce doc URL: Help `id`, otherwise the last path segment.
285
+ * Underscores and hyphens match so a REST rename (`intro_what_is_rest_api` →
286
+ * `intro-what-is-rest-api`) stays the same article.
287
+ *
288
+ * @param url - Requested or post-navigation URL.
289
+ * @returns Normalized article key, or null when the URL has no article identity.
290
+ */
291
+ export function salesforceArticleKey(url) {
292
+ let parsed;
293
+ try {
294
+ parsed = new URL(url);
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ const id = parsed.searchParams.get('id');
300
+ const raw = (id ?? parsed.pathname.replace(/\/+$/, '').split('/').pop() ?? '').trim();
301
+ const stripped = raw.replace(/\.(html?|md)$/i, '');
302
+ if (!stripped)
303
+ return null;
304
+ return stripped.toLowerCase().replace(/_/g, '-');
305
+ }
306
+ /** True when both URLs name the same Salesforce article, or either key can't be read.
307
+ *
308
+ * @param requested - URL the caller asked for.
309
+ * @param finalUrl - URL the browser landed on.
310
+ * @returns False only when both keys exist and differ.
311
+ */
312
+ export function sameSalesforceArticle(requested, finalUrl) {
313
+ const requestedKey = salesforceArticleKey(requested);
314
+ const finalKey = salesforceArticleKey(finalUrl);
315
+ if (!requestedKey || !finalKey)
316
+ return true;
317
+ return requestedKey === finalKey;
318
+ }
319
+ async function assertSalesforceDocumentExists(url) {
320
+ try {
321
+ await fetchStatus(url, { headers: DOC_PROBE_HEADERS, timeoutMs: 8_000 });
322
+ }
323
+ catch (err) {
324
+ const message = err instanceof Error ? err.message : '';
325
+ // Any other failure (WAF 403, timeout) still goes to the browser. Only a real
326
+ // not-found status is enough to skip Chrome.
327
+ if (/\bstatus (404|410)\b/.test(message)) {
328
+ throw new Error(`Salesforce doc not found: ${url}`);
329
+ }
330
+ }
331
+ }
332
+ async function assertLandedOnRequestedArticle(page, url) {
333
+ const result = await page.client
334
+ .send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, page.sessionId)
335
+ .catch(() => null);
336
+ const finalHref = result?.result?.value;
337
+ if (typeof finalHref !== 'string' || !finalHref)
338
+ return;
339
+ if (!sameSalesforceArticle(url, finalHref)) {
340
+ throw new Error(`Salesforce doc ${url} redirected to a different article (${finalHref}).`);
341
+ }
342
+ }
289
343
  function parseSalesforceDocUrl(url, allowedHost) {
290
344
  let parsed;
291
345
  try {
@@ -300,7 +354,10 @@ function parseSalesforceDocUrl(url, allowedHost) {
300
354
  return parsed;
301
355
  }
302
356
  function assertReadableSalesforceContent(detailedMarkdown, url) {
303
- if (detailedMarkdown.length < MIN_CONTAINER_CHARS || looksLikeSalesforceError(detailedMarkdown)) {
357
+ if (looksLikeSalesforceError(detailedMarkdown)) {
358
+ throw new Error(`Salesforce doc not found: ${url}`);
359
+ }
360
+ if (detailedMarkdown.length < MIN_CONTAINER_CHARS) {
304
361
  throw new Error(`Salesforce returned no readable content for ${url} (page may be missing, gated, or still loading).`);
305
362
  }
306
363
  }
@@ -351,6 +408,9 @@ async function captureSalesforceAttempt(page, url, captureScript, contentSelecto
351
408
  }
352
409
  let detailedMarkdown = stripBoilerplate(htmlToMarkdown(html));
353
410
  let usedDocsApi = false;
411
+ // Retired atlas leaves (apex_qs_hello.htm) stay HTTP 200, then the SPA swaps in the book
412
+ // home. Storing that home under the requested URL would cache the wrong article.
413
+ await assertLandedOnRequestedArticle(page, url);
354
414
  if (docsApiHtml) {
355
415
  const picked = preferRicherMarkdown(detailedMarkdown, docsApiHtml, title, docsApiTitle);
356
416
  detailedMarkdown = picked.markdown;
@@ -393,12 +453,14 @@ function buildSalesforceFetchResult(url, html, title, detailedMarkdown, qualityN
393
453
  * @param options - Allowed host, priority content selectors, and optional per-site remove selectors.
394
454
  * @returns Extracted Markdown, fetch metadata, and browser-capture provenance.
395
455
  * @throws {Error} When the URL is invalid, the host is not the allowed host, DNS is unsafe,
396
- * Chrome/CDP navigation or evaluation fails, the response body exceeds the size limit,
397
- * or the captured content is too short / looks like an error page.
456
+ * the document itself is HTTP 404/410, Chrome/CDP navigation or evaluation fails, the page
457
+ * redirects to a different article, the response body exceeds the size limit, or the captured
458
+ * content is too short / looks like an error page.
398
459
  */
399
460
  export async function fetchSalesforceDoc(url, { allowedHost, contentSelectors, removeSelectors = [] }) {
400
461
  const parsed = parseSalesforceDocUrl(url, allowedHost);
401
462
  await checkDnsSafety(parsed.hostname);
463
+ await assertSalesforceDocumentExists(url);
402
464
  const captureScript = buildCaptureScript(contentSelectors, removeSelectors);
403
465
  const page = await openCdpPage();
404
466
  try {
@@ -1,6 +1,12 @@
1
1
  import type { CdpPage } from '../lib/research/browser.js';
2
2
  /** Shared in-page helpers for traversing Salesforce Lightning Web Runtime (LWR) open shadow roots. */
3
3
  export declare const SALESFORCE_SHADOW_DOM_HELPERS = "\nconst deepElements = (root) => {\n const out = [];\n const visit = (node) => {\n if (node.shadowRoot) visit(node.shadowRoot);\n for (const child of Array.from(node.children || [])) {\n out.push(child);\n visit(child);\n }\n };\n visit(root);\n return out;\n};\n\nconst deepText = (root) => {\n let txt = '';\n const visit = (node) => {\n if (node.nodeType === 3) {\n txt += node.textContent || '';\n } else if (node.nodeType === 1 || node.nodeType === 9 || node.nodeType === 11) {\n const tag = node.tagName ? node.tagName.toLowerCase() : '';\n if (tag !== 'style' && tag !== 'script') {\n if (node.shadowRoot) visit(node.shadowRoot);\n for (const child of Array.from(node.childNodes || [])) visit(child);\n }\n }\n };\n visit(root);\n return txt;\n};\n\nconst textLenFor = (el) =>\n Math.max((el.innerText || '').trim().length, deepText(el).trim().length);\n";
4
+ /** True when extracted text is a Salesforce not-found or error shell rather than an article.
5
+ *
6
+ * @param text - Extracted Markdown or visible page text.
7
+ * @returns True when the text matches a Salesforce error/not-found pattern.
8
+ */
9
+ export declare function looksLikeSalesforceError(text: string): boolean;
4
10
  /**
5
11
  * In-page block: pick the highest-priority content container that exists on the page.
6
12
  * Does not fall through to lower-priority selectors when a higher-priority host is present
@@ -33,6 +33,23 @@ const deepText = (root) => {
33
33
  const textLenFor = (el) =>
34
34
  Math.max((el.innerText || '').trim().length, deepText(el).trim().length);
35
35
  `;
36
+ // Shells Salesforce renders for a missing article. Shared with the readiness probe so a
37
+ // not-found page ends the wait instead of burning the full content timeout.
38
+ const SALESFORCE_ERROR_PATTERNS = [
39
+ /page\s*(not\s*found|doesn'?t\s*exist|can'?t\s*be\s*found)/i,
40
+ /we\s*couldn'?t\s*find/i,
41
+ /we looked high and low/i,
42
+ /sorry to interrupt/i,
43
+ /head back to the space station/i,
44
+ ];
45
+ /** True when extracted text is a Salesforce not-found or error shell rather than an article.
46
+ *
47
+ * @param text - Extracted Markdown or visible page text.
48
+ * @returns True when the text matches a Salesforce error/not-found pattern.
49
+ */
50
+ export function looksLikeSalesforceError(text) {
51
+ return SALESFORCE_ERROR_PATTERNS.some((re) => re.test(text));
52
+ }
36
53
  /**
37
54
  * In-page block: pick the highest-priority content container that exists on the page.
38
55
  * Does not fall through to lower-priority selectors when a higher-priority host is present
@@ -64,6 +81,20 @@ export function buildPriorityContainerPickBlock(selectors, minChars, containerVa
64
81
  }
65
82
  }
66
83
  ${containerVar} = ${containerVar} || document.body || document.documentElement;
84
+ // A Redoc hash (#tag/…/operation/…) names one operation inside the reference.
85
+ // getElementById stops at the shadow boundary, so match the id while walking.
86
+ try {
87
+ const hashId = decodeURIComponent((location.hash || '').replace(/^#/, ''));
88
+ if (hashId) {
89
+ let hashBest = null, hashLen = 0;
90
+ for (const el of deepElements(document)) {
91
+ if (el.id !== hashId) continue;
92
+ const len = textLenFor(el);
93
+ if (len > hashLen) { hashBest = el; hashLen = len; }
94
+ }
95
+ if (hashBest && hashLen >= MIN_CHARS) ${containerVar} = hashBest;
96
+ }
97
+ } catch (e) {}
67
98
  `;
68
99
  }
69
100
  /**
@@ -75,8 +106,14 @@ export function buildPriorityContainerPickBlock(selectors, minChars, containerVa
75
106
  * @returns A self-invoking JS expression string that evaluates to `{ has: boolean, len: number }`.
76
107
  */
77
108
  export function buildContentReadyProbeExpression(selectors, minChars) {
109
+ const errorSource = SALESFORCE_ERROR_PATTERNS.map((re) => re.source).join('|');
78
110
  return `(() => {
79
111
  ${SALESFORCE_SHADOW_DOM_HELPERS}
112
+ const errorRe = new RegExp(${JSON.stringify(errorSource)}, 'i');
113
+ const pageText = deepText(document.body || document.documentElement);
114
+ if (errorRe.test(pageText)) {
115
+ return { has: true, len: pageText.length, error: true };
116
+ }
80
117
  const SELS = ${JSON.stringify(selectors)};
81
118
  const MIN = ${minChars};
82
119
  let has = false;
@@ -119,6 +156,9 @@ export async function pollSalesforceContentReady(page, selectors, minChars, time
119
156
  .send('Runtime.evaluate', { expression, returnByValue: true }, page.sessionId)
120
157
  .catch(() => null);
121
158
  const value = rawResult?.result?.value;
159
+ // A not-found shell is ready now; waiting for it to "settle" only delays the failure.
160
+ if (value?.error)
161
+ return;
122
162
  if (value?.has) {
123
163
  const grew = Math.abs((value.len || 0) - previousLength) > Math.max(40, previousLength * 0.02);
124
164
  if (!grew && ++stablePolls >= 2)