@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 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.1...HEAD
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 as bounded Markdown with Pi. No API key is required.
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 a URL fragment. |
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.1",
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.3.0",
45
- "vite-plus": "1.3.0"
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"
@@ -221,11 +221,14 @@ function assertSuccessfulResponse(response: IncomingMessage): void {
221
221
  );
222
222
  }
223
223
 
224
- const authenticationHint = [401, 403, 404].includes(status)
225
- ? " The page may be missing, private, or require authentication."
226
- : "";
227
-
228
- throw new Error(`web_fetch returned HTTP ${status}.${authenticationHint}`);
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
  /**
@@ -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 page content.]";
122
+ markdown += "\n\n[End of results.]";
123
123
  }
124
124
 
125
125
  return {
@@ -1,6 +1,6 @@
1
1
  const WORD_PATTERN = /[\p{L}\p{N}_+#]+/gu;
2
2
 
3
- const HEADING_PATTERN = /^#{1,6}\s+\S/;
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 = 12_000;
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) ?? []).filter((token) => token.length > 1);
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 one headingless, blank-line-free block into line-aligned windows.
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
- // Keep mixed fenced Markdown intact rather than splitting a fence at blank lines.
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
- return { sections: [{ markdown, tokens: tokens(markdown) }], separator: "\n\n" };
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: sections.map((section) => ({ markdown: section, tokens: tokens(section) })),
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 queryTerms = [...new Set(tokens(normalizedQuery))];
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 (queryTerms.length === 0 || sections.length === 0) {
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
- const selected = sections.filter((section) => {
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 > 0;
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 webpage as Markdown.",
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
- return executeWebFetch(params, signal, onUpdate);
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
@@ -37,7 +37,7 @@ export function renderCollapsibleOutput(
37
37
  );
38
38
  }
39
39
 
40
- if (warning) {
40
+ if (warning && !visible.includes(warning)) {
41
41
  component.addChild(new Spacer(1));
42
42
  component.addChild(new Text(theme.fg("warning", warning), 0, 0));
43
43
  }
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 hasFragmentRange =
543
- explicitOffset === undefined &&
544
- fragmentOffset !== undefined &&
545
- resolvedFragment.endOffset !== undefined;
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
- explicitOffset === undefined && fragmentOffset !== undefined
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
- "[The requested page looked like an app shell or had little readable text, so this is the Markdown version advertised by the site instead.]",
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's documentation index instead of the requested page. Follow a relevant link to read the documentation.]",
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
- "[The requested URL fragment was not found; showing the page from the beginning.]",
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
- ? `[Showing ${focused.details.matchedSections} of ${focused.details.totalSections} source sections selected by the focus query. Offsets apply to this focused view; omit query to read the complete document.]`
644
- : "[No source sections matched the focus query. Omit query to read the complete document.]",
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
  : []),