@zeldrisho/pi-web-fetch 0.16.1 → 0.16.3
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 +18 -1
- package/README.md +7 -7
- package/package.json +3 -6
- package/src/acquisition/fetch.ts +8 -5
- package/src/document/content.ts +1 -1
- package/src/document/focus.ts +195 -15
- package/src/index.ts +16 -2
- package/src/render.ts +1 -1
- package/src/service.ts +12 -11
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.16.3] - 2026-11-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Improve focus queries for identifiers, common words, word variants, HTML headings, and oversized content.
|
|
15
|
+
- Keep fragment-focused continuation offsets on the same view.
|
|
16
|
+
- Shorten focus, error, fallback, and end-of-results notices.
|
|
17
|
+
|
|
18
|
+
## [0.16.2] - 2026-10-30
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Remove the package's Node.js engine restriction; Node.js 24 remains the tested runtime.
|
|
23
|
+
- Clarify the model-facing tool description to reflect support for public URLs beyond webpages.
|
|
24
|
+
|
|
10
25
|
## [0.16.1] - 2026-10-28
|
|
11
26
|
|
|
12
27
|
### Changed
|
|
@@ -301,7 +316,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
301
316
|
|
|
302
317
|
- Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
|
|
303
318
|
|
|
304
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.16.
|
|
319
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.16.3...HEAD
|
|
320
|
+
[0.16.3]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.16.2...pi-web-fetch-v0.16.3
|
|
321
|
+
[0.16.2]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.16.1...pi-web-fetch-v0.16.2
|
|
305
322
|
[0.16.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.16.0...pi-web-fetch-v0.16.1
|
|
306
323
|
[0.16.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.15.0...pi-web-fetch-v0.16.0
|
|
307
324
|
[0.15.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.1...pi-web-fetch-v0.15.0
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zeldrisho/pi-web-fetch
|
|
2
2
|
|
|
3
|
-
Fetch public HTTP(S) pages and source files
|
|
3
|
+
Fetch bounded content from public HTTP(S) pages and source files with Pi. No API key is required.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -17,13 +17,13 @@ Use `pi config` to enable or disable the extension, and `pi list` to see configu
|
|
|
17
17
|
|
|
18
18
|
The `web_fetch` tool accepts public HTTP and HTTPS URLs and supports HTML, Markdown, plain text, JSON, and XML. HTML is converted to Markdown; images are omitted. Minified JavaScript, JSON, and YAML are returned as indented code blocks so long single-line payloads stay readable. URLs with credentials, local or private network targets, unsafe redirects, downloads over 5 MiB, and unsupported content types are rejected.
|
|
19
19
|
|
|
20
|
-
| Parameter | Description
|
|
21
|
-
| --------- |
|
|
22
|
-
| `url` | Required page URL. URL fragments can start output at a matching heading or anchor.
|
|
23
|
-
| `query` | Optional; selects matching sections from a long page. Sections remain in source order.
|
|
24
|
-
| `offset` | Optional character offset for continuing through extracted content; takes precedence over
|
|
20
|
+
| Parameter | Description |
|
|
21
|
+
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
22
|
+
| `url` | Required page URL. URL fragments can start output at a matching heading or anchor. |
|
|
23
|
+
| `query` | Optional; selects matching sections from a long page. Sections remain in source order. |
|
|
24
|
+
| `offset` | Optional character offset for continuing through extracted content. With a query and fragment, offsets apply to the fragment-focused view; otherwise, an offset takes precedence over the fragment. |
|
|
25
25
|
|
|
26
|
-
Output is bounded to 6,000 characters by default. For long pages, use the returned `nextOffset` to continue. Use `query` to focus on matching sections, or omit it to read the complete document. Pi's preview is collapsed by default; press `Ctrl+O` to expand it.
|
|
26
|
+
Output is bounded to 6,000 characters by default. For long pages, use the returned `nextOffset` to continue with the same URL and query. Use `query` to focus on matching sections, or omit it to read the complete document. Queries use approximate word matching and relevance selection; dotted or hyphenated identifiers and flags are matched literally. Focus can omit relevant content, so omit the query when completeness matters. Pi's preview is collapsed by default; press `Ctrl+O` to expand it.
|
|
27
27
|
|
|
28
28
|
For example, a URL such as `https://github.com/zeldrisho/pi-packages/blob/main/packages/pi-web-fetch/README.md#usage` starts at the matching heading. GitHub `blob` links and bare gists are converted to raw source URLs; repository roots use their README. GitHub directory listings are not supported.
|
|
29
29
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-web-fetch",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.3",
|
|
4
4
|
"description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -41,17 +41,14 @@
|
|
|
41
41
|
"@earendil-works/pi-tui": "^1.4.0",
|
|
42
42
|
"typebox": "^1.4.2",
|
|
43
43
|
"typescript": "^7.0.2",
|
|
44
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@1.
|
|
45
|
-
"vite-plus": "1.
|
|
44
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.4.0",
|
|
45
|
+
"vite-plus": "1.4.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@earendil-works/pi-coding-agent": "*",
|
|
49
49
|
"@earendil-works/pi-tui": "*",
|
|
50
50
|
"typebox": "*"
|
|
51
51
|
},
|
|
52
|
-
"engines": {
|
|
53
|
-
"node": ">=24"
|
|
54
|
-
},
|
|
55
52
|
"pi": {
|
|
56
53
|
"extensions": [
|
|
57
54
|
"./src/index.ts"
|
package/src/acquisition/fetch.ts
CHANGED
|
@@ -221,11 +221,14 @@ function assertSuccessfulResponse(response: IncomingMessage): void {
|
|
|
221
221
|
);
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
224
|
+
const hint =
|
|
225
|
+
status === 404
|
|
226
|
+
? " Page not found or inaccessible."
|
|
227
|
+
: status === 401 || status === 403
|
|
228
|
+
? " Access denied or authentication required."
|
|
229
|
+
: "";
|
|
230
|
+
|
|
231
|
+
throw new Error(`HTTP ${status}.${hint}`);
|
|
229
232
|
}
|
|
230
233
|
|
|
231
234
|
/**
|
package/src/document/content.ts
CHANGED
|
@@ -119,7 +119,7 @@ export function sliceCompleteDocument(
|
|
|
119
119
|
if (truncated) {
|
|
120
120
|
markdown += `\n\n[Content truncated. Continue with offset=${end} to read the next chunk.]`;
|
|
121
121
|
} else if (offset > 0) {
|
|
122
|
-
markdown += "\n\n[End of
|
|
122
|
+
markdown += "\n\n[End of results.]";
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
return {
|
package/src/document/focus.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const WORD_PATTERN = /[\p{L}\p{N}_+#]+/gu;
|
|
2
2
|
|
|
3
|
-
const HEADING_PATTERN =
|
|
3
|
+
const HEADING_PATTERN = /^(?:#{1,6}\s+\S| {0,3}<h[1-6](?:\s[^>]*|)>)/i;
|
|
4
4
|
|
|
5
5
|
/** Metadata describing deterministic query-focused extraction. */
|
|
6
6
|
export interface FocusDetails {
|
|
@@ -35,18 +35,95 @@ const WINDOW_TARGET_CHARACTERS = 8_000;
|
|
|
35
35
|
const WINDOW_MIN_LINES = 80;
|
|
36
36
|
|
|
37
37
|
/** Fewest characters a headingless block needs before it is split into windows. */
|
|
38
|
-
const WINDOW_MIN_CHARACTERS =
|
|
38
|
+
const WINDOW_MIN_CHARACTERS = 6_000;
|
|
39
|
+
|
|
40
|
+
const STOP_WORDS = new Set([
|
|
41
|
+
"a",
|
|
42
|
+
"an",
|
|
43
|
+
"and",
|
|
44
|
+
"are",
|
|
45
|
+
"for",
|
|
46
|
+
"how",
|
|
47
|
+
"in",
|
|
48
|
+
"is",
|
|
49
|
+
"of",
|
|
50
|
+
"on",
|
|
51
|
+
"the",
|
|
52
|
+
"to",
|
|
53
|
+
"with",
|
|
54
|
+
]);
|
|
39
55
|
|
|
40
56
|
function tokens(value: string): string[] {
|
|
41
|
-
return (value.toLowerCase().match(WORD_PATTERN) ?? []).
|
|
57
|
+
return (value.toLowerCase().match(WORD_PATTERN) ?? []).map((token) => {
|
|
58
|
+
let word = token;
|
|
59
|
+
|
|
60
|
+
if (word.length > 5 && /(?:ss|ch|sh|x|z)es$/.test(word)) word = word.slice(0, -2);
|
|
61
|
+
else if (word.length > 4 && word.endsWith("s") && !word.endsWith("ss"))
|
|
62
|
+
word = word.slice(0, -1);
|
|
63
|
+
|
|
64
|
+
if (word.length > 6 && word.endsWith("ion")) return word.slice(0, -3);
|
|
65
|
+
|
|
66
|
+
if (word.length > 4 && /(?:ing|ed)$/.test(word)) return word.replace(/(?:ing|ed)$/, "");
|
|
67
|
+
|
|
68
|
+
if (word.length > 4 && /(?:ch|sh|v)e$/.test(word)) return word.slice(0, -1);
|
|
69
|
+
|
|
70
|
+
return word;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function containsIdentifier(markdown: string, identifier: string): boolean {
|
|
75
|
+
const text = markdown.toLowerCase();
|
|
76
|
+
let index = text.indexOf(identifier);
|
|
77
|
+
|
|
78
|
+
while (index !== -1) {
|
|
79
|
+
const before = text[index - 1] ?? "";
|
|
80
|
+
const after = text[index + identifier.length] ?? "";
|
|
81
|
+
|
|
82
|
+
const beforeWord =
|
|
83
|
+
/[\p{L}\p{N}_+#-]/u.test(before) ||
|
|
84
|
+
(before === "." && /[\p{L}\p{N}]/u.test(text[index - 2] ?? ""));
|
|
85
|
+
|
|
86
|
+
const afterWord =
|
|
87
|
+
/[\p{L}\p{N}_+#-]/u.test(after) ||
|
|
88
|
+
(after === "." && /[\p{L}\p{N}]/u.test(text[index + identifier.length + 1] ?? ""));
|
|
89
|
+
|
|
90
|
+
if (!beforeWord && !afterWord) return true;
|
|
91
|
+
index = text.indexOf(identifier, index + 1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Split long lines at whitespace without breaking identifiers or surrogate pairs. */
|
|
98
|
+
function splitLongLine(line: string): string[] {
|
|
99
|
+
const parts: string[] = [];
|
|
100
|
+
let start = 0;
|
|
101
|
+
const target = WINDOW_TARGET_CHARACTERS / 2;
|
|
102
|
+
|
|
103
|
+
while (line.length - start > target) {
|
|
104
|
+
let end = start + target;
|
|
105
|
+
|
|
106
|
+
while (end > start && !/\s/u.test(line[end])) end -= 1;
|
|
107
|
+
|
|
108
|
+
if (end === start) {
|
|
109
|
+
end = start + target;
|
|
110
|
+
|
|
111
|
+
while (end < line.length && !/\s/u.test(line[end])) end += 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (end >= line.length) break;
|
|
115
|
+
parts.push(line.slice(start, end));
|
|
116
|
+
start = end;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (start < line.length) parts.push(line.slice(start));
|
|
120
|
+
|
|
121
|
+
return parts;
|
|
42
122
|
}
|
|
43
123
|
|
|
44
124
|
/**
|
|
45
|
-
* Splits
|
|
46
|
-
*
|
|
47
|
-
* Formatted source and log files have neither headings nor blank lines, so treating the
|
|
48
|
-
* whole body as a single section would make focus select everything. Windows are bounded
|
|
49
|
-
* by characters but never cut a line, and short blocks keep their single section.
|
|
125
|
+
* Splits headingless blocks into line-aligned windows, splitting long lines at
|
|
126
|
+
* whitespace when possible. Unbroken tokens stay intact even if oversized.
|
|
50
127
|
*/
|
|
51
128
|
function splitIntoWindows(block: string): string[] {
|
|
52
129
|
const lines = block.split(/\r?\n/);
|
|
@@ -58,6 +135,15 @@ function splitIntoWindows(block: string): string[] {
|
|
|
58
135
|
let size = 0;
|
|
59
136
|
|
|
60
137
|
for (const line of lines) {
|
|
138
|
+
if (line.length > WINDOW_TARGET_CHARACTERS / 2) {
|
|
139
|
+
if (current.length) windows.push(current.join("\n"));
|
|
140
|
+
current = [];
|
|
141
|
+
size = 0;
|
|
142
|
+
|
|
143
|
+
windows.push(...splitLongLine(line));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
61
147
|
if (size > 0 && size + line.length + 1 > WINDOW_TARGET_CHARACTERS) {
|
|
62
148
|
windows.push(current.join("\n"));
|
|
63
149
|
current = [];
|
|
@@ -134,9 +220,41 @@ function splitSections(markdown: string): SplitSections {
|
|
|
134
220
|
const hasHeadings = headingLines.some(Boolean);
|
|
135
221
|
|
|
136
222
|
if (!hasHeadings) {
|
|
137
|
-
//
|
|
223
|
+
// Split mixed prose and fenced blocks only at blank lines outside fences.
|
|
138
224
|
if (lines.some((line) => /^[ \t]{0,3}(`{3,}|~{3,})/.test(line))) {
|
|
139
|
-
|
|
225
|
+
const parts: string[] = [];
|
|
226
|
+
let current: string[] = [];
|
|
227
|
+
let fence: { marker: string; length: number } | undefined;
|
|
228
|
+
|
|
229
|
+
for (const line of lines) {
|
|
230
|
+
const match = /^[ \t]{0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
231
|
+
|
|
232
|
+
if (match) {
|
|
233
|
+
if (!fence) fence = { marker: match[1][0], length: match[1].length };
|
|
234
|
+
else if (
|
|
235
|
+
match[1][0] === fence.marker &&
|
|
236
|
+
match[1].length >= fence.length &&
|
|
237
|
+
!match[2].trim()
|
|
238
|
+
)
|
|
239
|
+
fence = undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!fence && !line.trim()) {
|
|
243
|
+
const part = current.join("\n").trim();
|
|
244
|
+
|
|
245
|
+
if (part) parts.push(part);
|
|
246
|
+
current = [];
|
|
247
|
+
} else current.push(line);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const part = current.join("\n").trim();
|
|
251
|
+
|
|
252
|
+
if (part) parts.push(part);
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
sections: parts.map((part) => ({ markdown: part, tokens: tokens(part) })),
|
|
256
|
+
separator: "\n\n",
|
|
257
|
+
};
|
|
140
258
|
}
|
|
141
259
|
|
|
142
260
|
const windowed = blocks.flatMap(splitIntoWindows);
|
|
@@ -164,8 +282,55 @@ function splitSections(markdown: string): SplitSections {
|
|
|
164
282
|
|
|
165
283
|
flush();
|
|
166
284
|
|
|
285
|
+
// Split oversized sections at blank lines outside fences, so matches do not pull
|
|
286
|
+
// in an entire chapter. Keep the heading with its first content block.
|
|
287
|
+
const bounded = sections.flatMap((section) => {
|
|
288
|
+
if (section.length <= WINDOW_MIN_CHARACTERS) return [section];
|
|
289
|
+
const chunks: string[] = [];
|
|
290
|
+
let current: string[] = [];
|
|
291
|
+
let size = 0;
|
|
292
|
+
let inFence: { marker: string; length: number } | undefined;
|
|
293
|
+
|
|
294
|
+
for (const line of section.split("\n")) {
|
|
295
|
+
const match = /^[ \t]{0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
296
|
+
|
|
297
|
+
if (match) {
|
|
298
|
+
if (!inFence) inFence = { marker: match[1][0], length: match[1].length };
|
|
299
|
+
else if (
|
|
300
|
+
match[1][0] === inFence.marker &&
|
|
301
|
+
match[1].length >= inFence.length &&
|
|
302
|
+
!match[2].trim()
|
|
303
|
+
)
|
|
304
|
+
inFence = undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!inFence && line.length > WINDOW_TARGET_CHARACTERS / 2) {
|
|
308
|
+
if (current.length) chunks.push(current.join("\n").trim());
|
|
309
|
+
current = [];
|
|
310
|
+
size = 0;
|
|
311
|
+
|
|
312
|
+
chunks.push(...splitLongLine(line));
|
|
313
|
+
} else if (!inFence && !line.trim() && size >= WINDOW_TARGET_CHARACTERS / 2) {
|
|
314
|
+
const chunk = current.join("\n").trim();
|
|
315
|
+
|
|
316
|
+
if (chunk) chunks.push(chunk);
|
|
317
|
+
current = [];
|
|
318
|
+
size = 0;
|
|
319
|
+
} else {
|
|
320
|
+
current.push(line);
|
|
321
|
+
size += line.length + 1;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const chunk = current.join("\n").trim();
|
|
326
|
+
|
|
327
|
+
if (chunk) chunks.push(chunk);
|
|
328
|
+
|
|
329
|
+
return chunks;
|
|
330
|
+
});
|
|
331
|
+
|
|
167
332
|
return {
|
|
168
|
-
sections:
|
|
333
|
+
sections: bounded.map((section) => ({ markdown: section, tokens: tokens(section) })),
|
|
169
334
|
separator: "\n\n",
|
|
170
335
|
};
|
|
171
336
|
}
|
|
@@ -177,11 +342,13 @@ function splitSections(markdown: string): SplitSections {
|
|
|
177
342
|
*/
|
|
178
343
|
export function focusMarkdown(markdown: string, query: string): FocusResult {
|
|
179
344
|
const normalizedQuery = query.trim();
|
|
180
|
-
const
|
|
345
|
+
const allTerms = [...new Set(tokens(normalizedQuery))];
|
|
346
|
+
const meaningfulTerms = allTerms.filter((term) => !STOP_WORDS.has(term));
|
|
347
|
+
const queryTerms = meaningfulTerms.length ? meaningfulTerms : allTerms;
|
|
181
348
|
const { sections, separator } = splitSections(markdown);
|
|
182
349
|
const originalCharacters = markdown.length;
|
|
183
350
|
|
|
184
|
-
if (
|
|
351
|
+
if (!normalizedQuery || sections.length === 0) {
|
|
185
352
|
return {
|
|
186
353
|
markdown: "",
|
|
187
354
|
details: {
|
|
@@ -207,7 +374,14 @@ export function focusMarkdown(markdown: string, query: string): FocusResult {
|
|
|
207
374
|
);
|
|
208
375
|
}
|
|
209
376
|
|
|
210
|
-
|
|
377
|
+
// Punctuated identifiers and flags are literal, never broad component searches.
|
|
378
|
+
const literalIdentifier =
|
|
379
|
+
!/\s/u.test(normalizedQuery) && /[.\-/]/u.test(normalizedQuery)
|
|
380
|
+
? normalizedQuery.toLowerCase()
|
|
381
|
+
: undefined;
|
|
382
|
+
|
|
383
|
+
const scores = sections.map((section) => {
|
|
384
|
+
if (literalIdentifier) return containsIdentifier(section.markdown, literalIdentifier) ? 1 : 0;
|
|
211
385
|
const frequencies = new Map<string, number>();
|
|
212
386
|
|
|
213
387
|
for (const token of section.tokens) frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
|
|
@@ -229,9 +403,15 @@ export function focusMarkdown(markdown: string, query: string): FocusResult {
|
|
|
229
403
|
((frequency * (1.2 + 1)) / (frequency + 1.2 * lengthNormalization));
|
|
230
404
|
}
|
|
231
405
|
|
|
232
|
-
return score
|
|
406
|
+
return score;
|
|
233
407
|
});
|
|
234
408
|
|
|
409
|
+
const highestScore = scores.reduce((highest, score) => Math.max(highest, score), 0);
|
|
410
|
+
|
|
411
|
+
const selected = sections.filter(
|
|
412
|
+
(_, index) => scores[index] > 0 && scores[index] >= highestScore * 0.5,
|
|
413
|
+
);
|
|
414
|
+
|
|
235
415
|
const focused = selected.map((section) => section.markdown).join(separator);
|
|
236
416
|
|
|
237
417
|
return {
|
package/src/index.ts
CHANGED
|
@@ -100,7 +100,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
100
100
|
pi.registerTool<typeof webFetchParameters, Partial<WebFetchDetails>>({
|
|
101
101
|
name: "web_fetch",
|
|
102
102
|
label: "Web Fetch",
|
|
103
|
-
description: "Read a public
|
|
103
|
+
description: "Read content from a public URL.",
|
|
104
104
|
parameters: webFetchParameters,
|
|
105
105
|
outputSchema: webFetchOutputSchema,
|
|
106
106
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -115,7 +115,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
115
115
|
},
|
|
116
116
|
|
|
117
117
|
async execute(_toolCallId, params, signal, onUpdate) {
|
|
118
|
-
|
|
118
|
+
try {
|
|
119
|
+
return await executeWebFetch(params, signal, onUpdate);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error instanceof Error && /^web_fetch(?:: | )/.test(error.message)) {
|
|
122
|
+
const message = error.message.replace(/^web_fetch(?:: | )/, "");
|
|
123
|
+
throw new Error(
|
|
124
|
+
message === "was cancelled."
|
|
125
|
+
? "Cancelled."
|
|
126
|
+
: message.charAt(0).toUpperCase() + message.slice(1),
|
|
127
|
+
{ cause: error },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
119
133
|
},
|
|
120
134
|
|
|
121
135
|
/**
|
package/src/render.ts
CHANGED
package/src/service.ts
CHANGED
|
@@ -374,7 +374,8 @@ const inflightFetches = new InflightCoalescer<string, FetchAcquisition>(MAX_INFL
|
|
|
374
374
|
* Executes a validated web fetch, including caching, focusing, and bounded pagination.
|
|
375
375
|
*
|
|
376
376
|
* A URL fragment selects the starting heading, anchor, or source range unless
|
|
377
|
-
* an explicit offset is supplied. A query selects sections before pagination
|
|
377
|
+
* an explicit offset is supplied without a query. A query selects sections before pagination;
|
|
378
|
+
* its continuation offsets retain the fragment-selected view.
|
|
378
379
|
* Missing fragments may suggest up to three links to other pages with that
|
|
379
380
|
* fragment. Discovered llms.txt index URLs appear on every unblocked result,
|
|
380
381
|
* including cache hits. Detected bot-wall documents return a blocked result
|
|
@@ -539,13 +540,13 @@ export async function executeWebFetch(
|
|
|
539
540
|
|
|
540
541
|
const startingOffset = explicitOffset ?? fragmentOffset ?? FETCH_DEFAULT_OFFSET;
|
|
541
542
|
|
|
542
|
-
const
|
|
543
|
-
explicitOffset === undefined
|
|
544
|
-
|
|
545
|
-
|
|
543
|
+
const useFragment =
|
|
544
|
+
fragmentOffset !== undefined && (explicitOffset === undefined || params.query !== undefined);
|
|
545
|
+
|
|
546
|
+
const hasFragmentRange = useFragment && resolvedFragment.endOffset !== undefined;
|
|
546
547
|
|
|
547
548
|
const fragmentDocument =
|
|
548
|
-
|
|
549
|
+
useFragment && fragmentOffset !== undefined
|
|
549
550
|
? {
|
|
550
551
|
...document,
|
|
551
552
|
markdown: hasFragmentRange
|
|
@@ -614,13 +615,13 @@ export async function executeWebFetch(
|
|
|
614
615
|
: [
|
|
615
616
|
...(result.markdownAlternateFallback
|
|
616
617
|
? [
|
|
617
|
-
"[
|
|
618
|
+
"[Page content was sparse; showing the site's advertised Markdown version instead.]",
|
|
618
619
|
"",
|
|
619
620
|
]
|
|
620
621
|
: []),
|
|
621
622
|
...(result.llmsTxtFallback
|
|
622
623
|
? [
|
|
623
|
-
"[Showing the site
|
|
624
|
+
"[Showing the site index instead of the requested page. Follow a link for details.]",
|
|
624
625
|
"",
|
|
625
626
|
]
|
|
626
627
|
: []),
|
|
@@ -629,7 +630,7 @@ export async function executeWebFetch(
|
|
|
629
630
|
: []),
|
|
630
631
|
...(resolvedFragment.fragment !== undefined && !fragmentMatched
|
|
631
632
|
? [
|
|
632
|
-
"[
|
|
633
|
+
"[Fragment not found; showing the page from the beginning.]",
|
|
633
634
|
...(fragmentSuggestions.length
|
|
634
635
|
? [`[This page links to the requested fragment: ${fragmentSuggestions.join(", ")}]`]
|
|
635
636
|
: []),
|
|
@@ -640,8 +641,8 @@ export async function executeWebFetch(
|
|
|
640
641
|
...(focused
|
|
641
642
|
? [
|
|
642
643
|
focused.details.matchedSections > 0
|
|
643
|
-
? `[
|
|
644
|
-
: "[No
|
|
644
|
+
? `[Query matched ${focused.details.matchedSections}/${focused.details.totalSections} sections. Offsets apply to this view; omit query for the full page.]`
|
|
645
|
+
: "[No sections matched. Omit query for the full page.]",
|
|
645
646
|
"",
|
|
646
647
|
]
|
|
647
648
|
: []),
|