@taurgis/bonsai 3.2.0 → 3.2.2

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 (162) hide show
  1. package/README.md +7 -4
  2. package/agents/agents/salesforce-docs-researcher.agent.md +14 -1
  3. package/agents/instructions/salesforce-research.instructions.md +2 -1
  4. package/bin/cli.mjs +9 -17
  5. package/dist/base-command.d.ts +22 -12
  6. package/dist/base-command.js +86 -49
  7. package/dist/commands/config/base.js +11 -2
  8. package/dist/commands/config/get.js +8 -12
  9. package/dist/commands/config/index.js +2 -2
  10. package/dist/commands/config/list.js +10 -16
  11. package/dist/commands/config/set.d.ts +2 -1
  12. package/dist/commands/config/set.js +34 -27
  13. package/dist/commands/config/unset.d.ts +2 -1
  14. package/dist/commands/config/unset.js +25 -24
  15. package/dist/commands/fetch.d.ts +7 -15
  16. package/dist/commands/fetch.js +57 -401
  17. package/dist/commands/import.d.ts +4 -9
  18. package/dist/commands/import.js +56 -284
  19. package/dist/commands/inspect.d.ts +7 -1
  20. package/dist/commands/inspect.js +103 -52
  21. package/dist/commands/list.d.ts +13 -1
  22. package/dist/commands/list.js +79 -32
  23. package/dist/commands/prune.d.ts +4 -1
  24. package/dist/commands/prune.js +55 -75
  25. package/dist/commands/status.d.ts +3 -2
  26. package/dist/commands/status.js +52 -79
  27. package/dist/hooks/command-not-found/suggest.d.ts +1 -0
  28. package/dist/hooks/command-not-found/suggest.js +66 -39
  29. package/dist/lib/argv.d.ts +24 -3
  30. package/dist/lib/argv.js +124 -53
  31. package/dist/lib/batch.d.ts +41 -0
  32. package/dist/lib/batch.js +39 -0
  33. package/dist/lib/cache-view.d.ts +21 -0
  34. package/dist/lib/cache-view.js +30 -0
  35. package/dist/lib/cli-emit.d.ts +28 -0
  36. package/dist/lib/cli-emit.js +44 -0
  37. package/dist/lib/cli-error-policy.d.ts +57 -0
  38. package/dist/lib/cli-error-policy.js +74 -0
  39. package/dist/lib/cli-flag-manifest.d.ts +5 -0
  40. package/dist/lib/cli-flag-manifest.js +8 -0
  41. package/dist/lib/cli-presentation.d.ts +39 -0
  42. package/dist/lib/cli-presentation.js +50 -0
  43. package/dist/lib/cli-result-types.d.ts +88 -0
  44. package/dist/lib/cli-result-types.js +2 -0
  45. package/dist/lib/config/index.d.ts +21 -3
  46. package/dist/lib/config/index.js +58 -3
  47. package/dist/lib/config-persist.d.ts +28 -0
  48. package/dist/lib/config-persist.js +29 -0
  49. package/dist/lib/envelope.d.ts +41 -3
  50. package/dist/lib/envelope.js +147 -4
  51. package/dist/lib/help-preflight.d.ts +5 -8
  52. package/dist/lib/help-preflight.js +13 -32
  53. package/dist/lib/json-meta.d.ts +0 -2
  54. package/dist/lib/json-meta.js +21 -25
  55. package/dist/lib/limit-flag.d.ts +11 -1
  56. package/dist/lib/limit-flag.js +19 -6
  57. package/dist/lib/parse-error-ux.d.ts +5 -0
  58. package/dist/lib/parse-error-ux.js +73 -0
  59. package/dist/lib/prune-flags.d.ts +28 -0
  60. package/dist/lib/prune-flags.js +74 -0
  61. package/dist/lib/research/artifact-index.d.ts +11 -2
  62. package/dist/lib/research/artifact-index.js +7 -4
  63. package/dist/lib/research/browser.d.ts +41 -2
  64. package/dist/lib/research/browser.js +139 -50
  65. package/dist/lib/research/capture.d.ts +24 -2
  66. package/dist/lib/research/capture.js +24 -4
  67. package/dist/lib/research/cli-io.d.ts +33 -0
  68. package/dist/lib/research/cli-io.js +27 -0
  69. package/dist/lib/research/compress.d.ts +7 -0
  70. package/dist/lib/research/compress.js +7 -3
  71. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  72. package/dist/lib/research/docs/capabilities.js +5 -0
  73. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  74. package/dist/lib/research/docs/clean-dom.js +2 -0
  75. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  76. package/dist/lib/research/docs/code-blocks.js +2 -0
  77. package/dist/lib/research/docs/detect.d.ts +4 -0
  78. package/dist/lib/research/docs/detect.js +4 -0
  79. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  80. package/dist/lib/research/docs/machine-readable.js +10 -0
  81. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  82. package/dist/lib/research/docs/markdown-source.js +19 -2
  83. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  84. package/dist/lib/research/docs/quality-gates.js +4 -0
  85. package/dist/lib/research/docs/rsc.d.ts +9 -2
  86. package/dist/lib/research/docs/rsc.js +5 -2
  87. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  88. package/dist/lib/research/docs/section-artifacts.js +12 -0
  89. package/dist/lib/research/docs/sections.d.ts +18 -0
  90. package/dist/lib/research/docs/sections.js +17 -1
  91. package/dist/lib/research/docs/source-map.d.ts +28 -0
  92. package/dist/lib/research/docs/source-map.js +27 -0
  93. package/dist/lib/research/docs/validate.d.ts +35 -2
  94. package/dist/lib/research/docs/validate.js +47 -2
  95. package/dist/lib/research/extract.d.ts +16 -0
  96. package/dist/lib/research/extract.js +12 -1
  97. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  98. package/dist/lib/research/fetch-command-service.js +310 -0
  99. package/dist/lib/research/fetch-result.d.ts +114 -0
  100. package/dist/lib/research/fetch-result.js +212 -0
  101. package/dist/lib/research/fetcher.d.ts +46 -8
  102. package/dist/lib/research/fetcher.js +58 -30
  103. package/dist/lib/research/freshness.d.ts +54 -11
  104. package/dist/lib/research/freshness.js +91 -31
  105. package/dist/lib/research/import-command-service.d.ts +56 -0
  106. package/dist/lib/research/import-command-service.js +381 -0
  107. package/dist/lib/research/keywords.js +5 -1
  108. package/dist/lib/research/markdown.d.ts +12 -1
  109. package/dist/lib/research/markdown.js +13 -5
  110. package/dist/lib/research/persist-artifact.d.ts +23 -0
  111. package/dist/lib/research/persist-artifact.js +27 -0
  112. package/dist/lib/research/prompt-injection.d.ts +8 -0
  113. package/dist/lib/research/prompt-injection.js +42 -13
  114. package/dist/lib/research/proxy.d.ts +14 -8
  115. package/dist/lib/research/proxy.js +14 -10
  116. package/dist/lib/research/revalidate.d.ts +49 -20
  117. package/dist/lib/research/revalidate.js +80 -15
  118. package/dist/lib/research/schema.d.ts +7 -0
  119. package/dist/lib/research/schema.js +2 -0
  120. package/dist/lib/research/secret-scan.d.ts +6 -0
  121. package/dist/lib/research/secret-scan.js +8 -0
  122. package/dist/lib/research/secure-write.d.ts +4 -0
  123. package/dist/lib/research/secure-write.js +31 -2
  124. package/dist/lib/research/storage.d.ts +5 -1
  125. package/dist/lib/research/storage.js +8 -3
  126. package/dist/lib/research/summarize.d.ts +15 -0
  127. package/dist/lib/research/summarize.js +11 -0
  128. package/dist/lib/research/url.d.ts +9 -6
  129. package/dist/lib/research/url.js +33 -21
  130. package/dist/lib/text.d.ts +7 -2
  131. package/dist/lib/text.js +13 -2
  132. package/dist/lib/value-flags.d.ts +14 -0
  133. package/dist/lib/value-flags.js +28 -0
  134. package/dist/sites/coveo.d.ts +10 -0
  135. package/dist/sites/coveo.js +10 -0
  136. package/dist/sites/index.d.ts +16 -0
  137. package/dist/sites/index.js +16 -4
  138. package/dist/sites/markdown-twin.d.ts +31 -0
  139. package/dist/sites/markdown-twin.js +56 -0
  140. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  141. package/dist/sites/salesforce/fetch-page.js +14 -3
  142. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  143. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  144. package/dist/sites/salesforce/index.d.ts +1 -0
  145. package/dist/sites/salesforce/index.js +1 -0
  146. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  147. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  148. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  149. package/dist/sites/salesforce-developer/index.js +1 -0
  150. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  151. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  152. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  153. package/dist/sites/salesforce-doc-fetch.js +52 -7
  154. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  155. package/dist/sites/salesforce-dom-probe.js +20 -2
  156. package/dist/sites/types.d.ts +14 -8
  157. package/dist/sites/types.js +3 -0
  158. package/oclif.manifest.json +104 -98
  159. package/package.json +19 -7
  160. package/agents/agents/official-docs-researcher.agent.md +0 -87
  161. package/agents/instructions/web-research.instructions.md +0 -44
  162. package/agents/skills/web-research/SKILL.md +0 -118
