@taurgis/bonsai 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -6
- package/agents/agents/salesforce-docs-researcher.agent.md +117 -0
- package/agents/hooks/README.md +62 -0
- package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +22 -0
- package/agents/hooks/claude-code/settings.json +16 -0
- package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +18 -0
- package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +22 -0
- package/agents/hooks/codex/hooks.json +28 -0
- package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +21 -0
- package/agents/hooks/cursor/hooks.json +12 -0
- package/agents/hooks/github-copilot/bonsai-web-fetch.json +23 -0
- package/agents/hooks/github-copilot/bonsai-web-fetch.sh +19 -0
- package/agents/instructions/salesforce-research.instructions.md +57 -0
- package/agents/instructions/web-research.instructions.md +2 -1
- package/bin/cli.mjs +13 -3
- package/dist/base-command.d.ts +20 -0
- package/dist/base-command.js +27 -1
- package/dist/commands/config/get.js +2 -2
- package/dist/commands/config/list.js +2 -2
- package/dist/commands/config/set.js +2 -2
- package/dist/commands/config/unset.js +2 -2
- package/dist/commands/fetch.d.ts +5 -0
- package/dist/commands/fetch.js +98 -27
- package/dist/commands/import.d.ts +1 -0
- package/dist/commands/import.js +51 -11
- package/dist/commands/list.js +18 -3
- package/dist/commands/prune.js +22 -6
- package/dist/commands/search.js +31 -30
- package/dist/commands/status.js +10 -9
- package/dist/hooks/command-not-found/suggest.d.ts +9 -0
- package/dist/hooks/command-not-found/suggest.js +93 -0
- package/dist/lib/config/index.d.ts +8 -3
- package/dist/lib/config/index.js +19 -4
- package/dist/lib/config/resolve.d.ts +21 -1
- package/dist/lib/config/resolve.js +42 -1
- package/dist/lib/config/schema.d.ts +3 -0
- package/dist/lib/config/schema.js +11 -1
- package/dist/lib/research/artifact.d.ts +13 -2
- package/dist/lib/research/artifact.js +27 -7
- package/dist/lib/research/browser.d.ts +12 -0
- package/dist/lib/research/browser.js +40 -3
- package/dist/lib/research/compress.d.ts +19 -0
- package/dist/lib/research/compress.js +31 -0
- package/dist/lib/research/docs/capabilities.d.ts +1 -1
- package/dist/lib/research/docs/code-blocks.js +24 -0
- package/dist/lib/research/docs/discovery-index.d.ts +14 -0
- package/dist/lib/research/docs/discovery-index.js +112 -0
- package/dist/lib/research/docs/machine-readable.d.ts +2 -2
- package/dist/lib/research/docs/machine-readable.js +3 -7
- package/dist/lib/research/docs/remote-search-runner.js +61 -1
- package/dist/lib/research/docs/section-artifacts.d.ts +3 -2
- package/dist/lib/research/docs/section-artifacts.js +5 -5
- package/dist/lib/research/docs/sections.d.ts +1 -0
- package/dist/lib/research/docs/sections.js +19 -1
- package/dist/lib/research/freshness.d.ts +9 -1
- package/dist/lib/research/freshness.js +24 -2
- package/dist/lib/research/keywords.d.ts +24 -0
- package/dist/lib/research/keywords.js +247 -0
- package/dist/lib/research/revalidate.d.ts +3 -1
- package/dist/lib/research/revalidate.js +8 -4
- package/dist/lib/research/search-index.d.ts +18 -0
- package/dist/lib/research/search-index.js +109 -0
- package/dist/lib/research/secret-scan.js +4 -2
- package/dist/lib/research/storage.d.ts +1 -0
- package/dist/lib/research/storage.js +1 -1
- package/dist/lib/research/summarize.d.ts +17 -0
- package/dist/lib/research/summarize.js +400 -0
- package/dist/lib/research/url.js +5 -2
- package/dist/lib/text.d.ts +33 -0
- package/dist/lib/text.js +63 -0
- package/oclif.manifest.json +8 -8
- package/package.json +17 -3
package/dist/commands/search.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
|
-
import {
|
|
3
|
+
import { loadSearchableArtifacts } from '../lib/research/search-index.js';
|
|
4
|
+
import { levenshtein, resultListHeading, truncationNotice, } from '../lib/text.js';
|
|
4
5
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
5
6
|
import { evaluateFreshness } from '../lib/research/freshness.js';
|
|
6
7
|
import { detectSite } from '../sites/index.js';
|
|
7
8
|
import { fetchStaticHtml, fetchText, postJson } from '../lib/research/fetcher.js';
|
|
8
9
|
import { runRemoteDocsSearch, } from '../lib/research/docs/remote-search-runner.js';
|
|
10
|
+
// Results are ranked, so the truncation word is "top"; --limit caps at 50.
|
|
11
|
+
const SEARCH_LABELS = {
|
|
12
|
+
noun: 'matching cached research',
|
|
13
|
+
order: 'top',
|
|
14
|
+
maxLimit: 50,
|
|
15
|
+
};
|
|
9
16
|
const REMOTE_SEARCH_DEPS = {
|
|
10
17
|
fetchStatic: async (url) => {
|
|
11
18
|
const res = await fetchStaticHtml(url);
|
|
@@ -54,7 +61,7 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
54
61
|
description: 'Search a documentation site directly (e.g. help.salesforce.com) instead of the local cache. Requires a site module that implements search.',
|
|
55
62
|
}),
|
|
56
63
|
remote: Flags.string({
|
|
57
|
-
description: 'Discover uncached docs via a site’s public search (Algolia DocSearch, MkDocs/Sphinx/Just-the-Docs
|
|
64
|
+
description: 'Discover uncached docs via a site’s public search or index (Algolia DocSearch, MkDocs/Sphinx/Just-the-Docs, llms.txt, sitemap.xml). Pass a docs page URL; falls back to local cache search if no connector applies.',
|
|
58
65
|
}),
|
|
59
66
|
};
|
|
60
67
|
static stdoutIsPrimaryData = true;
|
|
@@ -150,21 +157,22 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
150
157
|
return snippet;
|
|
151
158
|
}
|
|
152
159
|
scanCacheDirForResults(readRoots, queryTerms, currentTime) {
|
|
153
|
-
|
|
160
|
+
const results = [];
|
|
161
|
+
for (const { artifact, filePath } of loadSearchableArtifacts(readRoots)) {
|
|
154
162
|
if (artifact.metadata.status !== 'active')
|
|
155
|
-
|
|
163
|
+
continue;
|
|
156
164
|
const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
|
|
157
165
|
if (!this.matchesFilters(artifact.metadata, freshness, this.flags.topic, this.flags.tags, this.flags['artifact-type'], Boolean(this.flags['include-stale'])))
|
|
158
|
-
|
|
166
|
+
continue;
|
|
159
167
|
let score = this.calculateScore(artifact.metadata, artifact.summary, artifact.compressed, queryTerms, freshness);
|
|
160
168
|
if (score <= 0)
|
|
161
|
-
|
|
169
|
+
continue;
|
|
162
170
|
// Section children are more precise than the whole page, so rank a section hit slightly
|
|
163
171
|
// above its parent for the same query (T-22).
|
|
164
172
|
if (artifact.metadata.artifact_type === 'section')
|
|
165
173
|
score += 15;
|
|
166
174
|
const snippetText = [artifact.summary, artifact.compressed].filter(Boolean).join('\n');
|
|
167
|
-
|
|
175
|
+
results.push({
|
|
168
176
|
cacheKey: artifact.metadata.cache_key,
|
|
169
177
|
path: filePath,
|
|
170
178
|
artifactType: artifact.metadata.artifact_type,
|
|
@@ -177,13 +185,18 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
177
185
|
snippet: this.makeSnippet(snippetText, queryTerms),
|
|
178
186
|
siteModuleId: artifact.metadata.site_module_id,
|
|
179
187
|
score,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return results;
|
|
182
191
|
}
|
|
183
|
-
logSearchResults(finalResults) {
|
|
192
|
+
logSearchResults(finalResults, totalMatched) {
|
|
184
193
|
if (this.jsonEnabled())
|
|
185
194
|
return;
|
|
186
|
-
|
|
195
|
+
if (finalResults.length === 0) {
|
|
196
|
+
this.log('No matching cached research entries found.');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.log(`${resultListHeading(totalMatched, finalResults.length, SEARCH_LABELS)}\n`);
|
|
187
200
|
finalResults.forEach((res, index) => {
|
|
188
201
|
this.log(`${index + 1}. [${res.topic || 'No Topic'}] Score: ${res.score}`);
|
|
189
202
|
this.log(` Cache Key: ${res.cacheKey}`);
|
|
@@ -252,7 +265,13 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
252
265
|
const results = this.scanCacheDirForResults(roots.readRoots, queryTerms, currentTime);
|
|
253
266
|
results.sort((a, b) => b.score - a.score);
|
|
254
267
|
const finalResults = results.slice(0, this.flags.limit);
|
|
255
|
-
|
|
268
|
+
// Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
|
|
269
|
+
// always emits, even in --json) without touching the stdout envelope. Human mode already shows
|
|
270
|
+
// it in the heading, so only warn under --json.
|
|
271
|
+
const notice = truncationNotice(results.length, finalResults.length, SEARCH_LABELS);
|
|
272
|
+
if (notice && this.jsonEnabled())
|
|
273
|
+
this.warn(notice);
|
|
274
|
+
this.logSearchResults(finalResults, results.length);
|
|
256
275
|
return finalResults;
|
|
257
276
|
}
|
|
258
277
|
}
|
|
@@ -303,24 +322,6 @@ function scoreSingleTerm(term, topic, tags, sourceUrl, sourceUrls, summ, comp) {
|
|
|
303
322
|
}
|
|
304
323
|
return termScore;
|
|
305
324
|
}
|
|
306
|
-
function levenshtein(s1, s2) {
|
|
307
|
-
if (s1.length < s2.length)
|
|
308
|
-
return levenshtein(s2, s1);
|
|
309
|
-
if (s2.length === 0)
|
|
310
|
-
return s1.length;
|
|
311
|
-
let prevRow = Array.from({ length: s2.length + 1 }, (_, i) => i);
|
|
312
|
-
for (let i = 0; i < s1.length; i++) {
|
|
313
|
-
const currRow = [i + 1];
|
|
314
|
-
for (let j = 0; j < s2.length; j++) {
|
|
315
|
-
const deletions = prevRow[j + 1] + 1;
|
|
316
|
-
const insertions = currRow[j] + 1;
|
|
317
|
-
const substitutions = prevRow[j] + (s1[i] === s2[j] ? 0 : 1);
|
|
318
|
-
currRow.push(Math.min(deletions, insertions, substitutions));
|
|
319
|
-
}
|
|
320
|
-
prevRow = currRow;
|
|
321
|
-
}
|
|
322
|
-
return prevRow[s2.length];
|
|
323
|
-
}
|
|
324
325
|
function isFuzzyMatch(term, target) {
|
|
325
326
|
if (term.length < 4 || target.length < 4)
|
|
326
327
|
return false;
|
package/dist/commands/status.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
3
|
import { getArtifactPath } from '../lib/research/storage.js';
|
|
4
|
-
import { evaluateFreshness, checkMaxAgeExpired } from '../lib/research/freshness.js';
|
|
4
|
+
import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
|
|
5
5
|
import { resolveResearchTarget } from '../lib/research/resolve-target.js';
|
|
6
6
|
function resolveFreshness(cached, currentTime, ttl, maxAge) {
|
|
7
7
|
const isMaxAgeExpired = maxAge ? checkMaxAgeExpired(cached, currentTime, maxAge) : false;
|
|
@@ -11,7 +11,7 @@ function resolveFreshness(cached, currentTime, ttl, maxAge) {
|
|
|
11
11
|
}
|
|
12
12
|
function describeCacheStatus(cached, currentTime, ttl, maxAge) {
|
|
13
13
|
if (!cached) {
|
|
14
|
-
return { status: 'miss', freshness: '
|
|
14
|
+
return { status: 'miss', freshness: 'none', action: 'would_fetch' };
|
|
15
15
|
}
|
|
16
16
|
const freshness = resolveFreshness(cached, currentTime, ttl, maxAge);
|
|
17
17
|
return freshness === 'fresh'
|
|
@@ -46,6 +46,12 @@ export default class ResearchStatus extends BaseCommand {
|
|
|
46
46
|
async run() {
|
|
47
47
|
const { url } = this.args;
|
|
48
48
|
const { ttl, tier, 'max-age': maxAge } = this.flags;
|
|
49
|
+
// Validate the duration flags up front so a malformed value reports the exact flag that is
|
|
50
|
+
// wrong, rather than later surfacing as a misattributed parse failure.
|
|
51
|
+
for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
|
|
52
|
+
if (msg)
|
|
53
|
+
this.error(msg, { exit: 2 });
|
|
54
|
+
}
|
|
49
55
|
let target;
|
|
50
56
|
try {
|
|
51
57
|
target = resolveResearchTarget({
|
|
@@ -61,13 +67,8 @@ export default class ResearchStatus extends BaseCommand {
|
|
|
61
67
|
const { cacheKey, located, normalizedUrl, roots } = target;
|
|
62
68
|
const cached = located?.artifact ?? null;
|
|
63
69
|
const currentTime = new Date();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
result = describeCacheStatus(cached, currentTime, ttl, maxAge);
|
|
67
|
-
}
|
|
68
|
-
catch (err) {
|
|
69
|
-
this.error(`Invalid max-age: ${err.message}`, { exit: 2 });
|
|
70
|
-
}
|
|
70
|
+
// Both duration flags were validated above, so freshness resolution cannot throw on a parse error.
|
|
71
|
+
const result = describeCacheStatus(cached, currentTime, ttl, maxAge);
|
|
71
72
|
// On a hit, report where it actually lives; on a miss, where a fetch would write it.
|
|
72
73
|
const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
|
|
73
74
|
if (!this.jsonEnabled()) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Hook } from '@oclif/core';
|
|
2
|
+
/**
|
|
3
|
+
* Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
|
|
4
|
+
* throwing here (via `this.error`) replaces that default with a more helpful message. A correction
|
|
5
|
+
* is offered only when the nearest command is a close match, and it is never auto-run — the user
|
|
6
|
+
* re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
|
|
7
|
+
*/
|
|
8
|
+
declare const hook: Hook<'command_not_found'>;
|
|
9
|
+
export default hook;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { toConfiguredId } from '@oclif/core';
|
|
2
|
+
import { closestMatch } from '../../lib/text.js';
|
|
3
|
+
// Nearest command must be within this edit distance to be offered as a correction. Bonsai's command
|
|
4
|
+
// ids are short, so 3 catches realistic typos ("statuss", "improt") without suggesting an unrelated
|
|
5
|
+
// command for input that resembles nothing.
|
|
6
|
+
const MAX_SUGGESTION_DISTANCE = 3;
|
|
7
|
+
/**
|
|
8
|
+
* The nearest visible command to a typo, plus how many leading segments of the attempted id name
|
|
9
|
+
* the command. With `topicSeparator: ' '`, oclif folds every leading token of an unknown command
|
|
10
|
+
* into one colon-delimited id, so `bonsai serch hello` arrives as `serch:hello`. Matching that whole
|
|
11
|
+
* string never finds a short command, so walk prefixes longest→shortest and suggest off the closest:
|
|
12
|
+
* `serch:hello` resolves to nothing, but its prefix `serch` resolves to `search`. The matched prefix
|
|
13
|
+
* length doubles as the command-segment count, so a folded-in positional arg is dropped from display.
|
|
14
|
+
*/
|
|
15
|
+
function findSuggestion(segments, visibleIds) {
|
|
16
|
+
for (let n = segments.length; n >= 1; n--) {
|
|
17
|
+
const suggestion = closestMatch(segments.slice(0, n).join(':'), visibleIds, MAX_SUGGESTION_DISTANCE);
|
|
18
|
+
if (suggestion)
|
|
19
|
+
return { suggestion, commandSegments: n };
|
|
20
|
+
}
|
|
21
|
+
return { suggestion: null, commandSegments: 0 };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* How many leading segments name the command when no suggestion exists: the known-topic chain plus
|
|
25
|
+
* the first unknown segment. Keeps `config frobnicate` from rendering as the misleading bare `config`
|
|
26
|
+
* (a real topic) while still dropping a positional arg from `frobnicate banana`.
|
|
27
|
+
*/
|
|
28
|
+
function topicChainLength(segments, config) {
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < segments.length - 1 && config.findTopic(segments.slice(0, i + 1).join(':')))
|
|
31
|
+
i++;
|
|
32
|
+
return i + 1;
|
|
33
|
+
}
|
|
34
|
+
// Dot-separated, non-empty labels: a real domain (`docs.nestjs.com`) or IPv4 (`192.168.1.1`). The
|
|
35
|
+
// URL parser also accepts a leading-dot filename (`.env`) or a relative path (`./x`) as a "host",
|
|
36
|
+
// whose suggestion would be a nonsense `https://.env`; requiring well-formed labels rejects those.
|
|
37
|
+
// Anchored with no overlapping quantifiers, so it is ReDoS-safe on the (already length-bounded) host.
|
|
38
|
+
const DOMAIN_OR_IPV4 = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
39
|
+
/**
|
|
40
|
+
* The input as-typed when it looks like a URL missing its scheme (`example.com`,
|
|
41
|
+
* `docs.nestjs.com/guide`), else null. `bonsai <url>` is the headline command, but bin/cli.mjs only
|
|
42
|
+
* routes args carrying a `://` scheme to the fetch shorthand — so a scheme-less URL falls through to
|
|
43
|
+
* `command_not_found`. A domain-shaped host separates a forgotten-scheme URL from an ordinary command
|
|
44
|
+
* typo: `statuss` has no dot and never matches, while `config:frobnicate` fails to parse (the second
|
|
45
|
+
* segment becomes a non-numeric port), so neither is misread as a URL.
|
|
46
|
+
*/
|
|
47
|
+
function bareUrlInput(id) {
|
|
48
|
+
if (id.includes('://'))
|
|
49
|
+
return null;
|
|
50
|
+
let hostname;
|
|
51
|
+
try {
|
|
52
|
+
hostname = new URL(`https://${id}`).hostname;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return DOMAIN_OR_IPV4.test(hostname) ? id : null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
|
|
61
|
+
* throwing here (via `this.error`) replaces that default with a more helpful message. A correction
|
|
62
|
+
* is offered only when the nearest command is a close match, and it is never auto-run — the user
|
|
63
|
+
* re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
|
|
64
|
+
*/
|
|
65
|
+
const hook = async function (opts) {
|
|
66
|
+
const hiddenIds = new Set(opts.config.commands.filter((c) => c.hidden).map((c) => c.id));
|
|
67
|
+
const visibleIds = opts.config.commandIDs.filter((id) => !hiddenIds.has(id));
|
|
68
|
+
// A scheme-less URL is the most common "not a command" mistake for this CLI, so steer the user to
|
|
69
|
+
// the `bonsai <url>` shorthand with a scheme before falling back to nearest-command matching (which
|
|
70
|
+
// never finds a command for a hostname). The correction is shown, never auto-run (clig.dev).
|
|
71
|
+
const bareUrl = bareUrlInput(opts.id);
|
|
72
|
+
if (bareUrl) {
|
|
73
|
+
this.error([
|
|
74
|
+
`${bareUrl} is not a ${opts.config.bin} command.`,
|
|
75
|
+
`Run ${opts.config.bin} help for a list of available commands.`,
|
|
76
|
+
`Did you mean ${opts.config.bin} https://${bareUrl}? URLs need an http:// or https:// scheme.`,
|
|
77
|
+
].join('\n'), { exit: 2 });
|
|
78
|
+
}
|
|
79
|
+
const segments = opts.id.split(':');
|
|
80
|
+
const { suggestion, commandSegments } = findSuggestion(segments, visibleIds);
|
|
81
|
+
// Show only the segments that name the command, never the positional args oclif folded into the id
|
|
82
|
+
// (which would otherwise glue the arg on and turn `://` into ` //`).
|
|
83
|
+
const displaySegments = commandSegments || topicChainLength(segments, opts.config);
|
|
84
|
+
const attempted = toConfiguredId(segments.slice(0, displaySegments).join(':'), opts.config);
|
|
85
|
+
// Order so the most actionable line lands last, where the eye rests (clig.dev): the help pointer
|
|
86
|
+
// is the floor, and a concrete "Did you mean …?" correction — when we have one — goes below it.
|
|
87
|
+
const lines = [`${attempted} is not a ${opts.config.bin} command.`];
|
|
88
|
+
lines.push(`Run ${opts.config.bin} help for a list of available commands.`);
|
|
89
|
+
if (suggestion)
|
|
90
|
+
lines.push(`Did you mean ${toConfiguredId(suggestion, opts.config)}?`);
|
|
91
|
+
this.error(lines.join('\n'), { exit: 2 });
|
|
92
|
+
};
|
|
93
|
+
export default hook;
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
1
|
+
export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type SummaryLevel, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
2
2
|
export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
|
|
3
|
-
export { resolveStorageMode, parseEnvStorage, STORAGE_ENV_VAR, type ResolveStorageInput, } from './resolve.js';
|
|
4
|
-
import type { ConfigValues, ResolvedConfig, StorageMode } from './schema.js';
|
|
3
|
+
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, } from './resolve.js';
|
|
4
|
+
import type { ConfigValues, ResolvedConfig, StorageMode, SummaryLevel } from './schema.js';
|
|
5
5
|
/**
|
|
6
6
|
* Read both config files once and resolve the effective storage mode.
|
|
7
7
|
* `flag` (a per-invocation `--storage` override) wins over everything else.
|
|
8
8
|
*/
|
|
9
9
|
export declare function loadStorageMode(configDir: string | undefined, cwd: string, flag?: StorageMode, env?: Record<string, string | undefined>): StorageMode;
|
|
10
|
+
/**
|
|
11
|
+
* Read both config files once and resolve the effective summary level.
|
|
12
|
+
* `flag` (a per-invocation override) wins over everything else.
|
|
13
|
+
*/
|
|
14
|
+
export declare function loadSummaryLevel(configDir: string | undefined, cwd: string, flag?: SummaryLevel, env?: Record<string, string | undefined>): SummaryLevel;
|
|
10
15
|
/** The resolved value of every config key, factoring file + env layers. Extend as keys are added. */
|
|
11
16
|
export declare function effectiveConfig(configDir: string | undefined, cwd: string): ResolvedConfig;
|
|
12
17
|
export type ConfigScope = 'global' | 'local' | 'effective';
|
package/dist/lib/config/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { SCHEMA_VERSION, STORAGE_MODES, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
1
|
+
export { SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
2
2
|
export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
|
|
3
|
-
export { resolveStorageMode, parseEnvStorage, STORAGE_ENV_VAR, } from './resolve.js';
|
|
3
|
+
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, } from './resolve.js';
|
|
4
4
|
import { readUserConfig, readProjectConfig } from './io.js';
|
|
5
|
-
import { resolveStorageMode } from './resolve.js';
|
|
5
|
+
import { resolveStorageMode, resolveSummaryLevel } from './resolve.js';
|
|
6
6
|
/**
|
|
7
7
|
* Read both config files once and resolve the effective storage mode.
|
|
8
8
|
* `flag` (a per-invocation `--storage` override) wins over everything else.
|
|
@@ -15,9 +15,24 @@ export function loadStorageMode(configDir, cwd, flag, env = process.env) {
|
|
|
15
15
|
userConfig: readUserConfig(configDir),
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Read both config files once and resolve the effective summary level.
|
|
20
|
+
* `flag` (a per-invocation override) wins over everything else.
|
|
21
|
+
*/
|
|
22
|
+
export function loadSummaryLevel(configDir, cwd, flag, env = process.env) {
|
|
23
|
+
return resolveSummaryLevel({
|
|
24
|
+
flag,
|
|
25
|
+
env,
|
|
26
|
+
projectConfig: readProjectConfig(cwd),
|
|
27
|
+
userConfig: readUserConfig(configDir),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
18
30
|
/** The resolved value of every config key, factoring file + env layers. Extend as keys are added. */
|
|
19
31
|
export function effectiveConfig(configDir, cwd) {
|
|
20
|
-
return {
|
|
32
|
+
return {
|
|
33
|
+
storage: loadStorageMode(configDir, cwd),
|
|
34
|
+
summary: loadSummaryLevel(configDir, cwd),
|
|
35
|
+
};
|
|
21
36
|
}
|
|
22
37
|
/**
|
|
23
38
|
* The config values for a requested scope: the user file (`global`), the project file (`local`),
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import type { ConfigValues, StorageMode } from './schema.js';
|
|
1
|
+
import type { ConfigValues, StorageMode, SummaryLevel } from './schema.js';
|
|
2
2
|
/** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
|
|
3
3
|
export declare const STORAGE_ENV_VAR = "BONSAI_STORAGE";
|
|
4
|
+
export declare const SUMMARY_ENV_VAR = "BONSAI_SUMMARY";
|
|
5
|
+
/**
|
|
6
|
+
* Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
|
|
7
|
+
* dropped during resolution (falling back to config/default), which would otherwise silently mask
|
|
8
|
+
* a typo like `BONSAI_SUMMARY=agressive`. Callers surface these on stderr so the misconfiguration
|
|
9
|
+
* is visible without breaking the run. Returns one message per offending variable, or empty.
|
|
10
|
+
*/
|
|
11
|
+
export declare function invalidEnvOverrideWarnings(env: Record<string, string | undefined>): string[];
|
|
4
12
|
export declare function parseEnvStorage(env: Record<string, string | undefined>): StorageMode | undefined;
|
|
13
|
+
export declare function parseEnvSummary(env: Record<string, string | undefined>): SummaryLevel | undefined;
|
|
5
14
|
export interface ResolveStorageInput {
|
|
6
15
|
flag?: StorageMode;
|
|
7
16
|
env: Record<string, string | undefined>;
|
|
@@ -13,3 +22,14 @@ export interface ResolveStorageInput {
|
|
|
13
22
|
* flag > env > project file > user file > built-in default.
|
|
14
23
|
*/
|
|
15
24
|
export declare function resolveStorageMode(input: ResolveStorageInput): StorageMode;
|
|
25
|
+
export interface ResolveSummaryInput {
|
|
26
|
+
flag?: SummaryLevel;
|
|
27
|
+
env: Record<string, string | undefined>;
|
|
28
|
+
projectConfig: Partial<ConfigValues>;
|
|
29
|
+
userConfig: Partial<ConfigValues>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the effective summary level in precedence order:
|
|
33
|
+
* flag > env > project file > user file > built-in default.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveSummaryLevel(input: ResolveSummaryInput): SummaryLevel;
|
|
@@ -1,12 +1,42 @@
|
|
|
1
|
-
import { BUILT_IN_DEFAULTS, STORAGE_MODES } from './schema.js';
|
|
1
|
+
import { BUILT_IN_DEFAULTS, STORAGE_MODES, SUMMARY_LEVELS } from './schema.js';
|
|
2
2
|
/** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
|
|
3
3
|
export const STORAGE_ENV_VAR = 'BONSAI_STORAGE';
|
|
4
|
+
export const SUMMARY_ENV_VAR = 'BONSAI_SUMMARY';
|
|
5
|
+
/**
|
|
6
|
+
* Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
|
|
7
|
+
* dropped during resolution (falling back to config/default), which would otherwise silently mask
|
|
8
|
+
* a typo like `BONSAI_SUMMARY=agressive`. Callers surface these on stderr so the misconfiguration
|
|
9
|
+
* is visible without breaking the run. Returns one message per offending variable, or empty.
|
|
10
|
+
*/
|
|
11
|
+
export function invalidEnvOverrideWarnings(env) {
|
|
12
|
+
const checks = [
|
|
13
|
+
[STORAGE_ENV_VAR, STORAGE_MODES],
|
|
14
|
+
[SUMMARY_ENV_VAR, SUMMARY_LEVELS],
|
|
15
|
+
];
|
|
16
|
+
const warnings = [];
|
|
17
|
+
for (const [name, valid] of checks) {
|
|
18
|
+
// After trim, an empty/whitespace-only value is falsy and treated as "unset" (no warning),
|
|
19
|
+
// matching parseEnv*; matching is case-sensitive, so a wrong-case value is invalid and warns.
|
|
20
|
+
const raw = env[name]?.trim();
|
|
21
|
+
if (raw && !valid.includes(raw)) {
|
|
22
|
+
warnings.push(`Ignoring ${name}="${raw}": not one of ${valid.join(', ')}. ` +
|
|
23
|
+
`Using the configured value or default instead.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return warnings;
|
|
27
|
+
}
|
|
4
28
|
export function parseEnvStorage(env) {
|
|
5
29
|
const raw = env[STORAGE_ENV_VAR]?.trim();
|
|
6
30
|
return STORAGE_MODES.includes(raw ?? '')
|
|
7
31
|
? raw
|
|
8
32
|
: undefined;
|
|
9
33
|
}
|
|
34
|
+
export function parseEnvSummary(env) {
|
|
35
|
+
const raw = env[SUMMARY_ENV_VAR]?.trim();
|
|
36
|
+
return SUMMARY_LEVELS.includes(raw ?? '')
|
|
37
|
+
? raw
|
|
38
|
+
: undefined;
|
|
39
|
+
}
|
|
10
40
|
/**
|
|
11
41
|
* Resolve the effective storage mode in precedence order:
|
|
12
42
|
* flag > env > project file > user file > built-in default.
|
|
@@ -18,3 +48,14 @@ export function resolveStorageMode(input) {
|
|
|
18
48
|
input.userConfig.storage ??
|
|
19
49
|
BUILT_IN_DEFAULTS.storage);
|
|
20
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the effective summary level in precedence order:
|
|
53
|
+
* flag > env > project file > user file > built-in default.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveSummaryLevel(input) {
|
|
56
|
+
return (input.flag ??
|
|
57
|
+
parseEnvSummary(input.env) ??
|
|
58
|
+
input.projectConfig.summary ??
|
|
59
|
+
input.userConfig.summary ??
|
|
60
|
+
BUILT_IN_DEFAULTS.summary);
|
|
61
|
+
}
|
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
* where the research cache lives.
|
|
5
5
|
*/
|
|
6
6
|
export type StorageMode = 'global' | 'project';
|
|
7
|
+
export type SummaryLevel = 'conservative' | 'balanced' | 'aggressive';
|
|
7
8
|
export interface ConfigValues {
|
|
8
9
|
storage?: StorageMode;
|
|
10
|
+
summary?: SummaryLevel;
|
|
9
11
|
}
|
|
10
12
|
export type ResolvedConfig = Required<ConfigValues>;
|
|
11
13
|
export type ConfigKey = keyof ConfigValues;
|
|
12
14
|
export declare const SCHEMA_VERSION = 1;
|
|
13
15
|
export declare const STORAGE_MODES: readonly StorageMode[];
|
|
16
|
+
export declare const SUMMARY_LEVELS: readonly SummaryLevel[];
|
|
14
17
|
export declare const ALL_KEYS: readonly ConfigKey[];
|
|
15
18
|
export declare const BUILT_IN_DEFAULTS: ResolvedConfig;
|
|
16
19
|
export interface KeyMeta {
|
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export const SCHEMA_VERSION = 1;
|
|
7
7
|
export const STORAGE_MODES = ['global', 'project'];
|
|
8
|
-
export const
|
|
8
|
+
export const SUMMARY_LEVELS = ['conservative', 'balanced', 'aggressive'];
|
|
9
|
+
export const ALL_KEYS = ['storage', 'summary'];
|
|
9
10
|
export const BUILT_IN_DEFAULTS = {
|
|
10
11
|
// Default to global so existing installs keep using the OCLIF data dir unchanged.
|
|
11
12
|
storage: 'global',
|
|
13
|
+
// Default to the gentlest summarization so cached content keeps the most detail out of the box.
|
|
14
|
+
summary: 'conservative',
|
|
12
15
|
};
|
|
13
16
|
export const KEY_META = {
|
|
14
17
|
storage: {
|
|
@@ -18,6 +21,13 @@ export const KEY_META = {
|
|
|
18
21
|
isValid: (v) => STORAGE_MODES.includes(String(v)),
|
|
19
22
|
format: (v) => String(v),
|
|
20
23
|
},
|
|
24
|
+
summary: {
|
|
25
|
+
description: `How aggressively the compressed format is summarized (${SUMMARY_LEVELS.join('|')}).`,
|
|
26
|
+
values: SUMMARY_LEVELS,
|
|
27
|
+
parseValue: (raw) => raw.trim(),
|
|
28
|
+
isValid: (v) => SUMMARY_LEVELS.includes(String(v)),
|
|
29
|
+
format: (v) => String(v),
|
|
30
|
+
},
|
|
21
31
|
};
|
|
22
32
|
export function isKnownKey(key) {
|
|
23
33
|
return ALL_KEYS.includes(key);
|
|
@@ -12,9 +12,20 @@ export declare function serializeArtifact(artifact: ResearchArtifact): string;
|
|
|
12
12
|
*/
|
|
13
13
|
export declare function parseMetadata(lines: string[]): ResearchArtifactMetadata;
|
|
14
14
|
/**
|
|
15
|
-
* Extracts a named markdown section (e.g. ## Summary) from body text.
|
|
15
|
+
* Extracts a named markdown section (e.g. ## Summary) from body text. The section ends at the next
|
|
16
|
+
* known section header (`nextSection`), matched as the exact serialized separator `\n\n## <name>` —
|
|
17
|
+
* NOT at the next `## ` heading, because section bodies legitimately contain their own `##` headings
|
|
18
|
+
* (a compressed/detailed page often *starts* with one). Bounding on a generic heading would truncate
|
|
19
|
+
* such content to nothing. Omit `nextSection` for the final section to read to end-of-body.
|
|
16
20
|
*/
|
|
17
|
-
export declare function extractSection(body: string, sectionName: string): string;
|
|
21
|
+
export declare function extractSection(body: string, sectionName: string, nextSection?: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Parses an artifact for search/listing without materializing the (often large) Detailed and
|
|
24
|
+
* Provenance bodies. Truncates at the Detailed section boundary before parsing so megabyte-scale
|
|
25
|
+
* detailed content is never split or sliced. `detailed` and `provenance` come back empty — callers
|
|
26
|
+
* that need them must use {@link parseArtifact}. Shares all parsing rules with parseArtifact.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseArtifactShallow(content: string): ResearchArtifact;
|
|
18
29
|
/**
|
|
19
30
|
* Parses full Markdown content into a structured ResearchArtifact.
|
|
20
31
|
*/
|
|
@@ -181,18 +181,38 @@ export function parseMetadata(lines) {
|
|
|
181
181
|
return state.meta;
|
|
182
182
|
}
|
|
183
183
|
/**
|
|
184
|
-
* Extracts a named markdown section (e.g. ## Summary) from body text.
|
|
184
|
+
* Extracts a named markdown section (e.g. ## Summary) from body text. The section ends at the next
|
|
185
|
+
* known section header (`nextSection`), matched as the exact serialized separator `\n\n## <name>` —
|
|
186
|
+
* NOT at the next `## ` heading, because section bodies legitimately contain their own `##` headings
|
|
187
|
+
* (a compressed/detailed page often *starts* with one). Bounding on a generic heading would truncate
|
|
188
|
+
* such content to nothing. Omit `nextSection` for the final section to read to end-of-body.
|
|
185
189
|
*/
|
|
186
|
-
export function extractSection(body, sectionName) {
|
|
190
|
+
export function extractSection(body, sectionName, nextSection) {
|
|
187
191
|
const header = `## ${sectionName}`;
|
|
188
192
|
const idx = body.indexOf(header);
|
|
189
193
|
if (idx === -1)
|
|
190
194
|
return '';
|
|
191
195
|
const start = idx + header.length;
|
|
192
|
-
const
|
|
193
|
-
const rawText =
|
|
196
|
+
const endIdx = nextSection ? body.indexOf(`\n\n## ${nextSection}`, start) : -1;
|
|
197
|
+
const rawText = endIdx === -1 ? body.slice(start) : body.slice(start, endIdx);
|
|
194
198
|
return rawText.trim();
|
|
195
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Parses an artifact for search/listing without materializing the (often large) Detailed and
|
|
202
|
+
* Provenance bodies. Truncates at the Detailed section boundary before parsing so megabyte-scale
|
|
203
|
+
* detailed content is never split or sliced. `detailed` and `provenance` come back empty — callers
|
|
204
|
+
* that need them must use {@link parseArtifact}. Shares all parsing rules with parseArtifact.
|
|
205
|
+
*/
|
|
206
|
+
export function parseArtifactShallow(content) {
|
|
207
|
+
// Only look for the Detailed boundary in the body (after the closing frontmatter fence) and match
|
|
208
|
+
// the exact serialized separator `\n\n## Detailed`, so neither a frontmatter value nor an earlier
|
|
209
|
+
// body heading named "Detailed" can truncate the content prematurely.
|
|
210
|
+
const bodyStart = content.indexOf('\n---', 3);
|
|
211
|
+
const searchFrom = bodyStart === -1 ? 0 : bodyStart + 4;
|
|
212
|
+
const detailedIdx = content.indexOf('\n\n## Detailed', searchFrom);
|
|
213
|
+
const sliced = detailedIdx === -1 ? content : content.slice(0, detailedIdx);
|
|
214
|
+
return parseArtifact(sliced);
|
|
215
|
+
}
|
|
196
216
|
/**
|
|
197
217
|
* Parses full Markdown content into a structured ResearchArtifact.
|
|
198
218
|
*/
|
|
@@ -209,9 +229,9 @@ export function parseArtifact(content) {
|
|
|
209
229
|
const bodyLines = parts.slice(closingIdx + 1);
|
|
210
230
|
const bodyText = bodyLines.join('\n');
|
|
211
231
|
const metadata = parseMetadata(frontmatterLines);
|
|
212
|
-
const summary = extractSection(bodyText, 'Summary');
|
|
213
|
-
const compressed = extractSection(bodyText, 'Compressed');
|
|
214
|
-
const detailed = extractSection(bodyText, 'Detailed');
|
|
232
|
+
const summary = extractSection(bodyText, 'Summary', 'Compressed');
|
|
233
|
+
const compressed = extractSection(bodyText, 'Compressed', 'Detailed');
|
|
234
|
+
const detailed = extractSection(bodyText, 'Detailed', 'Provenance');
|
|
215
235
|
const provenance = extractSection(bodyText, 'Provenance');
|
|
216
236
|
return { metadata, summary, compressed, detailed, provenance };
|
|
217
237
|
}
|
|
@@ -39,6 +39,18 @@ export interface CdpPage {
|
|
|
39
39
|
sessionId: string;
|
|
40
40
|
close: () => Promise<void>;
|
|
41
41
|
}
|
|
42
|
+
/** Status line of the main-frame document response, captured from Network.responseReceived. */
|
|
43
|
+
export interface MainDocumentResponse {
|
|
44
|
+
status: number;
|
|
45
|
+
statusText: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Rejects a rendered page whose main document returned an HTTP error, mirroring the static
|
|
49
|
+
* fetcher's `assertOk`. Without this, `--rendered` on a 404/500 would capture the server's (or
|
|
50
|
+
* the browser's) error page as if it were real content and cache it. A missing response (no
|
|
51
|
+
* Document event seen — e.g. data: or about: targets) is left to the caller's content checks.
|
|
52
|
+
*/
|
|
53
|
+
export declare function assertRenderedHttpOk(mainDoc: MainDocumentResponse | undefined): void;
|
|
42
54
|
/**
|
|
43
55
|
* Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
|
|
44
56
|
* and Runtime domains. The caller drives navigation and must call close() when finished.
|
|
@@ -90,7 +90,8 @@ export function findChromePath() {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
catch { }
|
|
93
|
-
throw new Error('Chrome
|
|
93
|
+
throw new Error('No Chrome or Chromium browser found for browser-based extraction. Install Chrome/Chromium or ' +
|
|
94
|
+
'set CHROME_PATH to the browser executable. Static (non-rendered) fetching does not require a browser.');
|
|
94
95
|
}
|
|
95
96
|
async function spawnChrome(chromePath) {
|
|
96
97
|
const chromeProcess = spawn(chromePath, [
|
|
@@ -220,6 +221,17 @@ export const BLOCKED_ASSET_URLS = [
|
|
|
220
221
|
'*.ico',
|
|
221
222
|
];
|
|
222
223
|
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
224
|
+
/**
|
|
225
|
+
* Rejects a rendered page whose main document returned an HTTP error, mirroring the static
|
|
226
|
+
* fetcher's `assertOk`. Without this, `--rendered` on a 404/500 would capture the server's (or
|
|
227
|
+
* the browser's) error page as if it were real content and cache it. A missing response (no
|
|
228
|
+
* Document event seen — e.g. data: or about: targets) is left to the caller's content checks.
|
|
229
|
+
*/
|
|
230
|
+
export function assertRenderedHttpOk(mainDoc) {
|
|
231
|
+
if (mainDoc && mainDoc.status >= 400) {
|
|
232
|
+
throw new Error(`Fetch failed with status ${mainDoc.status} ${mainDoc.statusText}`.trimEnd());
|
|
233
|
+
}
|
|
234
|
+
}
|
|
223
235
|
/**
|
|
224
236
|
* Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
|
|
225
237
|
* and Runtime domains. The caller drives navigation and must call close() when finished.
|
|
@@ -332,8 +344,33 @@ export async function fetchRenderedHtml(url, options = {}) {
|
|
|
332
344
|
const page = await openCdpPage();
|
|
333
345
|
try {
|
|
334
346
|
await page.client.send('Network.setBlockedURLs', { urls: BLOCKED_ASSET_URLS }, page.sessionId);
|
|
335
|
-
|
|
347
|
+
// Record the status of each frame's first Document response so we can reject HTTP errors after
|
|
348
|
+
// the page settles. Keyed by frameId; the navigated top frame is matched via the navigate result.
|
|
349
|
+
const documentResponses = new Map();
|
|
350
|
+
page.client.on(`${page.sessionId}:Network.responseReceived`, (params) => {
|
|
351
|
+
const { frameId } = params;
|
|
352
|
+
const status = params.response?.status;
|
|
353
|
+
// Only record real numeric statuses: a Document event without a usable status would
|
|
354
|
+
// otherwise store NaN, which silently slips past the `>= 400` guard and the `?? 200`
|
|
355
|
+
// fallback alike, leaking a NaN into BrowserFetchResult.status (declared `number`).
|
|
356
|
+
if (params.type !== 'Document' || !frameId || typeof status !== 'number')
|
|
357
|
+
return;
|
|
358
|
+
if (documentResponses.has(frameId))
|
|
359
|
+
return;
|
|
360
|
+
documentResponses.set(frameId, {
|
|
361
|
+
status,
|
|
362
|
+
statusText: params.response?.statusText ?? '',
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
const nav = await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
|
|
366
|
+
// A network-level failure (DNS, refused/closed connection, TLS) never fires a load event, so
|
|
367
|
+
// fail fast here instead of waiting out the full navigation timeout on a dead page.
|
|
368
|
+
if (nav.errorText) {
|
|
369
|
+
throw new Error(`Navigation failed: ${nav.errorText}`);
|
|
370
|
+
}
|
|
336
371
|
await waitForLoad(page.client, page.sessionId, timeout, options.settleMs ?? 1000);
|
|
372
|
+
const mainDoc = nav.frameId ? documentResponses.get(nav.frameId) : undefined;
|
|
373
|
+
assertRenderedHttpOk(mainDoc);
|
|
337
374
|
const evalResult = await page.client.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true }, page.sessionId);
|
|
338
375
|
const html = evalResult.result?.value;
|
|
339
376
|
if (typeof html !== 'string') {
|
|
@@ -344,7 +381,7 @@ export async function fetchRenderedHtml(url, options = {}) {
|
|
|
344
381
|
throw new Error(`Response body size limit exceeded. Rendered HTML is ${byteLength} bytes.`);
|
|
345
382
|
}
|
|
346
383
|
return {
|
|
347
|
-
status: 200,
|
|
384
|
+
status: mainDoc?.status ?? 200,
|
|
348
385
|
contentType: 'text/html',
|
|
349
386
|
etag: null,
|
|
350
387
|
lastModified: null,
|