@taurgis/bonsai 1.0.5 → 3.0.0

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 (126) hide show
  1. package/README.md +59 -12
  2. package/agents/agents/official-docs-researcher.agent.md +15 -26
  3. package/agents/agents/salesforce-docs-researcher.agent.md +17 -35
  4. package/agents/hooks/README.md +2 -9
  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 +7 -6
  11. package/agents/instructions/web-research.instructions.md +3 -2
  12. package/agents/skills/web-research/SKILL.md +24 -32
  13. package/bin/cli.mjs +44 -14
  14. package/dist/base-command.d.ts +17 -1
  15. package/dist/base-command.js +70 -9
  16. package/dist/commands/config/base.d.ts +2 -0
  17. package/dist/commands/config/base.js +15 -1
  18. package/dist/commands/config/get.d.ts +1 -1
  19. package/dist/commands/config/get.js +4 -5
  20. package/dist/commands/config/index.d.ts +12 -0
  21. package/dist/commands/config/index.js +34 -0
  22. package/dist/commands/config/list.js +2 -2
  23. package/dist/commands/config/set.js +18 -12
  24. package/dist/commands/config/unset.d.ts +1 -1
  25. package/dist/commands/config/unset.js +9 -6
  26. package/dist/commands/fetch.d.ts +2 -0
  27. package/dist/commands/fetch.js +69 -45
  28. package/dist/commands/import.d.ts +3 -0
  29. package/dist/commands/import.js +91 -53
  30. package/dist/commands/inspect.d.ts +8 -0
  31. package/dist/commands/inspect.js +59 -30
  32. package/dist/commands/list.d.ts +5 -3
  33. package/dist/commands/list.js +60 -35
  34. package/dist/commands/prune.d.ts +2 -1
  35. package/dist/commands/prune.js +62 -19
  36. package/dist/commands/status.d.ts +8 -0
  37. package/dist/commands/status.js +84 -26
  38. package/dist/commands.d.ts +2 -2
  39. package/dist/commands.js +2 -2
  40. package/dist/hooks/command-not-found/suggest.d.ts +7 -1
  41. package/dist/hooks/command-not-found/suggest.js +100 -46
  42. package/dist/lib/argv.d.ts +10 -0
  43. package/dist/lib/argv.js +97 -0
  44. package/dist/lib/color.d.ts +20 -0
  45. package/dist/lib/color.js +36 -0
  46. package/dist/lib/envelope.d.ts +36 -0
  47. package/dist/lib/envelope.js +82 -0
  48. package/dist/lib/help-preflight.d.ts +15 -0
  49. package/dist/lib/help-preflight.js +81 -0
  50. package/dist/lib/json-meta.d.ts +10 -0
  51. package/dist/lib/json-meta.js +98 -0
  52. package/dist/lib/limit-flag.d.ts +1 -0
  53. package/dist/lib/limit-flag.js +24 -0
  54. package/dist/lib/research/{search-index.d.ts → artifact-index.d.ts} +5 -5
  55. package/dist/lib/research/{search-index.js → artifact-index.js} +10 -10
  56. package/dist/lib/research/browser.d.ts +0 -8
  57. package/dist/lib/research/browser.js +20 -43
  58. package/dist/lib/research/regression.d.ts +18 -0
  59. package/dist/lib/research/regression.js +47 -0
  60. package/dist/lib/research/revalidate.js +3 -1
  61. package/dist/lib/research/schema.d.ts +5 -2
  62. package/dist/lib/research/schema.js +12 -1
  63. package/dist/lib/research/storage.d.ts +0 -1
  64. package/dist/lib/research/storage.js +15 -13
  65. package/dist/lib/research/url.d.ts +22 -0
  66. package/dist/lib/research/url.js +63 -1
  67. package/dist/lib/text.d.ts +5 -1
  68. package/dist/lib/text.js +11 -1
  69. package/dist/sites/coveo.d.ts +7 -0
  70. package/dist/sites/coveo.js +33 -0
  71. package/dist/sites/index.js +1 -2
  72. package/dist/sites/salesforce/fetch-page.js +1 -1
  73. package/dist/sites/salesforce/index.js +3 -8
  74. package/dist/sites/salesforce-developer/fetch-page.js +3 -1
  75. package/dist/sites/salesforce-developer/index.js +1 -2
  76. package/dist/sites/salesforce-doc-fetch.d.ts +11 -0
  77. package/dist/sites/salesforce-doc-fetch.js +117 -76
  78. package/dist/sites/salesforce-dom-probe.d.ts +16 -0
  79. package/dist/sites/salesforce-dom-probe.js +114 -0
  80. package/dist/sites/types.d.ts +0 -6
  81. package/oclif.manifest.json +153 -155
  82. package/package.json +38 -25
  83. package/dist/commands/index.d.ts +0 -35
  84. package/dist/commands/index.js +0 -306
  85. package/dist/commands/research/config/base.d.ts +0 -23
  86. package/dist/commands/research/config/base.js +0 -46
  87. package/dist/commands/research/config/get.d.ts +0 -20
  88. package/dist/commands/research/config/get.js +0 -45
  89. package/dist/commands/research/config/list.d.ts +0 -17
  90. package/dist/commands/research/config/list.js +0 -42
  91. package/dist/commands/research/config/set.d.ts +0 -31
  92. package/dist/commands/research/config/set.js +0 -94
  93. package/dist/commands/research/config/unset.d.ts +0 -21
  94. package/dist/commands/research/config/unset.js +0 -59
  95. package/dist/commands/research/import.d.ts +0 -38
  96. package/dist/commands/research/import.js +0 -313
  97. package/dist/commands/research/inspect.d.ts +0 -13
  98. package/dist/commands/research/inspect.js +0 -86
  99. package/dist/commands/research/list.d.ts +0 -25
  100. package/dist/commands/research/list.js +0 -143
  101. package/dist/commands/research/prune.d.ts +0 -24
  102. package/dist/commands/research/prune.js +0 -141
  103. package/dist/commands/research/search.d.ts +0 -32
  104. package/dist/commands/research/search.js +0 -334
  105. package/dist/commands/research/status.d.ts +0 -17
  106. package/dist/commands/research/status.js +0 -108
  107. package/dist/commands/research.d.ts +0 -36
  108. package/dist/commands/research.js +0 -304
  109. package/dist/commands/search.d.ts +0 -31
  110. package/dist/commands/search.js +0 -329
  111. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +0 -1
  112. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +0 -2
  113. package/dist/lib/research/docs/discovery-index.d.ts +0 -14
  114. package/dist/lib/research/docs/discovery-index.js +0 -112
  115. package/dist/lib/research/docs/remote-search-runner.d.ts +0 -21
  116. package/dist/lib/research/docs/remote-search-runner.js +0 -114
  117. package/dist/lib/research/docs/remote-search.d.ts +0 -14
  118. package/dist/lib/research/docs/remote-search.js +0 -101
  119. package/dist/lib/research/docs/search-index.d.ts +0 -13
  120. package/dist/lib/research/docs/search-index.js +0 -176
  121. package/dist/sites/salesforce/browser-search.d.ts +0 -7
  122. package/dist/sites/salesforce/browser-search.js +0 -82
  123. package/dist/sites/salesforce/coveo.d.ts +0 -23
  124. package/dist/sites/salesforce/coveo.js +0 -161
  125. package/dist/sites/salesforce/token.d.ts +0 -26
  126. package/dist/sites/salesforce/token.js +0 -154
