@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.
- package/README.md +7 -4
- package/agents/agents/salesforce-docs-researcher.agent.md +14 -1
- package/agents/instructions/salesforce-research.instructions.md +2 -1
- package/bin/cli.mjs +9 -17
- package/dist/base-command.d.ts +22 -12
- package/dist/base-command.js +86 -49
- package/dist/commands/config/base.js +11 -2
- package/dist/commands/config/get.js +8 -12
- package/dist/commands/config/index.js +2 -2
- package/dist/commands/config/list.js +10 -16
- package/dist/commands/config/set.d.ts +2 -1
- package/dist/commands/config/set.js +34 -27
- package/dist/commands/config/unset.d.ts +2 -1
- package/dist/commands/config/unset.js +25 -24
- package/dist/commands/fetch.d.ts +7 -15
- package/dist/commands/fetch.js +57 -401
- package/dist/commands/import.d.ts +4 -9
- package/dist/commands/import.js +56 -284
- package/dist/commands/inspect.d.ts +7 -1
- package/dist/commands/inspect.js +103 -52
- package/dist/commands/list.d.ts +13 -1
- package/dist/commands/list.js +79 -32
- package/dist/commands/prune.d.ts +4 -1
- package/dist/commands/prune.js +55 -75
- package/dist/commands/status.d.ts +3 -2
- package/dist/commands/status.js +52 -79
- package/dist/hooks/command-not-found/suggest.d.ts +1 -0
- package/dist/hooks/command-not-found/suggest.js +66 -39
- package/dist/lib/argv.d.ts +24 -3
- package/dist/lib/argv.js +124 -53
- package/dist/lib/batch.d.ts +41 -0
- package/dist/lib/batch.js +39 -0
- package/dist/lib/cache-view.d.ts +21 -0
- package/dist/lib/cache-view.js +30 -0
- package/dist/lib/cli-emit.d.ts +28 -0
- package/dist/lib/cli-emit.js +44 -0
- package/dist/lib/cli-error-policy.d.ts +57 -0
- package/dist/lib/cli-error-policy.js +74 -0
- package/dist/lib/cli-flag-manifest.d.ts +5 -0
- package/dist/lib/cli-flag-manifest.js +8 -0
- package/dist/lib/cli-presentation.d.ts +39 -0
- package/dist/lib/cli-presentation.js +50 -0
- package/dist/lib/cli-result-types.d.ts +88 -0
- package/dist/lib/cli-result-types.js +2 -0
- package/dist/lib/config/index.d.ts +21 -3
- package/dist/lib/config/index.js +58 -3
- package/dist/lib/config-persist.d.ts +28 -0
- package/dist/lib/config-persist.js +29 -0
- package/dist/lib/envelope.d.ts +41 -3
- package/dist/lib/envelope.js +147 -4
- package/dist/lib/help-preflight.d.ts +5 -8
- package/dist/lib/help-preflight.js +13 -32
- package/dist/lib/json-meta.d.ts +0 -2
- package/dist/lib/json-meta.js +21 -25
- package/dist/lib/limit-flag.d.ts +11 -1
- package/dist/lib/limit-flag.js +19 -6
- package/dist/lib/parse-error-ux.d.ts +5 -0
- package/dist/lib/parse-error-ux.js +73 -0
- package/dist/lib/prune-flags.d.ts +28 -0
- package/dist/lib/prune-flags.js +74 -0
- package/dist/lib/research/artifact-index.d.ts +11 -2
- package/dist/lib/research/artifact-index.js +7 -4
- package/dist/lib/research/browser.d.ts +41 -2
- package/dist/lib/research/browser.js +139 -50
- package/dist/lib/research/capture.d.ts +24 -2
- package/dist/lib/research/capture.js +24 -4
- package/dist/lib/research/cli-io.d.ts +33 -0
- package/dist/lib/research/cli-io.js +27 -0
- package/dist/lib/research/compress.d.ts +7 -0
- package/dist/lib/research/compress.js +7 -3
- package/dist/lib/research/docs/capabilities.d.ts +15 -0
- package/dist/lib/research/docs/capabilities.js +5 -0
- package/dist/lib/research/docs/clean-dom.d.ts +2 -0
- package/dist/lib/research/docs/clean-dom.js +2 -0
- package/dist/lib/research/docs/code-blocks.d.ts +2 -0
- package/dist/lib/research/docs/code-blocks.js +2 -0
- package/dist/lib/research/docs/detect.d.ts +4 -0
- package/dist/lib/research/docs/detect.js +4 -0
- package/dist/lib/research/docs/machine-readable.d.ts +18 -0
- package/dist/lib/research/docs/machine-readable.js +10 -0
- package/dist/lib/research/docs/markdown-source.d.ts +20 -0
- package/dist/lib/research/docs/markdown-source.js +19 -2
- package/dist/lib/research/docs/quality-gates.d.ts +5 -0
- package/dist/lib/research/docs/quality-gates.js +4 -0
- package/dist/lib/research/docs/rsc.d.ts +9 -2
- package/dist/lib/research/docs/rsc.js +5 -2
- package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
- package/dist/lib/research/docs/section-artifacts.js +12 -0
- package/dist/lib/research/docs/sections.d.ts +18 -0
- package/dist/lib/research/docs/sections.js +17 -1
- package/dist/lib/research/docs/source-map.d.ts +28 -0
- package/dist/lib/research/docs/source-map.js +27 -0
- package/dist/lib/research/docs/validate.d.ts +35 -2
- package/dist/lib/research/docs/validate.js +47 -2
- package/dist/lib/research/extract.d.ts +16 -0
- package/dist/lib/research/extract.js +12 -1
- package/dist/lib/research/fetch-command-service.d.ts +36 -0
- package/dist/lib/research/fetch-command-service.js +310 -0
- package/dist/lib/research/fetch-result.d.ts +114 -0
- package/dist/lib/research/fetch-result.js +212 -0
- package/dist/lib/research/fetcher.d.ts +46 -8
- package/dist/lib/research/fetcher.js +58 -30
- package/dist/lib/research/freshness.d.ts +54 -11
- package/dist/lib/research/freshness.js +91 -31
- package/dist/lib/research/import-command-service.d.ts +56 -0
- package/dist/lib/research/import-command-service.js +381 -0
- package/dist/lib/research/keywords.js +5 -1
- package/dist/lib/research/markdown.d.ts +12 -1
- package/dist/lib/research/markdown.js +13 -5
- package/dist/lib/research/persist-artifact.d.ts +23 -0
- package/dist/lib/research/persist-artifact.js +27 -0
- package/dist/lib/research/prompt-injection.d.ts +8 -0
- package/dist/lib/research/prompt-injection.js +42 -13
- package/dist/lib/research/proxy.d.ts +14 -8
- package/dist/lib/research/proxy.js +14 -10
- package/dist/lib/research/revalidate.d.ts +49 -20
- package/dist/lib/research/revalidate.js +80 -15
- package/dist/lib/research/schema.d.ts +7 -0
- package/dist/lib/research/schema.js +2 -0
- package/dist/lib/research/secret-scan.d.ts +6 -0
- package/dist/lib/research/secret-scan.js +8 -0
- package/dist/lib/research/secure-write.d.ts +4 -0
- package/dist/lib/research/secure-write.js +31 -2
- package/dist/lib/research/storage.d.ts +5 -1
- package/dist/lib/research/storage.js +8 -3
- package/dist/lib/research/summarize.d.ts +15 -0
- package/dist/lib/research/summarize.js +11 -0
- package/dist/lib/research/url.d.ts +9 -6
- package/dist/lib/research/url.js +33 -21
- package/dist/lib/text.d.ts +7 -2
- package/dist/lib/text.js +13 -2
- package/dist/lib/value-flags.d.ts +14 -0
- package/dist/lib/value-flags.js +28 -0
- package/dist/sites/coveo.d.ts +10 -0
- package/dist/sites/coveo.js +10 -0
- package/dist/sites/index.d.ts +16 -0
- package/dist/sites/index.js +16 -4
- package/dist/sites/markdown-twin.d.ts +31 -0
- package/dist/sites/markdown-twin.js +56 -0
- package/dist/sites/salesforce/fetch-page.d.ts +8 -2
- package/dist/sites/salesforce/fetch-page.js +14 -3
- package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
- package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
- package/dist/sites/salesforce/index.d.ts +1 -0
- package/dist/sites/salesforce/index.js +1 -0
- package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
- package/dist/sites/salesforce-developer/fetch-page.js +6 -1
- package/dist/sites/salesforce-developer/index.d.ts +1 -0
- package/dist/sites/salesforce-developer/index.js +1 -0
- package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
- package/dist/sites/salesforce-developer/route-markdown.js +29 -67
- package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
- package/dist/sites/salesforce-doc-fetch.js +52 -7
- package/dist/sites/salesforce-dom-probe.d.ts +20 -2
- package/dist/sites/salesforce-dom-probe.js +20 -2
- package/dist/sites/types.d.ts +14 -8
- package/dist/sites/types.js +3 -0
- package/oclif.manifest.json +104 -98
- package/package.json +19 -7
- package/agents/agents/official-docs-researcher.agent.md +0 -87
- package/agents/instructions/web-research.instructions.md +0 -44
- 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
|
-
*
|
|
49
|
-
*
|
|
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
|
-
*
|
|
224
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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:  -> ""
|
|
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);
|