@demigodmode/pi-web-agent 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +82 -79
- package/dist/backends/config.d.ts +27 -0
- package/dist/backends/config.js +97 -1
- package/dist/backends/factory.d.ts +2 -1
- package/dist/backends/factory.js +92 -23
- package/dist/commands/web-agent-config.d.ts +3 -0
- package/dist/commands/web-agent-config.js +57 -5
- package/dist/extension.d.ts +1 -0
- package/dist/extension.js +2 -0
- package/dist/fetch/headless-fetch.d.ts +8 -1
- package/dist/fetch/headless-fetch.js +3 -3
- package/dist/fetch/proxy-fetch.d.ts +22 -0
- package/dist/fetch/proxy-fetch.js +46 -0
- package/dist/jiti-compat-run.d.ts +1 -0
- package/dist/jiti-compat-run.js +9 -0
- package/dist/jiti-compat.d.ts +32 -0
- package/dist/jiti-compat.js +215 -0
- package/dist/presentation/config-store.js +4 -0
- package/dist/readers/youtube-reader.d.ts +3 -1
- package/dist/readers/youtube-reader.js +11 -3
- package/dist/search/duckduckgo.d.ts +10 -1
- package/dist/search/duckduckgo.js +23 -5
- package/dist/search/tavily.d.ts +2 -1
- package/dist/search/tavily.js +5 -3
- package/dist/tools/web-search.js +21 -9
- package/dist/types.d.ts +1 -1
- package/package.json +2 -1
- package/scripts/patch-jiti-compat.mjs +52 -8
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Works around two incompatibilities between pi's extension loader (a patched
|
|
6
|
+
* jiti) and jsdom's dependency tree:
|
|
7
|
+
*
|
|
8
|
+
* 1. jiti can't resolve the trailing-slash bare specifier require("punycode/")
|
|
9
|
+
* used by tr46.
|
|
10
|
+
* 2. jiti wraps `module.exports = new Set(...)` (cssstyle) in a Proxy, which
|
|
11
|
+
* breaks native Set methods on the exported value.
|
|
12
|
+
*
|
|
13
|
+
* Both files live in the shared `~/.pi/agent/npm/node_modules` tree, so
|
|
14
|
+
* installing or updating any *other* pi extension re-extracts them and reverts
|
|
15
|
+
* the patch. A postinstall hook alone therefore can't keep this healthy, which
|
|
16
|
+
* is why `ensureJitiCompat()` also runs on extension load, before jsdom is
|
|
17
|
+
* evaluated. See https://github.com/demigodmode/pi-web-agent/issues/34.
|
|
18
|
+
*
|
|
19
|
+
* scripts/patch-jiti-compat.mjs duplicates this logic for the postinstall
|
|
20
|
+
* hook. That hook runs before `npm run build`, so it cannot import dist/.
|
|
21
|
+
* Keep the two in sync.
|
|
22
|
+
*/
|
|
23
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
24
|
+
/**
|
|
25
|
+
* Write via a temp file + rename rather than in place. Two reasons, both of
|
|
26
|
+
* which bite harder now that this runs on every extension load instead of once
|
|
27
|
+
* per install:
|
|
28
|
+
*
|
|
29
|
+
* - `writeFileSync` truncates first. A crash, a full disk, or an OOM kill
|
|
30
|
+
* mid-write leaves a half-written file that still contains the marker
|
|
31
|
+
* comment, so every later run would skip it as "already patched" and the
|
|
32
|
+
* doctor would report a broken tree as healthy.
|
|
33
|
+
* - Another Pi session can be `require`-ing the same file while we write it.
|
|
34
|
+
* `rename` is atomic within a filesystem, so readers see the old file or
|
|
35
|
+
* the new one, never a partial one.
|
|
36
|
+
*
|
|
37
|
+
* The rename also breaks a pnpm-style hardlink instead of writing through it
|
|
38
|
+
* into the shared content-addressable store.
|
|
39
|
+
*/
|
|
40
|
+
function writeFileAtomic(path, contents) {
|
|
41
|
+
const temp = `${path}.pi-web-agent-${process.pid}.tmp`;
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(temp, contents);
|
|
44
|
+
renameSync(temp, path);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
try {
|
|
48
|
+
if (existsSync(temp))
|
|
49
|
+
unlinkSync(temp);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Best effort. Leaving a stray temp file is better than masking the
|
|
53
|
+
// original write failure.
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const defaultDeps = {
|
|
59
|
+
resolve: (specifier, fromFile) => (fromFile ? createRequire(fromFile) : requireFromHere).resolve(specifier),
|
|
60
|
+
existsSync,
|
|
61
|
+
readFileSync: (path) => readFileSync(path, 'utf8'),
|
|
62
|
+
writeFileSync: writeFileAtomic
|
|
63
|
+
};
|
|
64
|
+
const SET_SHIM_MARKER = 'pi/jiti workaround';
|
|
65
|
+
const SET_SHIM = `
|
|
66
|
+
// ${SET_SHIM_MARKER}: expose bound native Set methods as own properties so a
|
|
67
|
+
// Proxy wrapper around this export does not break Set brand checks.
|
|
68
|
+
for (const k of ["has", "add", "delete", "forEach", "keys", "values", "entries"]) {
|
|
69
|
+
module.exports[k] = Set.prototype[k].bind(module.exports);
|
|
70
|
+
}
|
|
71
|
+
module.exports[Symbol.iterator] = Set.prototype[Symbol.iterator].bind(module.exports);
|
|
72
|
+
`;
|
|
73
|
+
const PUNYCODE_SPECIFIER = 'require("punycode/")';
|
|
74
|
+
const PUNYCODE_REPLACEMENT = 'require("punycode/punycode.js")';
|
|
75
|
+
function findPackageRoot(deps, entryFile, packageName) {
|
|
76
|
+
let directory = dirname(entryFile);
|
|
77
|
+
for (;;) {
|
|
78
|
+
const manifestFile = join(directory, 'package.json');
|
|
79
|
+
if (deps.existsSync(manifestFile)) {
|
|
80
|
+
try {
|
|
81
|
+
const manifest = JSON.parse(deps.readFileSync(manifestFile));
|
|
82
|
+
if (manifest.name === packageName)
|
|
83
|
+
return directory;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// An unreadable or malformed package.json on the way up is not our
|
|
87
|
+
// problem to report. Keep walking.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const parent = dirname(directory);
|
|
91
|
+
if (parent === directory) {
|
|
92
|
+
throw new Error(`could not find the ${packageName} package root from ${entryFile}`);
|
|
93
|
+
}
|
|
94
|
+
directory = parent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the copies jsdom actually loads, not whichever copy happens to sit
|
|
99
|
+
* highest in the tree.
|
|
100
|
+
*
|
|
101
|
+
* `~/.pi/agent/npm/node_modules` is shared by every pi extension, so version
|
|
102
|
+
* conflicts routinely push a second copy of a package into a nested
|
|
103
|
+
* `node_modules`. Resolving `cssstyle` or `tr46` from *this* module can then
|
|
104
|
+
* find a hoisted copy that jsdom never requires: the patch lands on the wrong
|
|
105
|
+
* file, the load still fails, and the doctor reports a healthy tree because it
|
|
106
|
+
* checked the same wrong file. Walk the real import chain instead
|
|
107
|
+
* (jsdom -> cssstyle, jsdom -> whatwg-url -> tr46) and only fall back to a
|
|
108
|
+
* direct resolve when jsdom is not resolvable at all.
|
|
109
|
+
*/
|
|
110
|
+
function resolveFromJsdom(deps, specifier, via) {
|
|
111
|
+
try {
|
|
112
|
+
const jsdomEntry = deps.resolve('jsdom');
|
|
113
|
+
const importer = via ? deps.resolve(via, jsdomEntry) : jsdomEntry;
|
|
114
|
+
return deps.resolve(specifier, importer);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// jsdom (or the intermediate package) is not resolvable from here, e.g. a
|
|
118
|
+
// layout we do not recognize. Fall back to a plain resolve rather than
|
|
119
|
+
// giving up on the patch entirely.
|
|
120
|
+
return deps.resolve(specifier);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function tr46Entry(deps) {
|
|
124
|
+
return resolveFromJsdom(deps, 'tr46', 'whatwg-url');
|
|
125
|
+
}
|
|
126
|
+
function cssstyleTargets(deps) {
|
|
127
|
+
const packageRoot = findPackageRoot(deps, resolveFromJsdom(deps, 'cssstyle'), 'cssstyle');
|
|
128
|
+
return [
|
|
129
|
+
join(packageRoot, 'lib', 'allExtraProperties.js'),
|
|
130
|
+
join(packageRoot, 'lib', 'generated', 'allProperties.js'),
|
|
131
|
+
join(packageRoot, 'lib', 'generated', 'implementedProperties.js')
|
|
132
|
+
].map((file) => ({
|
|
133
|
+
file,
|
|
134
|
+
label: `cssstyle/${relative(packageRoot, file).split(sep).join('/')}`
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Apply both patches if they are missing. Safe to call repeatedly: an already
|
|
139
|
+
* patched tree is a few `readFileSync` calls and no writes. Never throws.
|
|
140
|
+
*/
|
|
141
|
+
export function ensureJitiCompat(deps = defaultDeps) {
|
|
142
|
+
const status = { pending: [], patched: [] };
|
|
143
|
+
try {
|
|
144
|
+
const file = tr46Entry(deps);
|
|
145
|
+
const contents = deps.readFileSync(file);
|
|
146
|
+
if (contents.includes(PUNYCODE_SPECIFIER)) {
|
|
147
|
+
deps.writeFileSync(file, contents.replaceAll(PUNYCODE_SPECIFIER, PUNYCODE_REPLACEMENT));
|
|
148
|
+
status.patched.push('tr46/index.js');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// A read-only install, a missing dependency, or a future dependency bump
|
|
153
|
+
// that changes the shape. Report it rather than blocking extension load.
|
|
154
|
+
status.pending.push('tr46/index.js');
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
for (const { file, label } of cssstyleTargets(deps)) {
|
|
158
|
+
// Guard the read too: one unreadable file (EACCES, odd permissions) must
|
|
159
|
+
// not abandon the other two, which may be perfectly writable.
|
|
160
|
+
try {
|
|
161
|
+
if (!deps.existsSync(file))
|
|
162
|
+
continue;
|
|
163
|
+
const contents = deps.readFileSync(file);
|
|
164
|
+
if (contents.includes(SET_SHIM_MARKER))
|
|
165
|
+
continue;
|
|
166
|
+
if (!contents.includes('module.exports = new Set('))
|
|
167
|
+
continue;
|
|
168
|
+
deps.writeFileSync(file, contents + SET_SHIM);
|
|
169
|
+
status.patched.push(label);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
status.pending.push(label);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
status.pending.push('cssstyle');
|
|
178
|
+
}
|
|
179
|
+
return status;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Read-only view of the same checks, for `/web-agent doctor`. Never throws and
|
|
183
|
+
* never writes.
|
|
184
|
+
*/
|
|
185
|
+
export function checkJitiCompat(deps = defaultDeps) {
|
|
186
|
+
const status = { pending: [], patched: [] };
|
|
187
|
+
try {
|
|
188
|
+
const contents = deps.readFileSync(tr46Entry(deps));
|
|
189
|
+
if (contents.includes(PUNYCODE_SPECIFIER))
|
|
190
|
+
status.pending.push('tr46/index.js');
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
status.pending.push('tr46/index.js');
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
for (const { file, label } of cssstyleTargets(deps)) {
|
|
197
|
+
try {
|
|
198
|
+
if (!deps.existsSync(file))
|
|
199
|
+
continue;
|
|
200
|
+
const contents = deps.readFileSync(file);
|
|
201
|
+
if (contents.includes(SET_SHIM_MARKER))
|
|
202
|
+
continue;
|
|
203
|
+
if (contents.includes('module.exports = new Set('))
|
|
204
|
+
status.pending.push(label);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
status.pending.push(label);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
status.pending.push('cssstyle');
|
|
213
|
+
}
|
|
214
|
+
return status;
|
|
215
|
+
}
|
|
@@ -61,6 +61,10 @@ function serializeBackendConfigOverride(config) {
|
|
|
61
61
|
if (config.headless && Object.keys(config.headless).length > 0) {
|
|
62
62
|
backends.headless = { ...config.headless };
|
|
63
63
|
}
|
|
64
|
+
if (config.proxy && Object.keys(config.proxy).length > 0) {
|
|
65
|
+
const { password: _password, ...proxy } = config.proxy;
|
|
66
|
+
backends.proxy = { ...proxy };
|
|
67
|
+
}
|
|
64
68
|
return { backends };
|
|
65
69
|
}
|
|
66
70
|
async function readConfigFileForWrite(filePath) {
|
|
@@ -5,6 +5,8 @@ type Subtitle = {
|
|
|
5
5
|
text: string;
|
|
6
6
|
};
|
|
7
7
|
type YoutubeReaderDeps = {
|
|
8
|
+
/** Fetch implementation used for YouTube's requests (defaults to global fetch). */
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
8
10
|
fetchSubtitles?: (input: {
|
|
9
11
|
videoID: string;
|
|
10
12
|
lang: string;
|
|
@@ -17,5 +19,5 @@ type YoutubeReaderDeps = {
|
|
|
17
19
|
description?: string;
|
|
18
20
|
}>;
|
|
19
21
|
};
|
|
20
|
-
export declare function createYoutubeReader({ fetchSubtitles, fetchDetails }?: YoutubeReaderDeps): SpecialContentReader;
|
|
22
|
+
export declare function createYoutubeReader({ fetchImpl, fetchSubtitles, fetchDetails }?: YoutubeReaderDeps): SpecialContentReader;
|
|
21
23
|
export {};
|
|
@@ -21,7 +21,15 @@ function extractVideoId(url) {
|
|
|
21
21
|
}
|
|
22
22
|
return undefined;
|
|
23
23
|
}
|
|
24
|
-
export function createYoutubeReader({
|
|
24
|
+
export function createYoutubeReader({ fetchImpl = fetch, fetchSubtitles, fetchDetails } = {}) {
|
|
25
|
+
// Route YouTube's caption/metadata requests through the configured fetch
|
|
26
|
+
// client (e.g. the proxy) unless an explicit override is provided.
|
|
27
|
+
const doFetchSubtitles = fetchSubtitles ?? ((input) => getSubtitles({ ...input, fetch: fetchImpl }));
|
|
28
|
+
const doFetchDetails = fetchDetails ??
|
|
29
|
+
((input) => getVideoDetails({ ...input, fetch: fetchImpl }).then((details) => ({
|
|
30
|
+
title: details.title,
|
|
31
|
+
description: details.description
|
|
32
|
+
})));
|
|
25
33
|
return {
|
|
26
34
|
name: 'youtube',
|
|
27
35
|
canHandle(url) {
|
|
@@ -39,8 +47,8 @@ export function createYoutubeReader({ fetchSubtitles = getSubtitles, fetchDetail
|
|
|
39
47
|
}
|
|
40
48
|
try {
|
|
41
49
|
const [subtitles, details] = await Promise.all([
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
doFetchSubtitles({ videoID, lang: 'en' }),
|
|
51
|
+
doFetchDetails({ videoID, lang: 'en' }).catch(() => ({ title: undefined }))
|
|
44
52
|
]);
|
|
45
53
|
if (!subtitles || subtitles.length === 0) {
|
|
46
54
|
return {
|
|
@@ -5,5 +5,14 @@ export type ParsedDuckDuckGoResults = {
|
|
|
5
5
|
hasResultContainers: boolean;
|
|
6
6
|
};
|
|
7
7
|
export declare function buildSearchUrl(query: string): string;
|
|
8
|
-
export declare
|
|
8
|
+
export declare const DUCKDUCKGO_HEADERS: {
|
|
9
|
+
readonly 'User-Agent': "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
|
|
10
|
+
readonly Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
|
|
11
|
+
readonly 'Accept-Language': "en-US,en;q=0.9";
|
|
12
|
+
};
|
|
13
|
+
export declare function fetchDuckDuckGoHtml(query: string, { fetchImpl, retries, sleep }?: {
|
|
14
|
+
fetchImpl?: typeof fetch;
|
|
15
|
+
retries?: number;
|
|
16
|
+
sleep?: (ms: number) => Promise<void>;
|
|
17
|
+
}): Promise<string>;
|
|
9
18
|
export declare function parseDuckDuckGoResults(html: string): ParsedDuckDuckGoResults;
|
|
@@ -21,12 +21,30 @@ export function buildSearchUrl(query) {
|
|
|
21
21
|
const params = new URLSearchParams({ q: query });
|
|
22
22
|
return `https://html.duckduckgo.com/html/?${params.toString()}`;
|
|
23
23
|
}
|
|
24
|
-
export
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
export const DUCKDUCKGO_HEADERS = {
|
|
25
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
|
|
26
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
27
|
+
'Accept-Language': 'en-US,en;q=0.9'
|
|
28
|
+
};
|
|
29
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
|
+
export async function fetchDuckDuckGoHtml(query, { fetchImpl = fetch, retries = 1, sleep = defaultSleep } = {}) {
|
|
31
|
+
let lastError;
|
|
32
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetchImpl(buildSearchUrl(query), { headers: { ...DUCKDUCKGO_HEADERS } });
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`DuckDuckGo request failed with ${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
return response.text();
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
lastError = error;
|
|
42
|
+
if (attempt < retries) {
|
|
43
|
+
await sleep(500);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
28
46
|
}
|
|
29
|
-
|
|
47
|
+
throw lastError instanceof Error ? lastError : new Error('DuckDuckGo request failed');
|
|
30
48
|
}
|
|
31
49
|
export function parseDuckDuckGoResults(html) {
|
|
32
50
|
const $ = cheerio.load(html);
|
package/dist/search/tavily.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { WebSearchResponse } from '../types.js';
|
|
2
|
-
export declare function createTavilySearchTool({ apiKey, fetchImpl }: {
|
|
2
|
+
export declare function createTavilySearchTool({ apiKey, keyless, fetchImpl }: {
|
|
3
3
|
apiKey?: string;
|
|
4
|
+
keyless?: boolean;
|
|
4
5
|
fetchImpl?: typeof fetch;
|
|
5
6
|
}): ({ query }: {
|
|
6
7
|
query: string;
|
package/dist/search/tavily.js
CHANGED
|
@@ -17,7 +17,7 @@ function normalizeResults(response) {
|
|
|
17
17
|
];
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
|
-
export function createTavilySearchTool({ apiKey, fetchImpl = fetch }) {
|
|
20
|
+
export function createTavilySearchTool({ apiKey, keyless = false, fetchImpl = fetch }) {
|
|
21
21
|
return async function tavilySearch({ query }) {
|
|
22
22
|
const normalizedQuery = query.trim();
|
|
23
23
|
if (!normalizedQuery) {
|
|
@@ -28,7 +28,7 @@ export function createTavilySearchTool({ apiKey, fetchImpl = fetch }) {
|
|
|
28
28
|
error: { code: 'INVALID_QUERY', message: 'Query must not be empty.' }
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
-
if (!apiKey?.trim()) {
|
|
31
|
+
if (!apiKey?.trim() && !keyless) {
|
|
32
32
|
return resultWithPresentation({
|
|
33
33
|
status: 'error',
|
|
34
34
|
results: [],
|
|
@@ -45,7 +45,9 @@ export function createTavilySearchTool({ apiKey, fetchImpl = fetch }) {
|
|
|
45
45
|
headers: {
|
|
46
46
|
Accept: 'application/json',
|
|
47
47
|
'Content-Type': 'application/json',
|
|
48
|
-
|
|
48
|
+
...(apiKey?.trim()
|
|
49
|
+
? { Authorization: `Bearer ${apiKey}` }
|
|
50
|
+
: { 'X-Tavily-Access-Mode': 'keyless' })
|
|
49
51
|
},
|
|
50
52
|
body: JSON.stringify({ query: normalizedQuery, max_results: 10 })
|
|
51
53
|
});
|
package/dist/tools/web-search.js
CHANGED
|
@@ -27,7 +27,11 @@ function htmlLooksBlocked(html) {
|
|
|
27
27
|
normalized.includes('challenge') ||
|
|
28
28
|
normalized.includes('verify you are human') ||
|
|
29
29
|
normalized.includes('are you a robot') ||
|
|
30
|
-
normalized.includes('unusual traffic')
|
|
30
|
+
normalized.includes('unusual traffic') ||
|
|
31
|
+
normalized.includes('automated requests') ||
|
|
32
|
+
normalized.includes('automated queries') ||
|
|
33
|
+
normalized.includes('detected unusual') ||
|
|
34
|
+
normalized.includes('too many requests'));
|
|
31
35
|
}
|
|
32
36
|
export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache = createTtlCache({ ttlMs: 30_000 }) } = {}) {
|
|
33
37
|
return async function webSearch({ query }) {
|
|
@@ -57,8 +61,14 @@ export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache =
|
|
|
57
61
|
};
|
|
58
62
|
}
|
|
59
63
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
let html = await searchHtml(normalizedQuery);
|
|
65
|
+
let parsed = parseDuckDuckGoResults(html);
|
|
66
|
+
// A 200-OK bot-wall reads as a successful fetch, so the fetch-layer retry never sees it.
|
|
67
|
+
// Give a page that looks blocked one more shot here before we classify it.
|
|
68
|
+
if (parsed.results.length === 0 && htmlLooksBlocked(html)) {
|
|
69
|
+
html = await searchHtml(normalizedQuery);
|
|
70
|
+
parsed = parseDuckDuckGoResults(html);
|
|
71
|
+
}
|
|
62
72
|
if (parsed.results.length > 0) {
|
|
63
73
|
const result = {
|
|
64
74
|
status: 'ok',
|
|
@@ -71,14 +81,16 @@ export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache =
|
|
|
71
81
|
presentation: buildSearchPresentation(result)
|
|
72
82
|
};
|
|
73
83
|
}
|
|
74
|
-
|
|
84
|
+
// Check for a bot-wall before "no results": a page can carry both markers, and BLOCKED is
|
|
85
|
+
// the honest call since it routes to the fallback instead of a dead end.
|
|
86
|
+
if (htmlLooksBlocked(html)) {
|
|
75
87
|
const result = {
|
|
76
88
|
status: 'error',
|
|
77
89
|
results: [],
|
|
78
90
|
metadata: { backend: 'duckduckgo', cacheHit: false },
|
|
79
91
|
error: {
|
|
80
|
-
code: '
|
|
81
|
-
message: 'DuckDuckGo
|
|
92
|
+
code: 'BLOCKED',
|
|
93
|
+
message: 'DuckDuckGo search appears to be blocked or rate limited.'
|
|
82
94
|
}
|
|
83
95
|
};
|
|
84
96
|
return {
|
|
@@ -86,14 +98,14 @@ export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache =
|
|
|
86
98
|
presentation: buildSearchPresentation(result)
|
|
87
99
|
};
|
|
88
100
|
}
|
|
89
|
-
if (
|
|
101
|
+
if (parsed.noResults) {
|
|
90
102
|
const result = {
|
|
91
103
|
status: 'error',
|
|
92
104
|
results: [],
|
|
93
105
|
metadata: { backend: 'duckduckgo', cacheHit: false },
|
|
94
106
|
error: {
|
|
95
|
-
code: '
|
|
96
|
-
message: 'DuckDuckGo
|
|
107
|
+
code: 'NO_RESULTS',
|
|
108
|
+
message: 'DuckDuckGo returned no usable results for this query.'
|
|
97
109
|
}
|
|
98
110
|
};
|
|
99
111
|
return {
|
package/dist/types.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type ToolError = {
|
|
|
20
20
|
export type SearchMetadata = {
|
|
21
21
|
backend: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
|
|
22
22
|
cacheHit: boolean;
|
|
23
|
-
fallbackFrom?: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
|
|
23
|
+
fallbackFrom?: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'duckduckgo';
|
|
24
24
|
fallbackReason?: string;
|
|
25
25
|
fanout?: FanoutMetadata;
|
|
26
26
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@demigodmode/pi-web-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Pi package for reliable web access with explicit search, fetch, and headless boundaries.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/extension.js",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"jsdom": "^26.0.0",
|
|
69
69
|
"playwright": "^1.60.0",
|
|
70
70
|
"typebox": "^1.1.37",
|
|
71
|
+
"undici": "^8.10.2",
|
|
71
72
|
"unpdf": "^1.8.1",
|
|
72
73
|
"youtube-caption-extractor": "^1.10.2"
|
|
73
74
|
},
|
|
@@ -10,19 +10,63 @@
|
|
|
10
10
|
// when this package is installed as a pi extension via `pi install npm:...`.
|
|
11
11
|
// Safe to run multiple times and safe to no-op if the target files or
|
|
12
12
|
// patterns are missing (e.g. a future dependency bump changes the shape).
|
|
13
|
-
|
|
13
|
+
//
|
|
14
|
+
// src/jiti-compat.ts does the same two patches at extension load time, because
|
|
15
|
+
// the shared ~/.pi/agent/npm tree means another extension's install can revert
|
|
16
|
+
// them long after this hook ran (#34). The logic is deliberately duplicated
|
|
17
|
+
// rather than shared: postinstall runs before `npm run build` in CI and in a
|
|
18
|
+
// fresh clone, so this file cannot depend on dist/. Keep the two in sync.
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync } from "node:fs";
|
|
14
20
|
import { createRequire } from "node:module";
|
|
15
21
|
import { dirname, join, relative, sep } from "node:path";
|
|
16
22
|
|
|
17
|
-
const
|
|
23
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
24
|
+
const resolveFromHere = requireFromHere.resolve;
|
|
25
|
+
|
|
26
|
+
// Resolve the copies jsdom actually loads. The shared ~/.pi/agent/npm tree
|
|
27
|
+
// often holds a second, nested copy of a package when extensions disagree on
|
|
28
|
+
// versions, and patching a hoisted copy jsdom never requires would fix
|
|
29
|
+
// nothing. Mirrors resolveFromJsdom in src/jiti-compat.ts.
|
|
30
|
+
function resolveFromJsdom(specifier, via) {
|
|
31
|
+
try {
|
|
32
|
+
const jsdomEntry = resolveFromHere("jsdom");
|
|
33
|
+
const importer = via ? createRequire(jsdomEntry).resolve(via) : jsdomEntry;
|
|
34
|
+
return createRequire(importer).resolve(specifier);
|
|
35
|
+
} catch {
|
|
36
|
+
return resolveFromHere(specifier);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Temp file + rename, not an in-place write. `writeFileSync` truncates first,
|
|
41
|
+
// so an interrupted write would leave a half-patched file that still contains
|
|
42
|
+
// the marker comment and would be skipped as "already patched" forever.
|
|
43
|
+
// Mirrors writeFileAtomic in src/jiti-compat.ts.
|
|
44
|
+
function writeFileAtomic(path, contents) {
|
|
45
|
+
const temp = `${path}.pi-web-agent-${process.pid}.tmp`;
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(temp, contents);
|
|
48
|
+
renameSync(temp, path);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
try {
|
|
51
|
+
if (existsSync(temp)) unlinkSync(temp);
|
|
52
|
+
} catch {
|
|
53
|
+
// Best effort.
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
18
58
|
|
|
19
59
|
function findPackageRoot(entryFile, packageName) {
|
|
20
60
|
let directory = dirname(entryFile);
|
|
21
61
|
while (true) {
|
|
22
62
|
const manifestFile = join(directory, "package.json");
|
|
23
63
|
if (existsSync(manifestFile)) {
|
|
24
|
-
|
|
25
|
-
|
|
64
|
+
try {
|
|
65
|
+
const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
66
|
+
if (manifest.name === packageName) return directory;
|
|
67
|
+
} catch {
|
|
68
|
+
// Malformed package.json on the way up. Keep walking.
|
|
69
|
+
}
|
|
26
70
|
}
|
|
27
71
|
|
|
28
72
|
const parent = dirname(directory);
|
|
@@ -34,7 +78,7 @@ function findPackageRoot(entryFile, packageName) {
|
|
|
34
78
|
}
|
|
35
79
|
|
|
36
80
|
function patchTr46() {
|
|
37
|
-
const file =
|
|
81
|
+
const file = resolveFromJsdom("tr46", "whatwg-url");
|
|
38
82
|
if (!existsSync(file)) {
|
|
39
83
|
console.debug("patch-jiti-compat: tr46/index.js not found, skipping");
|
|
40
84
|
return;
|
|
@@ -44,7 +88,7 @@ function patchTr46() {
|
|
|
44
88
|
console.debug("patch-jiti-compat: tr46/index.js does not match expected pattern, skipping");
|
|
45
89
|
return;
|
|
46
90
|
}
|
|
47
|
-
|
|
91
|
+
writeFileAtomic(
|
|
48
92
|
file,
|
|
49
93
|
contents.replaceAll('require("punycode/")', 'require("punycode/punycode.js")'),
|
|
50
94
|
);
|
|
@@ -61,7 +105,7 @@ module.exports[Symbol.iterator] = Set.prototype[Symbol.iterator].bind(module.exp
|
|
|
61
105
|
`;
|
|
62
106
|
|
|
63
107
|
function patchCssstyleSetExports() {
|
|
64
|
-
const packageRoot = findPackageRoot(
|
|
108
|
+
const packageRoot = findPackageRoot(resolveFromJsdom("cssstyle"), "cssstyle");
|
|
65
109
|
const files = [
|
|
66
110
|
join(packageRoot, "lib", "allExtraProperties.js"),
|
|
67
111
|
join(packageRoot, "lib", "generated", "allProperties.js"),
|
|
@@ -79,7 +123,7 @@ function patchCssstyleSetExports() {
|
|
|
79
123
|
console.debug(`patch-jiti-compat: ${label} does not match expected pattern, skipping`);
|
|
80
124
|
continue;
|
|
81
125
|
}
|
|
82
|
-
|
|
126
|
+
writeFileAtomic(file, contents + SET_SHIM);
|
|
83
127
|
console.log(`patch-jiti-compat: patched ${label}`);
|
|
84
128
|
}
|
|
85
129
|
}
|