@@ -0,0 +1,33 @@
1
+ export const ALLOWED_DOC_HOSTS = new Set(['help.salesforce.com', 'developer.salesforce.com']);
2
+ export function isAllowedDocHost(hostname) {
3
+ return ALLOWED_DOC_HOSTS.has(hostname.toLowerCase());
4
+ }
5
+ /**
6
+ * Coveo indexes Help articles under an internal `/help_doccontent` URL; rewrite it to the
7
+ * canonical `/s/articleView` page a human (or our fetcher) can actually open.
8
+ */
9
+ export function normalizeHelpDocContentUrl(rawUrl) {
10
+ let url;
11
+ try {
12
+ url = new URL(rawUrl);
13
+ }
14
+ catch {
15
+ return rawUrl;
16
+ }
17
+ if (url.hostname.toLowerCase() !== 'help.salesforce.com')
18
+ return rawUrl;
19
+ if (!url.pathname.toLowerCase().startsWith('/help_doccontent'))
20
+ return rawUrl;
21
+ const id = url.searchParams.get('id');
22
+ if (!id)
23
+ return rawUrl;
24
+ const articleUrl = new URL('https://help.salesforce.com/s/articleView');
25
+ articleUrl.searchParams.set('id', id.endsWith('.htm') ? id : `${id}.htm`);
26
+ articleUrl.searchParams.set('type', '5');
27
+ for (const key of ['release', 'language']) {
28
+ const value = url.searchParams.get(key);
29
+ if (value)
30
+ articleUrl.searchParams.set(key, value);
31
+ }
32
+ return articleUrl.toString();
33
+ }
@@ -2,10 +2,9 @@ import { salesforce } from './salesforce/index.js';
2
2
  import { salesforceDeveloper } from './salesforce-developer/index.js';
