@taurgis/bonsai 1.0.6 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -13
- package/agents/agents/official-docs-researcher.agent.md +15 -35
- package/agents/agents/salesforce-docs-researcher.agent.md +17 -44
- package/agents/hooks/README.md +2 -9
- package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +1 -1
- package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +1 -1
- package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +1 -1
- package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +1 -1
- package/agents/hooks/github-copilot/bonsai-web-fetch.sh +1 -1
- package/agents/instructions/salesforce-research.instructions.md +7 -8
- package/agents/instructions/web-research.instructions.md +3 -4
- package/agents/skills/web-research/SKILL.md +24 -37
- package/bin/cli.mjs +15 -2
- package/dist/base-command.d.ts +15 -2
- package/dist/base-command.js +32 -4
- package/dist/commands/fetch.d.ts +3 -0
- package/dist/commands/fetch.js +96 -30
- package/dist/commands/import.js +14 -5
- package/dist/commands/inspect.d.ts +4 -0
- package/dist/commands/inspect.js +48 -24
- package/dist/commands/list.d.ts +3 -1
- package/dist/commands/list.js +42 -25
- package/dist/commands/prune.d.ts +1 -0
- package/dist/commands/prune.js +35 -9
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +57 -25
- package/dist/commands.d.ts +0 -2
- package/dist/commands.js +0 -2
- package/dist/hooks/command-not-found/suggest.d.ts +7 -1
- package/dist/hooks/command-not-found/suggest.js +82 -57
- package/dist/lib/argv.js +51 -7
- package/dist/lib/color.d.ts +20 -0
- package/dist/lib/color.js +36 -0
- package/dist/lib/envelope.js +8 -2
- package/dist/lib/help-preflight.d.ts +15 -0
- package/dist/lib/help-preflight.js +81 -0
- package/dist/lib/limit-flag.d.ts +1 -0
- package/dist/lib/limit-flag.js +24 -0
- package/dist/lib/research/{search-index.d.ts → artifact-index.d.ts} +5 -5
- package/dist/lib/research/{search-index.js → artifact-index.js} +10 -10
- package/dist/lib/research/browser.d.ts +1 -8
- package/dist/lib/research/browser.js +32 -46
- package/dist/lib/research/docs/markdown-source.js +2 -1
- package/dist/lib/research/extract.js +2 -1
- package/dist/lib/research/fetcher.js +92 -48
- package/dist/lib/research/prompt-injection.d.ts +1 -0
- package/dist/lib/research/prompt-injection.js +149 -0
- package/dist/lib/research/proxy.d.ts +35 -0
- package/dist/lib/research/proxy.js +107 -0
- package/dist/lib/research/regression.d.ts +18 -0
- package/dist/lib/research/regression.js +47 -0
- package/dist/lib/research/storage.d.ts +0 -1
- package/dist/lib/research/storage.js +15 -13
- package/dist/lib/research/url.d.ts +22 -0
- package/dist/lib/research/url.js +63 -1
- package/dist/lib/text.d.ts +3 -1
- package/dist/lib/text.js +9 -1
- package/dist/sites/coveo.d.ts +7 -0
- package/dist/sites/coveo.js +33 -0
- package/dist/sites/index.js +1 -18
- package/dist/sites/salesforce/fetch-page.js +1 -1
- package/dist/sites/salesforce/index.js +3 -8
- package/dist/sites/salesforce-developer/fetch-page.js +3 -1
- package/dist/sites/salesforce-developer/index.js +1 -2
- package/dist/sites/salesforce-doc-fetch.d.ts +11 -0
- package/dist/sites/salesforce-doc-fetch.js +117 -76
- package/dist/sites/salesforce-dom-probe.d.ts +16 -0
- package/dist/sites/salesforce-dom-probe.js +114 -0
- package/dist/sites/types.d.ts +0 -6
- package/oclif.manifest.json +31 -106
- package/package.json +35 -26
- package/dist/commands/index.d.ts +0 -35
- package/dist/commands/index.js +0 -306
- package/dist/commands/research/config/base.d.ts +0 -23
- package/dist/commands/research/config/base.js +0 -46
- package/dist/commands/research/config/get.d.ts +0 -20
- package/dist/commands/research/config/get.js +0 -45
- package/dist/commands/research/config/list.d.ts +0 -17
- package/dist/commands/research/config/list.js +0 -42
- package/dist/commands/research/config/set.d.ts +0 -31
- package/dist/commands/research/config/set.js +0 -94
- package/dist/commands/research/config/unset.d.ts +0 -21
- package/dist/commands/research/config/unset.js +0 -59
- package/dist/commands/research/import.d.ts +0 -38
- package/dist/commands/research/import.js +0 -313
- package/dist/commands/research/inspect.d.ts +0 -13
- package/dist/commands/research/inspect.js +0 -86
- package/dist/commands/research/list.d.ts +0 -25
- package/dist/commands/research/list.js +0 -143
- package/dist/commands/research/prune.d.ts +0 -24
- package/dist/commands/research/prune.js +0 -141
- package/dist/commands/research/search.d.ts +0 -32
- package/dist/commands/research/search.js +0 -334
- package/dist/commands/research/status.d.ts +0 -17
- package/dist/commands/research/status.js +0 -108
- package/dist/commands/research.d.ts +0 -36
- package/dist/commands/research.js +0 -304
- package/dist/commands/search.d.ts +0 -35
- package/dist/commands/search.js +0 -391
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +0 -1
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +0 -2
- package/dist/lib/research/docs/discovery-index.d.ts +0 -14
- package/dist/lib/research/docs/discovery-index.js +0 -112
- package/dist/lib/research/docs/remote-search-runner.d.ts +0 -21
- package/dist/lib/research/docs/remote-search-runner.js +0 -114
- package/dist/lib/research/docs/remote-search.d.ts +0 -14
- package/dist/lib/research/docs/remote-search.js +0 -101
- package/dist/lib/research/docs/search-index.d.ts +0 -13
- package/dist/lib/research/docs/search-index.js +0 -176
- package/dist/sites/algolia.d.ts +0 -9
- package/dist/sites/algolia.js +0 -26
- package/dist/sites/auto.d.ts +0 -8
- package/dist/sites/auto.js +0 -30
- package/dist/sites/nextjs/index.d.ts +0 -2
- package/dist/sites/nextjs/index.js +0 -52
- package/dist/sites/react/index.d.ts +0 -2
- package/dist/sites/react/index.js +0 -17
- package/dist/sites/salesforce/browser-search.d.ts +0 -7
- package/dist/sites/salesforce/browser-search.js +0 -82
- package/dist/sites/salesforce/coveo.d.ts +0 -23
- package/dist/sites/salesforce/coveo.js +0 -161
- package/dist/sites/salesforce/token.d.ts +0 -26
- package/dist/sites/salesforce/token.js +0 -154
- package/dist/sites/tailwind/index.d.ts +0 -2
- package/dist/sites/tailwind/index.js +0 -17
- package/dist/sites/vue/index.d.ts +0 -2
- package/dist/sites/vue/index.js +0 -17
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Config } from '@oclif/core';
|
|
2
|
+
import { buildEnvelope } from './envelope.js';
|
|
3
|
+
import { buildCommandNotFoundDetails } from '../hooks/command-not-found/suggest.js';
|
|
4
|
+
function commandId(parts) {
|
|
5
|
+
return parts.join(':');
|
|
6
|
+
}
|
|
7
|
+
function positionalTokens(argv) {
|
|
8
|
+
const tokens = [];
|
|
9
|
+
for (const arg of argv) {
|
|
10
|
+
if (arg === '--')
|
|
11
|
+
break;
|
|
12
|
+
if (arg === '--help' || arg === '--json')
|
|
13
|
+
continue;
|
|
14
|
+
if (arg.startsWith('-'))
|
|
15
|
+
continue;
|
|
16
|
+
tokens.push(arg);
|
|
17
|
+
}
|
|
18
|
+
return tokens;
|
|
19
|
+
}
|
|
20
|
+
function commandPrefixLength(tokens, config) {
|
|
21
|
+
let longest = 0;
|
|
22
|
+
for (let n = 1; n <= tokens.length; n++) {
|
|
23
|
+
if (config.commandIDs.includes(commandId(tokens.slice(0, n))))
|
|
24
|
+
longest = n;
|
|
25
|
+
}
|
|
26
|
+
return longest;
|
|
27
|
+
}
|
|
28
|
+
function branchPrefixLength(tokens, config) {
|
|
29
|
+
let longest = 0;
|
|
30
|
+
for (let n = 1; n <= tokens.length; n++) {
|
|
31
|
+
const id = commandId(tokens.slice(0, n));
|
|
32
|
+
if (config.commandIDs.some((command) => command.startsWith(`${id}:`)))
|
|
33
|
+
longest = n;
|
|
34
|
+
}
|
|
35
|
+
return longest;
|
|
36
|
+
}
|
|
37
|
+
function hasValidHelpTarget(tokens, config) {
|
|
38
|
+
const commandLength = commandPrefixLength(tokens, config);
|
|
39
|
+
const branchLength = branchPrefixLength(tokens, config);
|
|
40
|
+
if (commandLength === tokens.length || branchLength === tokens.length)
|
|
41
|
+
return true;
|
|
42
|
+
if (branchLength > 0 && tokens.length > branchLength) {
|
|
43
|
+
return commandLength > branchLength;
|
|
44
|
+
}
|
|
45
|
+
return commandLength > 0;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* oclif resolves help before its command_not_found hook, so `bonsai config gett --help` otherwise
|
|
49
|
+
* falls back to a terse framework error. Catch that narrow case and reuse the normal typo guidance.
|
|
50
|
+
*/
|
|
51
|
+
export async function tryUnknownHelpOutput(argv, root) {
|
|
52
|
+
if (!argv.includes('--help'))
|
|
53
|
+
return null;
|
|
54
|
+
const tokens = positionalTokens(argv);
|
|
55
|
+
if (tokens.length === 0)
|
|
56
|
+
return null;
|
|
57
|
+
const config = await Config.load({ root });
|
|
58
|
+
if (hasValidHelpTarget(tokens, config))
|
|
59
|
+
return null;
|
|
60
|
+
const attemptedId = commandId(tokens);
|
|
61
|
+
const details = buildCommandNotFoundDetails(attemptedId, [...argv], config);
|
|
62
|
+
if (argv.includes('--json')) {
|
|
63
|
+
return {
|
|
64
|
+
kind: 'json',
|
|
65
|
+
exitCode: 2,
|
|
66
|
+
envelope: buildEnvelope({
|
|
67
|
+
command: details.command,
|
|
68
|
+
ok: false,
|
|
69
|
+
exitCode: 2,
|
|
70
|
+
stderr: `${details.message}\nCode: ${details.code}`,
|
|
71
|
+
data: null,
|
|
72
|
+
code: details.code,
|
|
73
|
+
}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
kind: 'human',
|
|
78
|
+
exitCode: 2,
|
|
79
|
+
message: `${details.message}\nCode: ${details.code}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function limitFlag(max: number, defaultValue: number, description: string): import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Errors, Flags } from '@oclif/core';
|
|
2
|
+
export function limitFlag(max, defaultValue, description) {
|
|
3
|
+
return Flags.custom({
|
|
4
|
+
parse: async (input) => {
|
|
5
|
+
if (!/^\d+$/.test(input)) {
|
|
6
|
+
throw new Errors.CLIError(`Limit must be an integer between 1 and ${max}.`, {
|
|
7
|
+
exit: 2,
|
|
8
|
+
code: 'INVALID_LIMIT',
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
const value = Number(input);
|
|
12
|
+
if (value < 1 || value > max) {
|
|
13
|
+
throw new Errors.CLIError(`Limit must be between 1 and ${max}.`, {
|
|
14
|
+
exit: 2,
|
|
15
|
+
code: 'INVALID_LIMIT',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
},
|
|
20
|
+
})({
|
|
21
|
+
default: defaultValue,
|
|
22
|
+
description,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import type { ResearchArtifact } from './schema.js';
|
|
2
|
-
export interface
|
|
2
|
+
export interface IndexedArtifact {
|
|
3
3
|
artifact: ResearchArtifact;
|
|
4
4
|
filePath: string;
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
|
-
* Returns the
|
|
7
|
+
* Returns the indexed view of every active artifact in one research dir, using the sidecar index
|
|
8
8
|
* to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
|
|
9
|
-
* index (best-effort; a write failure never breaks
|
|
9
|
+
* index (best-effort; a write failure never breaks callers). Corrupt/unreadable files are skipped —
|
|
10
10
|
* the real read paths in storage.ts handle archiving them.
|
|
11
11
|
*/
|
|
12
|
-
export declare function
|
|
12
|
+
export declare function loadIndexedArtifactsForDir(researchDir: string): IndexedArtifact[];
|
|
13
13
|
/**
|
|
14
14
|
* Multi-root variant: scans the `research/` dir of each data dir in order and deduplicates by cache
|
|
15
15
|
* key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
|
|
16
16
|
* {@link scanCacheDirs} but reads through the per-root sidecar index.
|
|
17
17
|
*/
|
|
18
|
-
export declare function
|
|
18
|
+
export declare function loadIndexedArtifacts(dataDirs: string[]): IndexedArtifact[];
|
|
@@ -2,11 +2,11 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { atomicWriteFile } from '../atomic-write.js';
|
|
4
4
|
import { parseArtifactShallow } from './artifact.js';
|
|
5
|
-
import { isResearchFile } from './
|
|
5
|
+
import { isResearchFile } from './url.js';
|
|
6
6
|
// Sidecar index that caches the lightweight (metadata + summary + compressed) view of each artifact
|
|
7
|
-
// so repeat
|
|
8
|
-
// or missing index never changes results, because each entry is validated against the
|
|
9
|
-
// signature and re-read on any mismatch.
|
|
7
|
+
// so repeat list/scan operations avoid re-reading and re-parsing every file. It is a derived cache
|
|
8
|
+
// only: a stale or missing index never changes results, because each entry is validated against the
|
|
9
|
+
// file's current signature and re-read on any mismatch.
|
|
10
10
|
const INDEX_FILE = '.search-index.json';
|
|
11
11
|
const INDEX_VERSION = 1;
|
|
12
12
|
// The repo writes artifacts via temp-file + rename, so every rewrite yields a new inode. Combining
|
|
@@ -48,12 +48,12 @@ function getOrUpdateIndexEntry(filePath, cached, onChanged) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
|
-
* Returns the
|
|
51
|
+
* Returns the indexed view of every active artifact in one research dir, using the sidecar index
|
|
52
52
|
* to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
|
|
53
|
-
* index (best-effort; a write failure never breaks
|
|
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
55
|
*/
|
|
56
|
-
export function
|
|
56
|
+
export function loadIndexedArtifactsForDir(researchDir) {
|
|
57
57
|
if (!existsSync(researchDir))
|
|
58
58
|
return [];
|
|
59
59
|
const indexPath = join(researchDir, INDEX_FILE);
|
|
@@ -83,7 +83,7 @@ export function loadSearchableArtifactsForDir(researchDir) {
|
|
|
83
83
|
atomicWriteFile(indexPath, JSON.stringify({ version: INDEX_VERSION, entries }));
|
|
84
84
|
}
|
|
85
85
|
catch {
|
|
86
|
-
// Best-effort cache write;
|
|
86
|
+
// Best-effort cache write; results are already computed without it.
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
return results;
|
|
@@ -93,11 +93,11 @@ export function loadSearchableArtifactsForDir(researchDir) {
|
|
|
93
93
|
* key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
|
|
94
94
|
* {@link scanCacheDirs} but reads through the per-root sidecar index.
|
|
95
95
|
*/
|
|
96
|
-
export function
|
|
96
|
+
export function loadIndexedArtifacts(dataDirs) {
|
|
97
97
|
const seen = new Set();
|
|
98
98
|
const all = [];
|
|
99
99
|
for (const dataDir of dataDirs) {
|
|
100
|
-
for (const located of
|
|
100
|
+
for (const located of loadIndexedArtifactsForDir(join(dataDir, 'research'))) {
|
|
101
101
|
const key = located.artifact.metadata.cache_key;
|
|
102
102
|
if (seen.has(key))
|
|
103
103
|
continue;
|
|
@@ -24,15 +24,8 @@ export declare class CdpClient {
|
|
|
24
24
|
close(): void;
|
|
25
25
|
}
|
|
26
26
|
export declare function findChromePath(): string;
|
|
27
|
+
export declare function buildChromeArgs(): string[];
|
|
27
28
|
export declare function waitForLoad(client: CdpClient, sessionId: string, timeoutMs: number, settleMs?: number): Promise<void>;
|
|
28
|
-
/**
|
|
29
|
-
* Polls until a content-container element holds enough RENDERED text, so SPAs that fetch their
|
|
30
|
-
* body after load are captured rendered rather than as a "Loading…" shell. Pierces open shadow
|
|
31
|
-
* roots (the container is matched across shadow boundaries) and measures `innerText`, which
|
|
32
|
-
* reflects shadow-rendered text — `document.querySelector` + `textContent` miss both. Returns
|
|
33
|
-
* without throwing on timeout; the caller captures whatever rendered and judges its quality.
|
|
34
|
-
*/
|
|
35
|
-
export declare function waitForContentReady(page: CdpPage, selectors: string[], minChars: number, timeoutMs: number): Promise<void>;
|
|
36
29
|
export declare const BLOCKED_ASSET_URLS: string[];
|
|
37
30
|
export interface CdpPage {
|
|
38
31
|
client: CdpClient;
|
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
4
|
import { checkDnsSafety } from './fetcher.js';
|
|
5
|
+
import { getChromeProxyArgs } from './proxy.js';
|
|
5
6
|
import { normalizeUrl } from './url.js';
|
|
6
7
|
export class CdpClient {
|
|
7
8
|
ws;
|
|
@@ -93,8 +94,10 @@ export function findChromePath() {
|
|
|
93
94
|
throw new Error('No Chrome or Chromium browser found for browser-based extraction. Install Chrome/Chromium or ' +
|
|
94
95
|
'set CHROME_PATH to the browser executable. Static (non-rendered) fetching does not require a browser.');
|
|
95
96
|
}
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
// Split out from spawnChrome so the exact CLI args (including the proxy flags) are unit-testable
|
|
98
|
+
// without spawning a real browser process.
|
|
99
|
+
export function buildChromeArgs() {
|
|
100
|
+
return [
|
|
98
101
|
'--headless=new',
|
|
99
102
|
'--remote-debugging-port=0',
|
|
100
103
|
'--disable-gpu',
|
|
@@ -102,7 +105,13 @@ async function spawnChrome(chromePath) {
|
|
|
102
105
|
'--disable-setuid-sandbox',
|
|
103
106
|
'--disable-extensions',
|
|
104
107
|
'--disable-dev-shm-usage',
|
|
105
|
-
|
|
108
|
+
// CDP navigation uses Chrome's own network stack, not Node's fetch/undici, so a sandbox
|
|
109
|
+
// proxy detected via *_PROXY env vars must be passed to Chrome separately (see proxy.ts).
|
|
110
|
+
...getChromeProxyArgs(),
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
async function spawnChrome(chromePath) {
|
|
114
|
+
const chromeProcess = spawn(chromePath, buildChromeArgs());
|
|
106
115
|
try {
|
|
107
116
|
const wsUrl = await new Promise((resolve, reject) => {
|
|
108
117
|
let output = '';
|
|
@@ -164,48 +173,6 @@ export async function waitForLoad(client, sessionId, timeoutMs, settleMs = 1000)
|
|
|
164
173
|
}
|
|
165
174
|
throw new Error(`Navigation timed out after ${timeoutMs}ms`);
|
|
166
175
|
}
|
|
167
|
-
/**
|
|
168
|
-
* Polls until a content-container element holds enough RENDERED text, so SPAs that fetch their
|
|
169
|
-
* body after load are captured rendered rather than as a "Loading…" shell. Pierces open shadow
|
|
170
|
-
* roots (the container is matched across shadow boundaries) and measures `innerText`, which
|
|
171
|
-
* reflects shadow-rendered text — `document.querySelector` + `textContent` miss both. Returns
|
|
172
|
-
* without throwing on timeout; the caller captures whatever rendered and judges its quality.
|
|
173
|
-
*/
|
|
174
|
-
export async function waitForContentReady(page, selectors, minChars, timeoutMs) {
|
|
175
|
-
// Reports whether a content container has rendered (innerText pierces shadow), plus the total
|
|
176
|
-
// rendered text length so the caller can wait for the render to *settle* — heavy pages (e.g.
|
|
177
|
-
// the api-console) render the container early but keep filling it in.
|
|
178
|
-
const expression = `(() => {
|
|
179
|
-
const out = [];
|
|
180
|
-
const visit = (r) => { for (const c of Array.from(r.children || [])) { out.push(c); if (c.shadowRoot) visit(c.shadowRoot); visit(c); } };
|
|
181
|
-
visit(document);
|
|
182
|
-
const SELS = ${JSON.stringify(selectors)};
|
|
183
|
-
let has = false;
|
|
184
|
-
for (const el of out) {
|
|
185
|
-
if (!el.matches) continue;
|
|
186
|
-
if (!SELS.some((s) => { try { return el.matches(s); } catch (e) { return false; } })) continue;
|
|
187
|
-
if (((el.innerText) || '').trim().length >= ${minChars}) { has = true; break; }
|
|
188
|
-
}
|
|
189
|
-
return { has, len: document.body ? (document.body.innerText || '').length : 0 };
|
|
190
|
-
})()`;
|
|
191
|
-
const start = Date.now();
|
|
192
|
-
let previousLength = -1;
|
|
193
|
-
let stablePolls = 0;
|
|
194
|
-
while (Date.now() - start < timeoutMs) {
|
|
195
|
-
const value = (await page.client
|
|
196
|
-
.send('Runtime.evaluate', { expression, returnByValue: true }, page.sessionId)
|
|
197
|
-
.catch(() => null))?.result?.value;
|
|
198
|
-
if (value?.has) {
|
|
199
|
-
const grew = Math.abs((value.len || 0) - previousLength) > Math.max(40, previousLength * 0.02);
|
|
200
|
-
if (!grew && ++stablePolls >= 2)
|
|
201
|
-
return; // content present and rendering has settled
|
|
202
|
-
if (grew)
|
|
203
|
-
stablePolls = 0;
|
|
204
|
-
previousLength = value.len || 0;
|
|
205
|
-
}
|
|
206
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
176
|
export const BLOCKED_ASSET_URLS = [
|
|
210
177
|
'*.css',
|
|
211
178
|
'*.png',
|
|
@@ -336,7 +303,13 @@ export class ResponseCapture {
|
|
|
336
303
|
});
|
|
337
304
|
}
|
|
338
305
|
}
|
|
339
|
-
|
|
306
|
+
// Chrome can emit these on first navigation in headless CI when the cert verifier reloads mid-run.
|
|
307
|
+
const TRANSIENT_NAV_ERROR_CODES = ['ERR_CERT_VERIFIER_CHANGED'];
|
|
308
|
+
function isTransientBrowserNavigationError(err) {
|
|
309
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
310
|
+
return TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code));
|
|
311
|
+
}
|
|
312
|
+
async function fetchRenderedHtmlOnce(url, options = {}) {
|
|
340
313
|
const timeout = options.timeoutMs ?? 15_000;
|
|
341
314
|
const limit = options.bodyLimitBytes ?? 2 * 1024 * 1024;
|
|
342
315
|
const currentUrl = normalizeUrl(url);
|
|
@@ -394,3 +367,16 @@ export async function fetchRenderedHtml(url, options = {}) {
|
|
|
394
367
|
await page.close();
|
|
395
368
|
}
|
|
396
369
|
}
|
|
370
|
+
export async function fetchRenderedHtml(url, options = {}) {
|
|
371
|
+
const maxAttempts = 3;
|
|
372
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
373
|
+
try {
|
|
374
|
+
return await fetchRenderedHtmlOnce(url, options);
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
if (attempt === maxAttempts || !isTransientBrowserNavigationError(err))
|
|
378
|
+
throw err;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
throw new Error('Failed to fetch rendered HTML after transient browser navigation errors.');
|
|
382
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dropEmptyLinks, htmlToMarkdown } from '../markdown.js';
|
|
2
|
+
import { sanitizePromptInjection } from '../prompt-injection.js';
|
|
2
3
|
// Parses a leading YAML frontmatter block (--- ... ---). Only flat scalar keys are read; nested
|
|
3
4
|
// structures are ignored (we only need title/description). The block is stripped from the body.
|
|
4
5
|
export function parseFrontmatter(md) {
|
|
@@ -183,7 +184,7 @@ export function extractFromSource(md, sourceUrl) {
|
|
|
183
184
|
? cleanKumaScript(stripMdnHiddenSamples(body))
|
|
184
185
|
: body;
|
|
185
186
|
const tablesAsMarkdown = convertHtmlTables(sanitizeSourceMarkdown(deMacroed));
|
|
186
|
-
const cleaned = dropEmptySections(dropEmptyLinks(tablesAsMarkdown)).trim();
|
|
187
|
+
const cleaned = sanitizePromptInjection(dropEmptySections(dropEmptyLinks(tablesAsMarkdown))).trim();
|
|
187
188
|
const notes = [`captured from public Markdown/MDX source: ${sourceUrl}`];
|
|
188
189
|
return {
|
|
189
190
|
title: titleFrom(frontmatter, cleaned),
|
|
@@ -4,6 +4,7 @@ import { htmlToMarkdown } from './markdown.js';
|
|
|
4
4
|
import { normalizeCodeBlocks } from './docs/code-blocks.js';
|
|
5
5
|
import { cleanDocsChrome } from './docs/clean-dom.js';
|
|
6
6
|
import { analyzeMarkdownQuality } from './docs/quality-gates.js';
|
|
7
|
+
import { sanitizePromptInjection } from './prompt-injection.js';
|
|
7
8
|
function resolveRelativeLinks(document, finalUrl) {
|
|
8
9
|
const base = new URL(finalUrl);
|
|
9
10
|
const links = document.querySelectorAll('a');
|
|
@@ -69,7 +70,7 @@ export function extractHtmlContent(html, finalUrl) {
|
|
|
69
70
|
throw new Error('Content extraction failed: The target page could not be parsed by the readability engine. ' +
|
|
70
71
|
'Hint: The "import" command lets you import pre-cleaned, agent-supplied research notes directly.');
|
|
71
72
|
}
|
|
72
|
-
const detailedMarkdown = htmlToMarkdown(article.content);
|
|
73
|
+
const detailedMarkdown = sanitizePromptInjection(htmlToMarkdown(article.content));
|
|
73
74
|
const textLength = article.textContent ? article.textContent.trim().length : 0;
|
|
74
75
|
const { confidence, notes } = determineConfidence(textLength);
|
|
75
76
|
const quality = analyzeMarkdownQuality(detailedMarkdown, article.title || '');
|
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
import { lookup } from 'node:dns/promises';
|
|
2
2
|
import { isIP } from 'node:net';
|
|
3
|
+
import { fetch as undiciFetch } from 'undici';
|
|
3
4
|
import { isSafeIp, normalizeUrl } from './url.js';
|
|
5
|
+
import { getProxyDispatcher, isProxyConfigured, PROXY_TUNNEL_REJECTION_PATTERN } from './proxy.js';
|
|
6
|
+
// Runs one fetch attempt with its own fresh AbortController/timeout, so that when `doFetch` makes
|
|
7
|
+
// a second attempt (the proxy fallback below) it gets the full timeout budget rather than
|
|
8
|
+
// whatever was left over from the first attempt's signal. Loosely typed at this one boundary
|
|
9
|
+
// because it bridges two structurally-similar-but-nominally-distinct fetch implementations
|
|
10
|
+
// (Node's global fetch and the separate `undici` package's own fetch/RequestInit/Response).
|
|
11
|
+
async function fetchWithTimeout(fetchFn, url, init, timeoutMs) {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const id = setTimeout(() => controller.abort(), timeoutMs);
|
|
14
|
+
try {
|
|
15
|
+
return (await fetchFn(url, { ...init, signal: controller.signal }));
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
clearTimeout(id);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// True when `err` is a transport failure specifically caused by the proxy declining to tunnel to
|
|
22
|
+
// the target host (e.g. an allowlist-only sandbox proxy), as opposed to any other pre-response
|
|
23
|
+
// failure (DNS, refused connection, a genuinely unreachable destination). Only this class of
|
|
24
|
+
// failure is worth retrying without the proxy — retrying on any transport TypeError would also
|
|
25
|
+
// re-attempt destinations that are down regardless of the proxy, doubling their latency for no
|
|
26
|
+
// benefit.
|
|
27
|
+
function isProxyTunnelRejection(err) {
|
|
28
|
+
let current = err;
|
|
29
|
+
while (current instanceof Error) {
|
|
30
|
+
if (PROXY_TUNNEL_REJECTION_PATTERN.test(current.message))
|
|
31
|
+
return true;
|
|
32
|
+
current = current.cause;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
// Node's global fetch cannot be handed a Dispatcher from the separate `undici` package (their
|
|
37
|
+
// internal request-handler interfaces don't line up across versions), so routing through a
|
|
38
|
+
// sandbox-detected proxy requires switching to undici's own fetch for that one request. The
|
|
39
|
+
// unproxied path keeps using the global fetch, matching existing tests that stub `globalThis.fetch`.
|
|
40
|
+
//
|
|
41
|
+
// A configured proxy isn't a guarantee the proxy can actually reach a given host (many sandbox
|
|
42
|
+
// proxies only allowlist specific destinations), so a proxy-tunnel rejection falls back to a
|
|
43
|
+
// direct connection rather than treating "proxy is configured" as "proxy or nothing." The
|
|
44
|
+
// fallback gets its own fresh timeout (via fetchWithTimeout) rather than inheriting the proxied
|
|
45
|
+
// attempt's spent budget.
|
|
46
|
+
async function doFetch(url, init, timeoutMs) {
|
|
47
|
+
const dispatcher = getProxyDispatcher();
|
|
48
|
+
if (!dispatcher)
|
|
49
|
+
return fetchWithTimeout(fetch, url, init, timeoutMs);
|
|
50
|
+
try {
|
|
51
|
+
return await fetchWithTimeout(undiciFetch, url, { ...init, dispatcher }, timeoutMs);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (!isProxyTunnelRejection(err))
|
|
55
|
+
throw err;
|
|
56
|
+
return fetchWithTimeout(fetch, url, init, timeoutMs);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
4
59
|
export function looksLikeHtml(text) {
|
|
5
60
|
const trimmed = text.trimStart().toLowerCase();
|
|
6
61
|
return (trimmed.startsWith('<!doctype html') ||
|
|
@@ -19,17 +74,26 @@ export async function checkDnsSafety(hostname) {
|
|
|
19
74
|
}
|
|
20
75
|
return;
|
|
21
76
|
}
|
|
77
|
+
let addresses;
|
|
22
78
|
try {
|
|
23
|
-
|
|
24
|
-
for (const addr of addresses) {
|
|
25
|
-
if (!isSafeIp(addr.address)) {
|
|
26
|
-
throw new Error(`IP address "${addr.address}" resolved for "${hostname}" is a blocked local or private target.`);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
79
|
+
addresses = await lookup(hostToResolve, { all: true });
|
|
29
80
|
}
|
|
30
81
|
catch (err) {
|
|
82
|
+
// A sandbox that requires a proxy for HTTP egress may also block raw DNS queries (port 53)
|
|
83
|
+
// without blocking name resolution done through the proxy itself — so a failed *local*
|
|
84
|
+
// lookup isn't necessarily a dead end when a proxy is available to fall back on. Only skip
|
|
85
|
+
// the safety guarantee in that specific case; whenever local resolution succeeds (the common
|
|
86
|
+
// case, proxy or not), the resolved address is still validated below regardless of proxy
|
|
87
|
+
// configuration, so a hostname that resolves to a private/internal IP is still caught.
|
|
88
|
+
if (isProxyConfigured())
|
|
89
|
+
return;
|
|
31
90
|
throw new Error(`DNS resolution failed for hostname "${hostname}": ${err.message}`);
|
|
32
91
|
}
|
|
92
|
+
for (const addr of addresses) {
|
|
93
|
+
if (!isSafeIp(addr.address)) {
|
|
94
|
+
throw new Error(`IP address "${addr.address}" resolved for "${hostname}" is a blocked local or private target.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
33
97
|
}
|
|
34
98
|
async function readBodyWithLimit(body, limit) {
|
|
35
99
|
if (!body) {
|
|
@@ -123,32 +187,20 @@ async function fetchWithRedirects(url, options, process) {
|
|
|
123
187
|
while (true) {
|
|
124
188
|
const parsedUrl = new URL(currentUrl);
|
|
125
189
|
await checkDnsSafety(parsedUrl.hostname);
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
headers: initialHeaders,
|
|
132
|
-
redirect: 'manual',
|
|
133
|
-
signal: controller.signal,
|
|
134
|
-
});
|
|
135
|
-
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
|
136
|
-
redirectCount++;
|
|
137
|
-
if (redirectCount > maxRedirects) {
|
|
138
|
-
throw new Error(`Too many redirects. Exceeded limit of ${maxRedirects}.`);
|
|
139
|
-
}
|
|
140
|
-
const location = res.headers.get('location');
|
|
141
|
-
if (!location) {
|
|
142
|
-
throw new Error(`Redirect response status ${res.status} missing Location header.`);
|
|
143
|
-
}
|
|
144
|
-
currentUrl = normalizeUrl(new URL(location, currentUrl).toString());
|
|
145
|
-
continue;
|
|
190
|
+
const res = await doFetch(currentUrl, { method: 'GET', headers: initialHeaders, redirect: 'manual' }, timeout);
|
|
191
|
+
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
|
192
|
+
redirectCount++;
|
|
193
|
+
if (redirectCount > maxRedirects) {
|
|
194
|
+
throw new Error(`Too many redirects. Exceeded limit of ${maxRedirects}.`);
|
|
146
195
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
196
|
+
const location = res.headers.get('location');
|
|
197
|
+
if (!location) {
|
|
198
|
+
throw new Error(`Redirect response status ${res.status} missing Location header.`);
|
|
199
|
+
}
|
|
200
|
+
currentUrl = normalizeUrl(new URL(location, currentUrl).toString());
|
|
201
|
+
continue;
|
|
151
202
|
}
|
|
203
|
+
return await process(res, limit, currentUrl);
|
|
152
204
|
}
|
|
153
205
|
}
|
|
154
206
|
export async function fetchStaticHtml(url, options = {}) {
|
|
@@ -171,22 +223,14 @@ export async function postJson(url, body, headers = {}, options = {}) {
|
|
|
171
223
|
const limit = options.bodyLimitBytes ?? 2 * 1024 * 1024;
|
|
172
224
|
const target = normalizeUrl(url);
|
|
173
225
|
await checkDnsSafety(new URL(target).hostname);
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (!res.ok)
|
|
185
|
-
throw new Error(`Search request failed with status ${res.status} ${res.statusText}`);
|
|
186
|
-
const bytes = await readBodyWithLimit(res.body, limit);
|
|
187
|
-
return new TextDecoder().decode(bytes);
|
|
188
|
-
}
|
|
189
|
-
finally {
|
|
190
|
-
clearTimeout(id);
|
|
191
|
-
}
|
|
226
|
+
const res = await doFetch(target, {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
229
|
+
body: JSON.stringify(body),
|
|
230
|
+
redirect: 'error',
|
|
231
|
+
}, timeout);
|
|
232
|
+
if (!res.ok)
|
|
233
|
+
throw new Error(`Search request failed with status ${res.status} ${res.statusText}`);
|
|
234
|
+
const bytes = await readBodyWithLimit(res.body, limit);
|
|
235
|
+
return new TextDecoder().decode(bytes);
|
|
192
236
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function sanitizePromptInjection(markdown: string): string;
|