@@ -0,0 +1,74 @@
1
+ import { emptyUrlFilterError } from './research/url.js';
2
+ import { durationFlagError } from './research/freshness.js';
3
+ function missingFilterError(input) {
4
+ // Treat an explicitly passed empty string as "filter was attempted" so `--older-than ''`
5
+ // is not misreported as MISSING_FILTER (durationError rejects it as INVALID_DURATION).
6
+ if (input.olderThan !== undefined ||
7
+ input.inactive !== undefined ||
8
+ input.artifactType !== undefined ||
9
+ input.url !== undefined) {
10
+ return null;
11
+ }
12
+ return {
13
+ message: 'Must specify at least one pruning filter: --older-than, --inactive, --artifact-type, or --url.',
14
+ code: 'MISSING_FILTER',
15
+ suggestions: [`Preview age-based pruning: ${input.bin} prune --older-than 30d --dry-run`],
16
+ };
17
+ }
18
+ /**
19
+ * Mutation-gate checks. READ_ONLY before --dry-run/--yes conflict so
20
+ * `--dry-run --yes --read-only` reports the more specific READ_ONLY_MODE code.
21
+ */
22
+ function mutationSafetyError(input) {
23
+ if (input.readOnly && input.yes) {
24
+ return {
25
+ message: '--yes cannot be used while read-only mode is active (--read-only/--plan or BONSAI_READ_ONLY/BONSAI_PLAN_MODE): mutations are disabled.',
26
+ code: 'READ_ONLY_MODE',
27
+ suggestions: [`Preview instead: ${input.bin} prune --dry-run`],
28
+ };
29
+ }
30
+ if (input.dryRun && input.yes) {
31
+ return {
32
+ message: '--dry-run and --yes are mutually exclusive: --dry-run previews without deleting, --yes confirms deletion. Choose one.',
33
+ code: 'CONFLICTING_FLAGS',
34
+ suggestions: [
35
+ `Preview without deleting: ${input.bin} prune --dry-run`,
36
+ `Or confirm deletion: ${input.bin} prune --yes`,
37
+ ],
38
+ };
39
+ }
40
+ // Read-only mode implicitly previews, so the usual "pick one" safety check is redundant then.
41
+ if (!input.readOnly && !input.dryRun && !input.yes) {
42
+ const olderThanPart = input.olderThan ? ` --older-than ${input.olderThan}` : '';
43
+ const urlPart = input.url ? ` --url "${input.url}"` : '';
44
+ return {
45
+ message: 'Safety check: use --yes to confirm pruning, or --dry-run to preview files that would be deleted.',
46
+ code: 'SAFETY_CHECK_REQUIRED',
47
+ suggestions: [`Preview first: ${input.bin} prune --dry-run${olderThanPart}${urlPart}`],
48
+ };
49
+ }
50
+ return null;
51
+ }
52
+ function durationError(input) {
53
+ for (const msg of [
54
+ durationFlagError('--older-than', input.olderThan),
55
+ durationFlagError('--inactive', input.inactive),
56
+ ]) {
57
+ if (msg)
58
+ return { message: msg, code: 'INVALID_DURATION' };
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * Return the first prune usage/safety error, or `null` when flags are valid.
64
+ *
65
+ * @param input - Parsed prune flags and binary name.
66
+ * @returns Error descriptor for `this.error`, or `null`.
67
+ */
68
+ export function pruneFlagError(input) {
69
+ const urlErr = emptyUrlFilterError(input.url);
70
+ if (urlErr)
71
+ return { message: urlErr, code: 'INVALID_FLAG_VALUE' };
72
+ // Duration before missing-filter so `--older-than ''` reports INVALID_DURATION, not MISSING_FILTER.
73
+ return durationError(input) ?? missingFilterError(input) ?? mutationSafetyError(input);
74
+ }
@@ -3,16 +3,25 @@ export interface IndexedArtifact {
3
3
  artifact: ResearchArtifact;
4
4
  filePath: string;
5
5
  }
6
+ export interface LoadIndexedArtifactsOptions {
7
+ /**
8
+ * When false, skip writing `.search-index.json` (read-only/plan mode). Results are still
9
+ * computed from disk; only the derived sidecar is withheld. Defaults to true.
10
+ */
11
+ persist?: boolean;
12
+ }
6
13
  /**
7
14
  * Returns the indexed view of every active artifact in one research dir, using the sidecar index
8
15
  * to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
9
16
  * index (best-effort; a write failure never breaks callers). Corrupt/unreadable files are skipped —
10
17
  * the real read paths in storage.ts handle archiving them.
18
+ *
19
+ * Pass `{ persist: false }` under read-only/plan mode so the derived sidecar is never written.
11
20
  */
12
- export declare function loadIndexedArtifactsForDir(researchDir: string): IndexedArtifact[];
21
+ export declare function loadIndexedArtifactsForDir(researchDir: string, options?: LoadIndexedArtifactsOptions): IndexedArtifact[];
13
22
  /**
14
23
  * Multi-root variant: scans the `research/` dir of each data dir in order and deduplicates by cache
15
24
  * key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
16
25
  * {@link scanCacheDirs} but reads through the per-root sidecar index.
17
26
  */
18
- export declare function loadIndexedArtifacts(dataDirs: string[]): IndexedArtifact[];
27
+ export declare function loadIndexedArtifacts(dataDirs: string[], options?: LoadIndexedArtifactsOptions): IndexedArtifact[];
@@ -52,10 +52,13 @@ function getOrUpdateIndexEntry(filePath, cached, onChanged) {
52
52
  * to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
53
53
  * index (best-effort; a write failure never breaks callers). Corrupt/unreadable files are skipped —
54
54
  * the real read paths in storage.ts handle archiving them.
55
+ *
56
+ * Pass `{ persist: false }` under read-only/plan mode so the derived sidecar is never written.
55
57
  */
56
- export function loadIndexedArtifactsForDir(researchDir) {
58
+ export function loadIndexedArtifactsForDir(researchDir, options = {}) {
57
59
  if (!existsSync(researchDir))
58
60
  return [];
61
+ const persist = options.persist !== false;
59
62
  const indexPath = join(researchDir, INDEX_FILE);
60
63
  const previous = loadIndex(indexPath);
61
64
  const entries = {};
@@ -78,7 +81,7 @@ export function loadIndexedArtifactsForDir(researchDir) {
78
81
  if (!changed) {
79
82
  changed = Object.keys(previous.entries).some((key) => !(key in entries));
80
83
  }
81
- if (changed) {
84
+ if (changed && persist) {
82
85
  try {
83
86
  atomicWriteFile(indexPath, JSON.stringify({ version: INDEX_VERSION, entries }));
84
87
  }
@@ -93,11 +96,11 @@ export function loadIndexedArtifactsForDir(researchDir) {
93
96
  * key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
94
97
  * {@link scanCacheDirs} but reads through the per-root sidecar index.
95
98
  */
96
- export function loadIndexedArtifacts(dataDirs) {
99
+ export function loadIndexedArtifacts(dataDirs, options = {}) {
97
100
  const seen = new Set();
98
101
  const all = [];
99
102
  for (const dataDir of dataDirs) {
100
- for (const located of loadIndexedArtifactsForDir(join(dataDir, 'research'))) {
103
+ for (const located of loadIndexedArtifactsForDir(join(dataDir, 'research'), options)) {
101
104
  const key = located.artifact.metadata.cache_key;
102
105
  if (seen.has(key))
103
106
  continue;
@@ -31,6 +31,11 @@ export interface CdpPage {
31
31
  client: CdpClient;
32
32
  sessionId: string;
33
33
  close: () => Promise<void>;
34
+ /**
35
+ * Returns (and clears) the reason the most recent navigation request was blocked by the
36
+ * per-hop safety guard installed in {@link openCdpPage}, or `null` if none was blocked.
37
+ */
38
+ takeBlockedNavigationError: () => Error | null;
34
39
  }
35
40
  /** Status line of the main-frame document response, captured from Network.responseReceived. */
36
41
  export interface MainDocumentResponse {
@@ -45,8 +50,20 @@ export interface MainDocumentResponse {
45
50
  */
46
51
  export declare function assertRenderedHttpOk(mainDoc: MainDocumentResponse | undefined): void;
47
52
  /**
48
- * Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
49
- * and Runtime domains. The caller drives navigation and must call close() when finished.
53
+ * Validates a navigated (or redirected) Document request against the same scheme/credentials/DNS
54
+ * safety `normalizeUrl` and `fetchWithRedirects` enforce per hop in url.ts/fetcher.ts. Chrome
55
+ * follows redirects internally with no hook back into that check, so a page that starts on a safe
56
+ * public host could otherwise redirect a rendered-fallback capture into a private/internal address
57
+ * (SSRF) or a non-http(s) scheme (e.g. `file:`) that the static fetcher would have rejected outright.
58
+ */
59
+ export declare function describeUnsafeNavigationTarget(url: string): Promise<Error | null>;
60
+ /**
61
+ * Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network, Runtime,
62
+ * and Fetch domains. Fetch domain interception guards every Document-type request (the initial
63
+ * navigation and each subsequent redirect) with {@link describeUnsafeNavigationTarget} before
64
+ * Chrome is allowed to send it; a blocked request's reason is recorded and can be read back via
65
+ * the returned page's `takeBlockedNavigationError()`. The caller drives navigation and must call
66
+ * close() when finished.
50
67
  */
51
68
  export declare function openCdpPage(): Promise<CdpPage>;
52
69
  export interface ResponseMatcher {
@@ -89,4 +106,26 @@ export declare const SANDBOX_EGRESS_ERROR_MARKER = "sandboxed execution environm
89
106
  * against the environment directly and fall back to a manual fetch if it's still unreachable.
90
107
  */
91
108
  export declare function describeNavigationFailure(errorText: string, url: string): Error;
109
+ /**
110
+ * Navigates an already-open CDP page to `currentUrl` and captures the rendered HTML, honoring the
111
+ * openCdpPage Fetch-domain safety guard at every point a blocked navigation could otherwise be
112
+ * discarded in favor of a less specific (or entirely silent) failure:
113
+ *
114
+ * 1. Right after `Page.navigate` returns — a fast HTTP redirect chain can already be blocked and
115
+ * resolved by this point, surfacing as the generic `nav.errorText` (e.g. `net::ERR_BLOCKED_BY_CLIENT`)
116
+ * below if not checked first.
117
+ * 2. If `waitForLoad` throws — a client-side (JS-triggered) navigation into a blocked target after
118
+ * the initial navigation already settled can prevent the load/domContentLoaded events
119
+ * `waitForLoad` is waiting on, surfacing as a generic timeout instead of the specific reason.
120
+ * 3. After `waitForLoad` resolves — a client-side navigation blocked without otherwise disrupting
121
+ * the load signal would otherwise sail through silently, capturing whatever the page settled on.
122
+ *
123
+ * Exported (rather than folded into `fetchRenderedHtmlOnce`) so tests can drive it against a fake
124
+ * `CdpPage` without spawning a real browser.
125
+ */
126
+ export declare function captureNavigatedPage(page: CdpPage, currentUrl: string, options: {
127
+ timeout: number;
128
+ limit: number;
129
+ settleMs: number;
130
+ }): Promise<BrowserFetchResult>;
92
131
  export declare function fetchRenderedHtml(url: string, options?: BrowserFetchOptions): Promise<BrowserFetchResult>;
@@ -220,13 +220,41 @@ export function assertRenderedHttpOk(mainDoc) {
220
220
  }
221
221
  }
222
222
  /**
223
- * Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
224
- * and Runtime domains. The caller drives navigation and must call close() when finished.
223
+ * Validates a navigated (or redirected) Document request against the same scheme/credentials/DNS
224
+ * safety `normalizeUrl` and `fetchWithRedirects` enforce per hop in url.ts/fetcher.ts. Chrome
225
+ * follows redirects internally with no hook back into that check, so a page that starts on a safe
226
+ * public host could otherwise redirect a rendered-fallback capture into a private/internal address
227
+ * (SSRF) or a non-http(s) scheme (e.g. `file:`) that the static fetcher would have rejected outright.
228
+ */
229
+ export async function describeUnsafeNavigationTarget(url) {
230
+ let normalized;
231
+ try {
232
+ normalized = normalizeUrl(url);
233
+ }
234
+ catch (err) {
235
+ return err;
236
+ }
237
+ try {
238
+ await checkDnsSafety(new URL(normalized).hostname);
239
+ return null;
240
+ }
241
+ catch (err) {
242
+ return err;
243
+ }
244
+ }
245
+ /**
246
+ * Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network, Runtime,
247
+ * and Fetch domains. Fetch domain interception guards every Document-type request (the initial
248
+ * navigation and each subsequent redirect) with {@link describeUnsafeNavigationTarget} before
249
+ * Chrome is allowed to send it; a blocked request's reason is recorded and can be read back via
250
+ * the returned page's `takeBlockedNavigationError()`. The caller drives navigation and must call
251
+ * close() when finished.
225
252
  */
226
253
  export async function openCdpPage() {
227
254
  const chromePath = findChromePath();
228
255
  const { chromeProcess, wsUrl } = await spawnChrome(chromePath);
229
256
  const client = new CdpClient(wsUrl);
257
+ let blockedNavigationError = null;
230
258
  try {
231
259
  await client.connect();
232
260
  const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
@@ -235,6 +263,23 @@ export async function openCdpPage() {
235
263
  await client.send('Network.enable', {}, sessionId);
236
264
  await client.send('Runtime.enable', {}, sessionId);
237
265
  await client.send('Network.setUserAgentOverride', { userAgent: DEFAULT_USER_AGENT }, sessionId);
266
+ await client.send('Fetch.enable', { patterns: [{ urlPattern: '*', resourceType: 'Document', requestStage: 'Request' }] }, sessionId);
267
+ client.on(`${sessionId}:Fetch.requestPaused`, (params) => {
268
+ // CdpClient.on's handler type is synchronous (CDP event dispatch isn't awaited), so the
269
+ // actual async validate-then-continue/fail work runs in this detached IIFE instead.
270
+ void (async () => {
271
+ const { requestId, request } = params;
272
+ const blockReason = await describeUnsafeNavigationTarget(request.url);
273
+ if (blockReason) {
274
+ blockedNavigationError = blockReason;
275
+ await client
276
+ .send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, sessionId)
277
+ .catch(() => { });
278
+ return;
279
+ }
280
+ await client.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
281
+ })();
282
+ });
238
283
  const close = async () => {
239
284
  try {
240
285
  await client.send('Target.closeTarget', { targetId });
@@ -243,7 +288,12 @@ export async function openCdpPage() {
243
288
  client.close();
244
289
  chromeProcess.kill('SIGKILL');
245
290
  };
246
- return { client, sessionId, close };
291
+ const takeBlockedNavigationError = () => {
292
+ const err = blockedNavigationError;
293
+ blockedNavigationError = null;
294
+ return err;
295
+ };
296
+ return { client, sessionId, close, takeBlockedNavigationError };
247
297
  }
248
298
  catch (err) {
249
299
  client.close();
@@ -332,8 +382,8 @@ const PROXY_BLOCKED_CHROME_NET_ERRORS = [
332
382
  'ERR_PROXY_CONNECTION_FAILED',
333
383
  'ERR_SOCKS_CONNECTION_FAILED',
334
384
  ];
335
- // Shared substring fetch.ts's fetchFailureGuidance matches on to attach suggestions/ref to this
336
- // error, without duplicating the message text it needs to match against.
385
+ // Shared substring fetch-result.ts's fetchFailureGuidance matches on to attach suggestions/ref to
386
+ // this error, without duplicating the message text it needs to match against.
337
387
  export const SANDBOX_EGRESS_ERROR_MARKER = 'sandboxed execution environment';
338
388
  /**
339
389
  * Builds the error thrown for a failed Page.navigate. When a proxy is configured (proxy.ts's
@@ -378,6 +428,86 @@ function isTransientBrowserError(err) {
378
428
  return (TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code)) ||
379
429
  TRANSIENT_CHROME_STARTUP_MESSAGES.some((text) => message.includes(text)));
380
430
  }
431
+ /**
432
+ * Navigates an already-open CDP page to `currentUrl` and captures the rendered HTML, honoring the
433
+ * openCdpPage Fetch-domain safety guard at every point a blocked navigation could otherwise be
434
+ * discarded in favor of a less specific (or entirely silent) failure:
435
+ *
436
+ * 1. Right after `Page.navigate` returns — a fast HTTP redirect chain can already be blocked and
437
+ * resolved by this point, surfacing as the generic `nav.errorText` (e.g. `net::ERR_BLOCKED_BY_CLIENT`)
438
+ * below if not checked first.
439
+ * 2. If `waitForLoad` throws — a client-side (JS-triggered) navigation into a blocked target after
440
+ * the initial navigation already settled can prevent the load/domContentLoaded events
441
+ * `waitForLoad` is waiting on, surfacing as a generic timeout instead of the specific reason.
442
+ * 3. After `waitForLoad` resolves — a client-side navigation blocked without otherwise disrupting
443
+ * the load signal would otherwise sail through silently, capturing whatever the page settled on.
444
+ *
445
+ * Exported (rather than folded into `fetchRenderedHtmlOnce`) so tests can drive it against a fake
446
+ * `CdpPage` without spawning a real browser.
447
+ */
448
+ export async function captureNavigatedPage(page, currentUrl, options) {
449
+ const { timeout, limit, settleMs } = options;
450
+ await page.client.send('Network.setBlockedURLs', { urls: BLOCKED_ASSET_URLS }, page.sessionId);
451
+ // Record the status of each frame's first Document response so we can reject HTTP errors after
452
+ // the page settles. Keyed by frameId; the navigated top frame is matched via the navigate result.
453
+ const documentResponses = new Map();
454
+ page.client.on(`${page.sessionId}:Network.responseReceived`, (params) => {
455
+ const { frameId } = params;
456
+ const status = params.response?.status;
457
+ // Only record real numeric statuses: a Document event without a usable status would
458
+ // otherwise store NaN, which silently slips past the `>= 400` guard and the `?? 200`
459
+ // fallback alike, leaking a NaN into BrowserFetchResult.status (declared `number`).
460
+ if (params.type !== 'Document' || !frameId || typeof status !== 'number')
461
+ return;
462
+ if (documentResponses.has(frameId))
463
+ return;
464
+ documentResponses.set(frameId, {
465
+ status,
466
+ statusText: params.response?.statusText ?? '',
467
+ });
468
+ });
469
+ const nav = await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
470
+ // See point 1 above: prefer the safety guard's specific reason over the opaque errorText below.
471
+ const blockedNavigation = page.takeBlockedNavigationError();
472
+ if (blockedNavigation)
473
+ throw blockedNavigation;
474
+ // A network-level failure (DNS, refused/closed connection, TLS) never fires a load event, so
475
+ // fail fast here instead of waiting out the full navigation timeout on a dead page.
476
+ if (nav.errorText) {
477
+ throw describeNavigationFailure(nav.errorText, currentUrl);
478
+ }
479
+ try {
480
+ await waitForLoad(page.client, page.sessionId, timeout, settleMs);
481
+ }
482
+ catch (err) {
483
+ // See point 2 above: a blocked reason recorded during the wait outranks the generic timeout.
484
+ throw page.takeBlockedNavigationError() ?? err;
485
+ }
486
+ // See point 3 above.
487
+ const blockedNavigationAfterLoad = page.takeBlockedNavigationError();
488
+ if (blockedNavigationAfterLoad)
489
+ throw blockedNavigationAfterLoad;
490
+ const mainDoc = nav.frameId ? documentResponses.get(nav.frameId) : undefined;
491
+ assertRenderedHttpOk(mainDoc);
492
+ const evalResult = await page.client.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true }, page.sessionId);
493
+ const html = evalResult.result?.value;
494
+ if (typeof html !== 'string') {
495
+ throw new Error('Failed to retrieve rendered HTML from browser page.');
496
+ }
497
+ const byteLength = Buffer.byteLength(html);
498
+ if (byteLength > limit) {
499
+ throw new Error(`Response body size limit exceeded. Rendered HTML is ${byteLength} bytes.`);
500
+ }
501
+ return {
502
+ status: mainDoc?.status ?? 200,
503
+ contentType: 'text/html',
504
+ etag: null,
505
+ lastModified: null,
506
+ finalUrl: currentUrl,
507
+ responseSize: byteLength,
508
+ content: html,
509
+ };
510
+ }
381
511
  async function fetchRenderedHtmlOnce(url, options = {}) {
382
512
  const timeout = options.timeoutMs ?? 15_000;
383
513
  const limit = options.bodyLimitBytes ?? 2 * 1024 * 1024;
@@ -385,52 +515,11 @@ async function fetchRenderedHtmlOnce(url, options = {}) {
385
515
  await checkDnsSafety(new URL(currentUrl).hostname);
386
516
  const page = await openCdpPage();
387
517
  try {
388
- await page.client.send('Network.setBlockedURLs', { urls: BLOCKED_ASSET_URLS }, page.sessionId);
389
- // Record the status of each frame's first Document response so we can reject HTTP errors after
390
- // the page settles. Keyed by frameId; the navigated top frame is matched via the navigate result.
391
- const documentResponses = new Map();
392
- page.client.on(`${page.sessionId}:Network.responseReceived`, (params) => {
393
- const { frameId } = params;
394
- const status = params.response?.status;
395
- // Only record real numeric statuses: a Document event without a usable status would
396
- // otherwise store NaN, which silently slips past the `>= 400` guard and the `?? 200`
397
- // fallback alike, leaking a NaN into BrowserFetchResult.status (declared `number`).
398
- if (params.type !== 'Document' || !frameId || typeof status !== 'number')
399
- return;
400
- if (documentResponses.has(frameId))
401
- return;
402
- documentResponses.set(frameId, {
403
- status,
404
- statusText: params.response?.statusText ?? '',
405
- });
518
+ return await captureNavigatedPage(page, currentUrl, {
519
+ timeout,
520
+ limit,
521
+ settleMs: options.settleMs ?? 1000,
406
522
  });
407
- const nav = await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
408
- // A network-level failure (DNS, refused/closed connection, TLS) never fires a load event, so
409
- // fail fast here instead of waiting out the full navigation timeout on a dead page.
410
- if (nav.errorText) {
411
- throw describeNavigationFailure(nav.errorText, currentUrl);
412
- }
413
- await waitForLoad(page.client, page.sessionId, timeout, options.settleMs ?? 1000);
414
- const mainDoc = nav.frameId ? documentResponses.get(nav.frameId) : undefined;
415
- assertRenderedHttpOk(mainDoc);
416
- const evalResult = await page.client.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true }, page.sessionId);
417
- const html = evalResult.result?.value;
418
- if (typeof html !== 'string') {
419
- throw new Error('Failed to retrieve rendered HTML from browser page.');
420
- }
421
- const byteLength = Buffer.byteLength(html);
422
- if (byteLength > limit) {
423
- throw new Error(`Response body size limit exceeded. Rendered HTML is ${byteLength} bytes.`);
424
- }
425
- return {
426
- status: mainDoc?.status ?? 200,
427
- contentType: 'text/html',
428
- etag: null,
429
- lastModified: null,
430
- finalUrl: currentUrl,
431
- responseSize: byteLength,
432
- content: html,
433
- };
434
523
  }
435
524
  finally {
436
525
  await page.close();
@@ -1,16 +1,19 @@
1
- import type { FetchResult } from './fetcher.js';
1
+ import { type FetchResult } from './fetcher.js';
2
2
  import type { ExtractionResult } from './extract.js';
3
3
  import type { CaptureMethod } from './schema.js';
4
4
  import type { MachineReadableArtifact, SiteCapabilities } from './docs/capabilities.js';
5
5
  import { type TextFetcher } from './docs/machine-readable.js';
6
+ /** Injected fetcher dependencies for `capturePage`. All three are required. */
6
7
  export interface CaptureDeps {
7
8
  fetchStatic: (url: string) => Promise<FetchResult>;
8
9
  fetchRendered: (url: string) => Promise<FetchResult>;
9
10
  fetchText: TextFetcher;
10
11
  }
12
+ /** Options passed to `capturePage` by the caller. */
11
13
  export interface CaptureOptions {
12
14
  forceRendered?: boolean;
13
15
  }
16
+ /** Full outcome of a `capturePage` call, including provenance and capability metadata. */
14
17
  export interface CaptureOutcome {
15
18
  fetchResult: FetchResult;
16
19
  extraction: ExtractionResult;
@@ -20,10 +23,29 @@ export interface CaptureOutcome {
20
23
  machineReadable: MachineReadableArtifact[];
21
24
  attemptedMethods: string[];
22
25
  }
23
- export declare function renderedFallbackNeeded(caps: SiteCapabilities, extraction: ExtractionResult | null): boolean;
26
+ /**
27
+ * Returns true when the static extraction result is missing or too weak to serve as the artifact,
28
+ * meaning the caller should attempt a rendered fallback.
29
+ *
30
+ * @param caps - Detected site capabilities; `recommendedCapture === 'rendered'` forces a fallback.
31
+ * @param extraction - Result of static extraction, or null when static fetch failed.
32
+ * @param staticError - The static fetch's failure, if any. A content-type policy rejection (JSON,
33
+ * PDF, binary) means the URL isn't a web page at all — a browser would just render its own
34
+ * built-in viewer for it, producing a corrupted-looking "extracted" artifact — so that specific
35
+ * failure is a hard stop, never a fallback trigger. `--rendered` still bypasses this entirely (it
36
+ * skips the static phase), so an informed retry remains available.
37
+ * @returns True when a rendered (browser) fallback should be attempted.
38
+ */
39
+ export declare function renderedFallbackNeeded(caps: SiteCapabilities, extraction: ExtractionResult | null, staticError?: Error | null): boolean;
24
40
  /**
25
41
  * Captures a page's best-available content. Order: forced rendered → route/source Markdown → static
26
42
  * HTML → rendered fallback. Returns the chosen fetch result, extraction, detected capabilities, and
27
43
  * provenance (capture method, source URL, machine-readable artifacts, attempted methods).
44
+ *
45
+ * @param url - The page URL to capture.
46
+ * @param options - Capture options (e.g. `forceRendered`).
47
+ * @param deps - Injected fetchers for static, rendered, and text requests.
48
+ * @returns Full CaptureOutcome including the best extraction and capture provenance.
49
+ * @throws {Error} When all capture strategies fail and no usable content is produced.
28
50
  */
29
51
  export declare function capturePage(url: string, options: CaptureOptions, deps: CaptureDeps): Promise<CaptureOutcome>;
@@ -1,3 +1,4 @@
1
+ import { isUnsupportedContentTypeError } from './fetcher.js';
1
2
  import { extractHtmlContent } from './extract.js';
2
3
  import { detectDocsEngine } from './docs/detect.js';
3
4
  import { emptyCapabilities } from './docs/capabilities.js';
@@ -6,9 +7,22 @@ import { mapUrlToSource } from './docs/source-map.js';
6
7
  import { extractFromSource } from './docs/markdown-source.js';
7
8
  import { validateTextArtifact } from './docs/validate.js';
8
9
  const MIN_USEFUL_CHARS = 600;
9
- // A static extraction needs rendering help when it failed outright, the detector says the page is
10
- // an SPA/client-rendered, or the extracted article is too thin/low-confidence to be useful.
11
- export function renderedFallbackNeeded(caps, extraction) {
10
+ /**
11
+ * Returns true when the static extraction result is missing or too weak to serve as the artifact,
12
+ * meaning the caller should attempt a rendered fallback.
13
+ *
14
+ * @param caps - Detected site capabilities; `recommendedCapture === 'rendered'` forces a fallback.
15
+ * @param extraction - Result of static extraction, or null when static fetch failed.
16
+ * @param staticError - The static fetch's failure, if any. A content-type policy rejection (JSON,
17
+ * PDF, binary) means the URL isn't a web page at all — a browser would just render its own
18
+ * built-in viewer for it, producing a corrupted-looking "extracted" artifact — so that specific
19
+ * failure is a hard stop, never a fallback trigger. `--rendered` still bypasses this entirely (it
20
+ * skips the static phase), so an informed retry remains available.
21
+ * @returns True when a rendered (browser) fallback should be attempted.
22
+ */
23
+ export function renderedFallbackNeeded(caps, extraction, staticError = null) {
24
+ if (staticError && isUnsupportedContentTypeError(staticError))
25
+ return false;
12
26
  if (!extraction)
13
27
  return true;
14
28
  if (caps.recommendedCapture === 'rendered')
@@ -116,7 +130,7 @@ async function sourceOutcome(url, phase, deps, attemptedMethods) {
116
130
  // is warranted; throws only when rendering fails and nothing usable was produced (preferring the
117
131
  // earlier static error).
118
132
  async function renderedFallbackOutcome(url, deps, attemptedMethods, phase, extraction) {
119
- if (!renderedFallbackNeeded(phase.capabilities, extraction))
133
+ if (!renderedFallbackNeeded(phase.capabilities, extraction, phase.staticError))
120
134
  return null;
121
135
  try {
122
136
  return await renderedOutcome(url, deps, attemptedMethods, phase.machineReadable);
@@ -131,6 +145,12 @@ async function renderedFallbackOutcome(url, deps, attemptedMethods, phase, extra
131
145
  * Captures a page's best-available content. Order: forced rendered → route/source Markdown → static
132
146
  * HTML → rendered fallback. Returns the chosen fetch result, extraction, detected capabilities, and
133
147
  * provenance (capture method, source URL, machine-readable artifacts, attempted methods).
148
+ *
149
+ * @param url - The page URL to capture.
150
+ * @param options - Capture options (e.g. `forceRendered`).
151
+ * @param deps - Injected fetchers for static, rendered, and text requests.
152
+ * @returns Full CaptureOutcome including the best extraction and capture provenance.
153
+ * @throws {Error} When all capture strategies fail and no usable content is produced.
134
154
  */
135
155
  export async function capturePage(url, options, deps) {
136
156
  const attemptedMethods = [];
@@ -0,0 +1,33 @@
1
+ /** Options accepted by {@link CliIo.error} — the subset of oclif's `this.error` options Bonsai uses. */
2
+ export interface CliErrorOptions {
3
+ exit: number;
4
+ code?: string;
5
+ suggestions?: string[];
6
+ ref?: string;
7
+ }
8
+ /**
9
+ * Narrow command-context port passed from oclif commands into command services, so the services
10
+ * stay free of oclif imports and are testable without a framework harness.
11
+ */
12
+ export interface CliIo {
13
+ bin: string;
14
+ configDir: string | undefined;
15
+ dataDir: string;
16
+ cwd: string;
17
+ json: boolean;
18
+ warn(msg: string): void;
19
+ log(msg: string): void;
20
+ error(msg: string, opts: CliErrorOptions): never;
21
+ }
22
+ /**
23
+ * Single exit point for a URL that failed normalization, shared by BaseCommand and the command
24
+ * services. A scheme-less but domain-shaped input reports MISSING_URL_SCHEME — the same code the
25
+ * root `bonsai <url>` shorthand uses — so an agent sees one stable code for "forgot https://"
26
+ * everywhere; truly unparseable input stays INVALID_URL.
27
+ *
28
+ * @param error - `this.error` (oclif) or `io.error`; must not return.
29
+ * @param url - Raw URL string the user supplied.
30
+ * @param message - Human-readable failure message.
31
+ * @returns Never — always exits via `error`.
32
+ */
33
+ export declare function failInvalidUrl(error: (msg: string, opts: CliErrorOptions) => never, url: string, message: string): never;
@@ -0,0 +1,27 @@
1
+ import { EXIT_USAGE } from '../cli-error-policy.js';
2
+ import { looksLikeSchemelessUrl } from './url.js';
3
+ /**
4
+ * Single exit point for a URL that failed normalization, shared by BaseCommand and the command
5
+ * services. A scheme-less but domain-shaped input reports MISSING_URL_SCHEME — the same code the
6
+ * root `bonsai <url>` shorthand uses — so an agent sees one stable code for "forgot https://"
7
+ * everywhere; truly unparseable input stays INVALID_URL.
8
+ *
9
+ * @param error - `this.error` (oclif) or `io.error`; must not return.
10
+ * @param url - Raw URL string the user supplied.
11
+ * @param message - Human-readable failure message.
12
+ * @returns Never — always exits via `error`.
13
+ */
14
+ export function failInvalidUrl(error, url, message) {
15
+ if (looksLikeSchemelessUrl(url)) {
16
+ error(message, {
17
+ exit: EXIT_USAGE,
18
+ code: 'MISSING_URL_SCHEME',
19
+ suggestions: [`Use a full URL: https://${url}`],
20
+ });
21
+ }
22
+ error(`Invalid URL: ${message}`, {
23
+ exit: EXIT_USAGE,
24
+ code: 'INVALID_URL',
25
+ suggestions: ['Provide a valid http:// or https:// URL.'],
26
+ });
27
+ }
@@ -2,6 +2,9 @@ import { type SummaryLevel } from './summarize.js';
2
2
  /**
3
3
  * Compresses Markdown text by removing images, simplifying links from [text](url) to [text],
4
4
  * and collapsing multiple consecutive blank lines.
5
+ *
6
+ * @param markdown - Markdown text to compress.
7
+ * @returns Structurally compressed Markdown with images removed and links simplified.
5
8
  */
6
9
  export declare function compressMarkdown(markdown: string): string;
7
10
  /**
@@ -20,5 +23,9 @@ export declare function compressMarkdown(markdown: string): string;
20
23
  * detailed). This means the cache can never end up serving a `compressed` that is larger than
21
24
  * `detailed`, or one that is empty while `detailed` has content — if the auto-compaction can't be
22
25
  * trusted, the caller transparently gets the (verified) structural form or the detailed text itself.
26
+ *
27
+ * @param detailed - Full detailed Markdown for a page.
28
+ * @param level - Extractive summarization aggressiveness level passed to the summarizer.
29
+ * @returns The most compact trustworthy Markdown view: summarized, structural, or detailed as fallback.
23
30
  */
24
31
  export declare function buildCompressed(detailed: string, level: SummaryLevel): string;
@@ -4,17 +4,17 @@ const SUMMARY_FLOOR_TOKENS = 200;
4
4
  /**
5
5
  * Compresses Markdown text by removing images, simplifying links from [text](url) to [text],
6
6
  * and collapsing multiple consecutive blank lines.
7
+ *
8
+ * @param markdown - Markdown text to compress.
9
+ * @returns Structurally compressed Markdown with images removed and links simplified.
7
10
  */
8
11
  export function compressMarkdown(markdown) {
9
12
  if (!markdown) {
10
13
  return '';
11
14
  }
12
15
  let result = markdown;
13
- // Remove markdown images: ![alt](url) -> ""
14
16
  result = result.replace(/!\[.*?\]\(.*?\)/g, '');
15
- // Simplify links: [text](url) -> [text]
16
17
  result = result.replace(/\[(.*?)\]\(.*?\)/g, '[$1]');
17
- // Collapse multiple consecutive newlines (3 or more) to maximum 2 newlines
18
18
  result = result.replace(/\n{3,}/g, '\n\n');
19
19
  return result.trim();
20
20
  }
@@ -34,6 +34,10 @@ export function compressMarkdown(markdown) {
34
34
  * detailed). This means the cache can never end up serving a `compressed` that is larger than
35
35
  * `detailed`, or one that is empty while `detailed` has content — if the auto-compaction can't be
36
36
  * trusted, the caller transparently gets the (verified) structural form or the detailed text itself.
37
+ *
38
+ * @param detailed - Full detailed Markdown for a page.
39
+ * @param level - Extractive summarization aggressiveness level passed to the summarizer.
40
+ * @returns The most compact trustworthy Markdown view: summarized, structural, or detailed as fallback.
37
41
  */
38
42
  export function buildCompressed(detailed, level) {
39
43
  const structural = compressMarkdown(detailed);