3
3
  // The known documentation sites. A plain constant, not a registry: these ship
4
4
  // with the CLI, so there is nothing to register at runtime. Sites with custom
5
- // fetch/search behavior live in their own module (e.g. ./salesforce). Salesforce Help and
5
+ // fetch behavior live in their own module (e.g. ./salesforce). Salesforce Help and
6
6
  // Salesforce Developer are separate modules — different domains, different extraction.
7
7
  export const SITES = [
8
- { id: 'react', name: 'React', domains: ['react.dev', 'legacy.reactjs.org'] },
9
8
  salesforce,
10
9
  salesforceDeveloper,
11
10
  // TanStack docs embed a GitHub "edit this page" link, so capture resolves the page's source
@@ -1,5 +1,5 @@
1
1
  import { fetchSalesforceDoc } from '../salesforce-doc-fetch.js';
2
- import { normalizeHelpDocContentUrl } from './coveo.js';
2
+ import { normalizeHelpDocContentUrl } from '../coveo.js';
3
3
  // Containers a Help article renders into (inside shadow DOM); the shared fetcher pierces
4
4
  // shadow roots to find them, falling back to the page body when none match.
5
5
  const CONTENT_SELECTORS = [
@@ -1,16 +1,11 @@
1
1
  import { fetchSalesforcePage } from './fetch-page.js';
2
- import { searchSalesforce } from './browser-search.js';
3
- // Scoped to help.salesforce.com — an LWR Experience site with light-DOM article content and
4
- // Coveo (HTCommunity) search. developer.salesforce.com is intentionally NOT routed here: its
5
- // API-reference docs render inside shadow DOM with tabbed/code content that needs a different
6
- // extraction strategy, so it belongs in its own module when built — not bolted onto this one.
7
- // (developer.salesforce.com URLs that appear in Help search results are still surfaced; see
8
- // ALLOWED_DOC_HOSTS in coveo.ts.)
2
+ // Scoped to help.salesforce.com — an LWR Experience site with light-DOM article content.
3
+ // developer.salesforce.com is intentionally NOT routed here: its API-reference docs render
4
+ // inside shadow DOM with tabbed/code content that needs a different extraction strategy.
9
5
  export const salesforce = {
10
6
  id: 'salesforce',
11
7
  name: 'Salesforce Help',
12
8
  domains: ['help.salesforce.com'],
13
9
  defaults: { rendered: true },
14
10
  fetchPage: fetchSalesforcePage,
15
- search: searchSalesforce,
16
11
  };
@@ -2,8 +2,10 @@ import { fetchSalesforceDoc } from '../salesforce-doc-fetch.js';
2
2
  // Containers a developer doc renders into. doc-content-layout / doc-amf-reference are the
3
3
  // web-component hosts for guide pages and API-reference (AMF) pages respectively.
4
4
  const CONTENT_SELECTORS = [
5
- 'doc-content-layout',
5
+ // API-reference pages: doc-amf-reference holds the method/type tables. doc-content-layout only
6
+ // carries the shared deprecation banner and must not win selector priority on those pages.
6
7
  'doc-amf-reference',
8
+ 'doc-content-layout',
7
9
  '.markdown-content',
8
10
  'main article',
9
11
  'article',
@@ -1,8 +1,7 @@
1
1
  import { fetchDeveloperPage } from './fetch-page.js';
2
2
  // developer.salesforce.com docs (guides + API reference) render client-side inside web
3
3
  // components, so content lives in shadow DOM — a fundamentally different extraction strategy
4
- // than help.salesforce.com (see ../salesforce). No `search`: developer docs use a separate
5
- // search backend, not Help's Coveo HTCommunity hub.
4
+ // than help.salesforce.com (see ../salesforce).
6
5
  export const salesforceDeveloper = {
7
6
  id: 'salesforce-developer',
8
7
  name: 'Salesforce Developer',
@@ -2,6 +2,17 @@ import type { SiteFetchResult } from './types.js';
2
2
  /** True when the extracted text is an error/not-found/loading shell rather than real content. */
3
3
  export declare function looksLikeSalesforceError(text: string): boolean;
4
4
  export declare function stripBoilerplate(markdown: string): string;
5
+ /** Parses the Salesforce private content API JSON payload. */
6
+ export declare function parseDocsApiPayload(raw: string): {
7
+ content: string;
8
+ title: string;
9
+ } | null;
10
+ /** Keeps whichever Markdown source carries more structural content (DOM vs content API HTML). */
11
+ export declare function preferRicherMarkdown(domMarkdown: string, apiHtml: string, domTitle: string, apiTitle: string): {
12
+ markdown: string;
13
+ title: string;
14
+ usedApi: boolean;
15
+ };
5
16
  export interface SalesforceDocOptions {
6
17
  allowedHost: string;
7
18
  contentSelectors: string[];
@@ -1,17 +1,22 @@
1
- import { openCdpPage, waitForLoad, waitForContentReady } from '../lib/research/browser.js';
1
+ import { openCdpPage, waitForLoad, ResponseCapture, } from '../lib/research/browser.js';
2
2
  import { checkDnsSafety } from '../lib/research/fetcher.js';
3
3
  import { htmlToMarkdown } from '../lib/research/markdown.js';
4
+ import { contentMetrics, contentRicherThan } from '../lib/research/regression.js';
5
+ import { SALESFORCE_SHADOW_DOM_HELPERS, buildPriorityContainerPickBlock, pollSalesforceContentReady, } from './salesforce-dom-probe.js';
4
6
  // Both help.salesforce.com and developer.salesforce.com are LWR sites that render doc content
5
7
  // client-side inside web components (shadow DOM). document.documentElement.outerHTML captures
6
8
  // only the "Loading…" shell, and Readability prunes the structured result to a single block —
7
9
  // so this shared fetcher serializes the shadow DOM and converts the content container directly.
8
10
  const TIMEOUT_MS = 45_000;
9
11
  const SETTLE_MS = 1_500;
10
- const MIN_CONTENT_CHARS = 100;
12
+ /** Minimum text on the priority content host before wait/capture proceed (shared threshold). */
13
+ const MIN_CONTAINER_CHARS = 100;
11
14
  // Below this a capture is treated as a shell and retried (a fresh reload of the heavy page).
12
15
  const SUBSTANTIAL_CHARS = 300;
13
16
  const MAX_ATTEMPTS = 2;
14
17
  const BODY_LIMIT_BYTES = 6 * 1024 * 1024;
18
+ /** Grace period after DOM settle for late /docs/get_document_content/ responses on guide pages. */
19
+ const DOCS_API_GRACE_MS = 500;
15
20
  const ACCEPT_CONSENT_EXPRESSION = "(() => { const b = document.querySelector('#onetrust-accept-btn-handler'); if (b) b.click(); })()";
16
21
  const ERROR_PATTERNS = [
17
22
  /page\s*(not\s*found|doesn'?t\s*exist|can'?t\s*be\s*found)/i,
@@ -66,6 +71,33 @@ export function stripBoilerplate(markdown) {
66
71
  .replace(/\n{3,}/g, '\n\n')
67
72
  .trim();
68
73
  }
74
+ const DOCS_CONTENT_API = '/docs/get_document_content/';
75
+ /** Parses the Salesforce private content API JSON payload. */
76
+ export function parseDocsApiPayload(raw) {
77
+ if (!raw?.trim())
78
+ return null;
79
+ try {
80
+ const payload = JSON.parse(raw);
81
+ if (!payload.content)
82
+ return null;
83
+ return { content: payload.content, title: payload.title || payload.id || '' };
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /** Keeps whichever Markdown source carries more structural content (DOM vs content API HTML). */
90
+ export function preferRicherMarkdown(domMarkdown, apiHtml, domTitle, apiTitle) {
91
+ const apiMarkdown = stripBoilerplate(htmlToMarkdown(apiHtml));
92
+ if (contentRicherThan(contentMetrics(apiMarkdown), contentMetrics(domMarkdown))) {
93
+ return {
94
+ markdown: apiMarkdown,
95
+ title: apiTitle || domTitle,
96
+ usedApi: true,
97
+ };
98
+ }
99
+ return { markdown: domMarkdown, title: domTitle, usedApi: false };
100
+ }
69
101
  /**
70
102
  * In-page capture. Expands collapsed "Show" sections, inlines dx-code-block source (its code
71
103
  * lives in a `code-block` attribute, not text), strips chrome, then picks the richest content
@@ -73,22 +105,9 @@ export function stripBoilerplate(markdown) {
73
105
  * Help pages (which lack those components) and load-bearing on Developer API-reference pages.
74
106
  */
75
107
  function buildCaptureScript(contentSelectors, extraRemove) {
108
+ const containerPick = buildPriorityContainerPickBlock(contentSelectors, MIN_CONTAINER_CHARS);
76
109
  return `(async () => {
77
- // Collects every descendant element across open shadow roots AND light DOM, including the
78
- // root's own shadow root (so it works both document-wide and scoped to a single component).
79
- const deepElements = (root) => {
80
- const out = [];
81
- const visit = (node) => {
82
- if (node.shadowRoot) visit(node.shadowRoot);
83
- for (const child of Array.from(node.children || [])) {
84
- out.push(child);
85
- visit(child);
86
- }
87
- };
88
- visit(root);
89
- return out;
90
- };
91
-
110
+ ${SALESFORCE_SHADOW_DOM_HELPERS}
92
111
  // Collapse controls vary by page: <anypoint-button class="toggle-button">, <dx-button>, and
93
112
  // older <* class="complex-toggle">. They all render as a button-like element whose entire
94
113
  // label is just "Show"/"Hide", so match on that shape rather than a fixed tag list.
@@ -229,34 +248,12 @@ function buildCaptureScript(contentSelectors, extraRemove) {
229
248
  };
230
249
  const serialize = (el) => { const root = document.createElement('div'); append(el, root, 0); return root.innerHTML; };
231
250
 
232
- // Pick the content container by selector PRIORITY, not by max text: the selectors are ordered
233
- // specific→general, so the first one with substantial content is the tight article/doc wrapper.
234
- // Picking the largest match instead pulls in broader page chrome (nav tree, breadcrumb, chat
235
- // widget) that surrounds the article. innerText is used because it pierces shadow roots
236
- // (textContent does not), so a real container is measured rather than scoring 0.
237
- const SELECTORS = ${JSON.stringify(contentSelectors)};
238
- const MIN_PICK_CHARS = 200;
239
- let container = null;
240
- for (const sel of SELECTORS) {
241
- let best = null, bestLen = 0;
242
- for (const el of deepElements(document)) {
243
- let matched; try { matched = el.matches && el.matches(sel); } catch (e) { matched = false; }
244
- if (!matched) continue;
245
- const len = (el.innerText || '').trim().length;
246
- if (len > bestLen) { best = el; bestLen = len; }
247
- }
248
- if (best && bestLen >= MIN_PICK_CHARS) { container = best; break; }
249
- }
250
- container = container || document.body || document.documentElement;
251
+ ${containerPick}
251
252
  const title = (document.querySelector('h1')?.textContent || document.title || '').trim();
252
253
  return { html: serialize(container), title };
253
254
  })()`;
254
255
  }
255
- /**
256
- * Renders a Salesforce LWR doc page, serializes its shadow-DOM content container, and converts
257
- * it to Markdown. Validates the host before any network access.
258
- */
259
- export async function fetchSalesforceDoc(url, { allowedHost, contentSelectors, removeSelectors = [] }) {
256
+ function parseSalesforceDocUrl(url, allowedHost) {
260
257
  let parsed;
261
258
  try {
262
259
  parsed = new URL(url);
@@ -267,6 +264,74 @@ export async function fetchSalesforceDoc(url, { allowedHost, contentSelectors, r
267
264
  if (parsed.hostname.toLowerCase() !== allowedHost) {
268
265
  throw new Error(`Refusing to fetch host "${parsed.hostname}" (expected ${allowedHost}).`);
269
266
  }
267
+ return parsed;
268
+ }
269
+ function assertReadableSalesforceContent(detailedMarkdown, url) {
270
+ if (detailedMarkdown.length < MIN_CONTAINER_CHARS || looksLikeSalesforceError(detailedMarkdown)) {
271
+ throw new Error(`Salesforce returned no readable content for ${url} (page may be missing, gated, or still loading).`);
272
+ }
273
+ }
274
+ async function captureSalesforceAttempt(page, url, captureScript, contentSelectors) {
275
+ const docsCapture = new ResponseCapture(page, [
276
+ {
277
+ key: 'docs-content',
278
+ test: (request) => request.url.includes(DOCS_CONTENT_API),
279
+ accept: (body) => parseDocsApiPayload(body) !== null,
280
+ },
281
+ ]);
282
+ await page.client.send('Page.navigate', { url }, page.sessionId);
283
+ await waitForLoad(page.client, page.sessionId, TIMEOUT_MS, 0).catch(() => { });
284
+ await page.client
285
+ .send('Runtime.evaluate', { expression: ACCEPT_CONSENT_EXPRESSION }, page.sessionId)
286
+ .catch(() => { });
287
+ await pollSalesforceContentReady(page, contentSelectors, MIN_CONTAINER_CHARS, TIMEOUT_MS);
288
+ await new Promise((r) => setTimeout(r, SETTLE_MS));
289
+ const docsPayload = parseDocsApiPayload((await docsCapture.waitFor('docs-content', DOCS_API_GRACE_MS)) ?? '');
290
+ const docsApiHtml = docsPayload?.content ?? '';
291
+ const docsApiTitle = docsPayload?.title ?? '';
292
+ const result = await page.client.send('Runtime.evaluate', { expression: captureScript, awaitPromise: true, returnByValue: true }, page.sessionId);
293
+ const captured = result?.result?.value;
294
+ const html = typeof captured?.html === 'string' ? captured.html : '';
295
+ const title = captured?.title || '';
296
+ if (Buffer.byteLength(html) > BODY_LIMIT_BYTES) {
297
+ throw new Error(`Page exceeded body size limit (${Buffer.byteLength(html)} bytes).`);
298
+ }
299
+ let detailedMarkdown = stripBoilerplate(htmlToMarkdown(html));
300
+ let usedDocsApi = false;
301
+ if (docsApiHtml) {
302
+ const picked = preferRicherMarkdown(detailedMarkdown, docsApiHtml, title, docsApiTitle);
303
+ detailedMarkdown = picked.markdown;
304
+ if (picked.usedApi) {
305
+ usedDocsApi = true;
306
+ }
307
+ return { html, title: picked.title, detailedMarkdown, usedDocsApi };
308
+ }
309
+ return { html, title, detailedMarkdown, usedDocsApi };
310
+ }
311
+ function buildSalesforceFetchResult(url, html, title, detailedMarkdown, qualityNotes) {
312
+ return {
313
+ fetchResult: {
314
+ contentType: 'text/html',
315
+ etag: null,
316
+ lastModified: null,
317
+ finalUrl: url,
318
+ responseSize: Buffer.byteLength(html),
319
+ content: html,
320
+ },
321
+ extraction: {
322
+ title: title || url,
323
+ detailedMarkdown,
324
+ confidence: confidenceFor(detailedMarkdown.length),
325
+ qualityNotes,
326
+ },
327
+ };
328
+ }
329
+ /**
330
+ * Renders a Salesforce LWR doc page, serializes its shadow-DOM content container, and converts
331
+ * it to Markdown. Validates the host before any network access.
332
+ */
333
+ export async function fetchSalesforceDoc(url, { allowedHost, contentSelectors, removeSelectors = [] }) {
334
+ const parsed = parseSalesforceDocUrl(url, allowedHost);
270
335
  await checkDnsSafety(parsed.hostname);
271
336
  const captureScript = buildCaptureScript(contentSelectors, removeSelectors);
272
337
  const page = await openCdpPage();
@@ -274,46 +339,22 @@ export async function fetchSalesforceDoc(url, { allowedHost, contentSelectors, r
274
339
  let html = '';
275
340
  let title = '';
276
341
  let detailedMarkdown = '';
277
- // The heavy api-console renders intermittently slowly; reload and retry if a capture comes
278
- // back shell-thin rather than caching an empty page.
342
+ let usedDocsApi = false;
343
+ const qualityNotes = ['extracted from rendered shadow-DOM content container'];
279
344
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
280
- await page.client.send('Page.navigate', { url }, page.sessionId);
281
- await waitForLoad(page.client, page.sessionId, TIMEOUT_MS, 0).catch(() => { });
282
- await page.client
283
- .send('Runtime.evaluate', { expression: ACCEPT_CONSENT_EXPRESSION }, page.sessionId)
284
- .catch(() => { });
285
- await waitForContentReady(page, contentSelectors, MIN_CONTENT_CHARS, TIMEOUT_MS);
286
- await new Promise((r) => setTimeout(r, SETTLE_MS));
287
- const result = await page.client.send('Runtime.evaluate', { expression: captureScript, awaitPromise: true, returnByValue: true }, page.sessionId);
288
- const captured = result?.result?.value;
289
- html = typeof captured?.html === 'string' ? captured.html : '';
290
- title = captured?.title || '';
291
- if (Buffer.byteLength(html) > BODY_LIMIT_BYTES) {
292
- throw new Error(`Page exceeded body size limit (${Buffer.byteLength(html)} bytes).`);
293
- }
294
- detailedMarkdown = stripBoilerplate(htmlToMarkdown(html));
345
+ const captured = await captureSalesforceAttempt(page, url, captureScript, contentSelectors);
346
+ html = captured.html;
347
+ title = captured.title;
348
+ detailedMarkdown = captured.detailedMarkdown;
349
+ usedDocsApi = usedDocsApi || captured.usedDocsApi;
295
350
  if (detailedMarkdown.length >= SUBSTANTIAL_CHARS)
296
351
  break;
297
352
  }
298
- if (detailedMarkdown.length < MIN_CONTENT_CHARS || looksLikeSalesforceError(detailedMarkdown)) {
299
- throw new Error(`Salesforce returned no readable content for ${url} (page may be missing, gated, or still loading).`);
353
+ assertReadableSalesforceContent(detailedMarkdown, url);
354
+ if (usedDocsApi) {
355
+ qualityNotes.push('article body from /docs/get_document_content/ API');
300
356
  }
301
- return {
302
- fetchResult: {
303
- contentType: 'text/html',
304
- etag: null,
305
- lastModified: null,
306
- finalUrl: url,
307
- responseSize: Buffer.byteLength(html),
308
- content: html,
309
- },
310
- extraction: {
311
- title: title || url,
312
- detailedMarkdown,
313
- confidence: confidenceFor(detailedMarkdown.length),
314
- qualityNotes: ['extracted from rendered shadow-DOM content container'],
315
- },
316
- };
357
+ return buildSalesforceFetchResult(url, html, title, detailedMarkdown, qualityNotes);
317
358
  }
318
359
  finally {
319
360
  await page.close();
@@ -0,0 +1,16 @@
1
+ import type { CdpPage } from '../lib/research/browser.js';
2
+ /** Shared in-page helpers for traversing Salesforce LWR open shadow roots. */
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
+ /**
5
+ * In-page block: pick the highest-priority content container that exists on the page.
6
+ * Does not fall through to lower-priority selectors when a higher-priority host is present
7
+ * (e.g. doc-content-layout banner vs doc-amf-reference table).
8
+ */
9
+ export declare function buildPriorityContainerPickBlock(selectors: string[], minChars: number, containerVar?: string): string;
10
+ /** CDP expression polled until the priority content host reaches minChars and the body settles. */
11
+ export declare function buildContentReadyProbeExpression(selectors: string[], minChars: number): string;
12
+ /**
13
+ * Polls until the priority Salesforce content host is ready and body text has settled.
14
+ * Returns without throwing on timeout; the caller captures whatever rendered and judges quality.
15
+ */
16
+ export declare function pollSalesforceContentReady(page: CdpPage, selectors: string[], minChars: number, timeoutMs: number): Promise<void>;
@@ -0,0 +1,114 @@
1
+ /** Shared in-page helpers for traversing Salesforce LWR open shadow roots. */
2
+ export const SALESFORCE_SHADOW_DOM_HELPERS = `
3
+ const deepElements = (root) => {
4
+ const out = [];
5
+ const visit = (node) => {
6
+ if (node.shadowRoot) visit(node.shadowRoot);
7
+ for (const child of Array.from(node.children || [])) {
8
+ out.push(child);
9
+ visit(child);
10
+ }
11
+ };
12
+ visit(root);
13
+ return out;
14
+ };
15
+
16
+ const deepText = (root) => {
17
+ let txt = '';
18
+ const visit = (node) => {
19
+ if (node.nodeType === 3) {
20
+ txt += node.textContent || '';
21
+ } else if (node.nodeType === 1 || node.nodeType === 9 || node.nodeType === 11) {
22
+ const tag = node.tagName ? node.tagName.toLowerCase() : '';
23
+ if (tag !== 'style' && tag !== 'script') {
24
+ if (node.shadowRoot) visit(node.shadowRoot);
25
+ for (const child of Array.from(node.childNodes || [])) visit(child);
26
+ }
27
+ }
28
+ };
29
+ visit(root);
30
+ return txt;
31
+ };
32
+
33
+ const textLenFor = (el) =>
34
+ Math.max((el.innerText || '').trim().length, deepText(el).trim().length);
35
+ `;
36
+ /**
37
+ * In-page block: pick the highest-priority content container that exists on the page.
38
+ * Does not fall through to lower-priority selectors when a higher-priority host is present
39
+ * (e.g. doc-content-layout banner vs doc-amf-reference table).
40
+ */
41
+ export function buildPriorityContainerPickBlock(selectors, minChars, containerVar = 'container') {
42
+ return `
43
+ let ${containerVar} = null;
44
+ const SELECTORS = ${JSON.stringify(selectors)};
45
+ const MIN_CHARS = ${minChars};
46
+ for (const sel of SELECTORS) {
47
+ let best = null, bestLen = 0, anyMatch = false;
48
+ for (const el of deepElements(document)) {
49
+ let matched;
50
+ try { matched = el.matches && el.matches(sel); } catch (e) { matched = false; }
51
+ if (!matched) continue;
52
+ anyMatch = true;
53
+ const len = textLenFor(el);
54
+ if (len > bestLen) { best = el; bestLen = len; }
55
+ }
56
+ if (anyMatch) {
57
+ if (best && bestLen >= MIN_CHARS) ${containerVar} = best;
58
+ break;
59
+ }
60
+ }
61
+ ${containerVar} = ${containerVar} || document.body || document.documentElement;
62
+ `;
63
+ }
64
+ /** CDP expression polled until the priority content host reaches minChars and the body settles. */
65
+ export function buildContentReadyProbeExpression(selectors, minChars) {
66
+ return `(() => {
67
+ ${SALESFORCE_SHADOW_DOM_HELPERS}
68
+ const SELS = ${JSON.stringify(selectors)};
69
+ const MIN = ${minChars};
70
+ let has = false;
71
+ for (const sel of SELS) {
72
+ let bestLen = 0, anyMatch = false;
73
+ for (const el of deepElements(document)) {
74
+ let matched;
75
+ try { matched = el.matches && el.matches(sel); } catch (e) { matched = false; }
76
+ if (!matched) continue;
77
+ anyMatch = true;
78
+ const len = textLenFor(el);
79
+ if (len > bestLen) bestLen = len;
80
+ }
81
+ if (anyMatch) {
82
+ has = bestLen >= MIN;
83
+ break;
84
+ }
85
+ }
86
+ const len = document.body ? ((document.body.innerText) || '').length : 0;
87
+ return { has, len };
88
+ })()`;
89
+ }
90
+ /**
91
+ * Polls until the priority Salesforce content host is ready and body text has settled.
92
+ * Returns without throwing on timeout; the caller captures whatever rendered and judges quality.
93
+ */
94
+ export async function pollSalesforceContentReady(page, selectors, minChars, timeoutMs) {
95
+ const expression = buildContentReadyProbeExpression(selectors, minChars);
96
+ const start = Date.now();
97
+ let previousLength = -1;
98
+ let stablePolls = 0;
99
+ while (Date.now() - start < timeoutMs) {
100
+ const rawResult = await page.client
101
+ .send('Runtime.evaluate', { expression, returnByValue: true }, page.sessionId)
102
+ .catch(() => null);
103
+ const value = rawResult?.result?.value;
104
+ if (value?.has) {
105
+ const grew = Math.abs((value.len || 0) - previousLength) > Math.max(40, previousLength * 0.02);
106
+ if (!grew && ++stablePolls >= 2)
107
+ return;
108
+ if (grew)
109
+ stablePolls = 0;
110
+ previousLength = value.len || 0;
111
+ }
112
+ await new Promise((r) => setTimeout(r, 500));
113
+ }
114
+ }
@@ -10,11 +10,6 @@ export interface SiteFetchResult {
10
10
  };
11
11
  extraction: ExtractionResult;
12
12
  }
13
- export interface SiteSearchResult {
14
- url: string;
15
- title: string;
16
- snippet?: string;
17
- }
18
13
  export interface SiteModule {
19
14
  id: string;
20
15
  name: string;
@@ -23,5 +18,4 @@ export interface SiteModule {
23
18
  rendered?: boolean;
24
19
  };
25
20
  fetchPage?: (url: string) => Promise<SiteFetchResult>;
26
- search?: (query: string) => Promise<SiteSearchResult[]>;
27
21
  }