@zeldrisho/pi-web-fetch 0.11.0 → 0.12.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 CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-10-23
11
+
12
+ ### Changed
13
+
14
+ - Show discovered `llms.txt` indexes on every fetch result so the index remains visible when fetching multiple pages from the same origin.
15
+ - Prefer native Markdown representations during content negotiation while retaining supported text formats.
16
+
17
+ ### Fixed
18
+
19
+ - Suggest links to pages containing a requested fragment when it is absent from the current page.
20
+ - Handle empty and text-only HTML fragments without a null-document parser crash.
21
+ - Resolve section wrapper fragments when extracted headings are omitted.
22
+
23
+ ## [0.11.1] - 2026-10-20
24
+
25
+ ### Changed
26
+
27
+ - Simplify the model-facing fetch tool and parameter descriptions.
28
+
29
+ ### Fixed
30
+
31
+ - Retry 404 and 406 responses to Markdown-negotiated requests with HTML preferred, allowing pages whose HTML representation is available to fetch successfully.
32
+
10
33
  ## [0.11.0] - 2026-10-19
11
34
 
12
35
  ### Added
@@ -223,7 +246,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
223
246
 
224
247
  - Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
225
248
 
226
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.11.0...HEAD
249
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.12.0...HEAD
250
+ [0.12.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.11.1...pi-web-fetch-v0.12.0
251
+ [0.11.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.11.0...pi-web-fetch-v0.11.1
227
252
  [0.11.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.10.2...pi-web-fetch-v0.11.0
228
253
  [0.10.2]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.10.1...pi-web-fetch-v0.10.2
229
254
  [0.10.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.10.0...pi-web-fetch-v0.10.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-web-fetch",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
5
5
  "keywords": [
6
6
  "pi",
@@ -8,7 +8,7 @@ import {
8
8
 
9
9
  export { normalizeGitHubRawUrl, parseLinkHeaderForAgentHints } from "./fetch-metadata.ts";
10
10
 
11
- import { awaitWithAbort } from "../abort";
11
+ import { awaitWithAbort } from "../abort.ts";
12
12
  import {
13
13
  hasAnubisMarkers,
14
14
  parseAnubisChallenge,
@@ -31,7 +31,7 @@ import {
31
31
  type RedirectDependencies,
32
32
  } from "../network/network-redirects.ts";
33
33
  import type { ValidatedTarget } from "../network/network-policy.ts";
34
- import { redactUrlForDisplay } from "../redact";
34
+ import { redactUrlForDisplay } from "../redact.ts";
35
35
  import {
36
36
  decodeResponse,
37
37
  decompressResponse,
@@ -478,13 +478,29 @@ async function fetchDocument(
478
478
  };
479
479
 
480
480
  try {
481
+ const requestUrl = normalizeGitHubRawUrl(rawUrl);
482
+
481
483
  let { target, response } = await requestFollowingRedirects(
482
- normalizeGitHubRawUrl(rawUrl),
484
+ requestUrl,
483
485
  controller.signal,
484
486
  requestDependencies,
485
487
  conditionalHeaders,
486
488
  );
487
489
 
490
+ // Some servers route Markdown negotiation to a missing representation even
491
+ // though their HTML page exists (for example, Redis documentation URLs).
492
+ // Retry only these representation-related statuses, through the same
493
+ // validated redirect/network boundary, with HTML preferred.
494
+ if ([404, 406].includes(response.statusCode ?? 0)) {
495
+ response.resume();
496
+ ({ target, response } = await requestFollowingRedirects(
497
+ target.url,
498
+ controller.signal,
499
+ requestDependencies,
500
+ { Accept: "text/html, text/plain;q=0.9, */*;q=0.1" },
501
+ ));
502
+ }
503
+
488
504
  if (
489
505
  response.statusCode !== undefined &&
490
506
  responseHeader(response, "cf-mitigated")?.trim().toLowerCase() === "challenge"
@@ -82,11 +82,13 @@ function isElementNode(node: ChildNode): node is Element {
82
82
  * HTML5 permits those omissions, but body-based extraction (Defuddle and the plain-text
83
83
  * fallback) then sees nothing. Rebuild a well-formed `<html>` document, moving the
84
84
  * fragment's top-level nodes into `<head>` or `<body>` as appropriate.
85
+ * Empty and text-only documents also receive these wrappers, with text moved
86
+ * into the body. Documents already rooted at `<html>` are left unchanged.
85
87
  */
86
88
  function normalizeFragmentDocument(document: Document): void {
87
89
  const root = document.documentElement;
88
90
 
89
- if (!root || root.tagName === "HTML") return;
91
+ if (root?.tagName === "HTML") return;
90
92
 
91
93
  const html = document.createElement("html");
92
94
  const head = document.createElement("head");
@@ -94,9 +96,18 @@ function normalizeFragmentDocument(document: Document): void {
94
96
  html.appendChild(head);
95
97
  html.appendChild(body);
96
98
 
97
- const nodes = root.tagName === "BODY" ? [...root.childNodes] : [...document.childNodes];
99
+ const nodes = root
100
+ ? root.tagName === "BODY"
101
+ ? [...root.childNodes]
102
+ : [...document.childNodes]
103
+ : [...document.childNodes];
98
104
 
99
105
  for (const node of nodes) {
106
+ if (node.nodeType === 3) {
107
+ body.appendChild(node);
108
+ continue;
109
+ }
110
+
100
111
  if (!isElementNode(node)) continue;
101
112
  const tagName = node.tagName;
102
113
 
@@ -36,6 +36,53 @@ interface FragmentOffsets {
36
36
  [fragment: string]: number;
37
37
  }
38
38
 
39
+ interface NormalizedMarkdown {
40
+ text: string;
41
+ offsets: number[];
42
+ }
43
+
44
+ /**
45
+ * Collapses whitespace and lowercases Markdown while retaining the source
46
+ * offset of every resulting character.
47
+ */
48
+ function normalizeMarkdown(markdown: string): NormalizedMarkdown {
49
+ const characters: string[] = [];
50
+ const offsets: number[] = [];
51
+ let index = 0;
52
+
53
+ while (index < markdown.length) {
54
+ const character = markdown[index] ?? "";
55
+
56
+ if (/\s/u.test(character)) {
57
+ let end = index + 1;
58
+
59
+ while (end < markdown.length && /\s/u.test(markdown[end] ?? "")) end += 1;
60
+
61
+ characters.push(" ");
62
+ offsets.push(index);
63
+ index = end;
64
+ continue;
65
+ }
66
+
67
+ for (const lowered of character.toLowerCase()) {
68
+ characters.push(lowered);
69
+ offsets.push(index);
70
+ }
71
+
72
+ index += 1;
73
+ }
74
+
75
+ return { text: characters.join(""), offsets };
76
+ }
77
+
78
+ /**
79
+ * Maps HTML ids, named anchors, and Markdown heading slugs to extracted Markdown positions.
80
+ * HTML anchors match headings or retained text, including section descendants;
81
+ * anchors with no matching content are omitted.
82
+ *
83
+ * @param originalIds - Original ids for elements renamed during extraction; override current ids
84
+ * @returns Zero-based UTF-16 code-unit offsets into `markdown`, keyed by fragment without `#`
85
+ */
39
86
  export function buildFragmentOffsets(
40
87
  document: Document,
41
88
  markdown: string,
@@ -58,6 +105,7 @@ export function buildFragmentOffsets(
58
105
 
59
106
  const offsets: Record<string, number> = {};
60
107
  const used = new Set<number>();
108
+ let normalizedMarkdown: NormalizedMarkdown | undefined;
61
109
 
62
110
  for (const element of document.querySelectorAll<HTMLElement>("[id], a[name]")) {
63
111
  const id =
@@ -82,7 +130,28 @@ export function buildFragmentOffsets(
82
130
  if (text) {
83
131
  const textOffset = markdown.toLowerCase().indexOf(text.toLowerCase());
84
132
 
85
- if (textOffset >= 0) offsets[id] = textOffset;
133
+ if (textOffset >= 0) {
134
+ offsets[id] = textOffset;
135
+ continue;
136
+ }
137
+ }
138
+
139
+ // Section wrapper IDs often precede headings omitted by extraction. Fall
140
+ // back to the first retained paragraph/list/code block inside the section.
141
+ for (const descendant of element.querySelectorAll<HTMLElement>("p, li, pre, blockquote")) {
142
+ const content = (descendant.textContent ?? "").replace(/\s+/g, " ").trim();
143
+
144
+ if (content.length < 12) continue;
145
+ normalizedMarkdown ??= normalizeMarkdown(markdown);
146
+ const normalizedContent = content.toLowerCase();
147
+ const position = normalizedMarkdown.text.indexOf(normalizedContent);
148
+
149
+ if (position >= 0) {
150
+ const offset = normalizedMarkdown.offsets[position];
151
+
152
+ if (offset !== undefined) offsets[id] = offset;
153
+ break;
154
+ }
86
155
  }
87
156
  }
88
157
  }
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import { redactUrlForDisplay } from "./redact";
5
5
  import { formatCollapsibleOutput } from "./render";
6
- import { executeWebFetch, resetLlmsNoticeOrigins } from "./service";
6
+ import { executeWebFetch } from "./service";
7
7
 
8
8
  export { ExpiringLruCache } from "./cache";
9
9
 
@@ -61,10 +61,8 @@ export {
61
61
 
62
62
  export const webFetchParameters = Type.Object({
63
63
  url: Type.String(),
64
- query: Type.Optional(Type.String({ description: "Return only matching sections (long pages)." })),
65
- offset: Type.Optional(
66
- Type.Integer({ description: "Character offset; use nextOffset to continue." }),
67
- ),
64
+ query: Type.Optional(Type.String({ description: "Find sections matching this text." })),
65
+ offset: Type.Optional(Type.Integer({ description: "Start at this character offset." })),
68
66
  });
69
67
 
70
68
  /**
@@ -73,14 +71,10 @@ export const webFetchParameters = Type.Object({
73
71
  * @param pi - The extension API instance
74
72
  */
75
73
  export default function (pi: ExtensionAPI) {
76
- // SAFETY: The host provides `on`; minimal extension test doubles may omit it.
77
- (pi as ExtensionAPI & { on?: ExtensionAPI["on"] }).on?.("session_start", () =>
78
- resetLlmsNoticeOrigins(),
79
- );
80
74
  pi.registerTool({
81
75
  name: "web_fetch",
82
76
  label: "Web Fetch",
83
- description: "Fetch a public HTTP(S) page as Markdown (6,000 chars per call).",
77
+ description: "Read a public webpage as Markdown.",
84
78
  parameters: webFetchParameters,
85
79
 
86
80
  renderCall(args, theme) {
@@ -1,5 +1,5 @@
1
1
  import type { IncomingMessage } from "node:http";
2
- import { awaitWithAbort } from "../abort";
2
+ import { awaitWithAbort } from "../abort.ts";
3
3
  import type { CookieJar } from "./cookies.ts";
4
4
  import { validateRemoteUrl, type ValidatedTarget } from "./network-policy.ts";
5
5
  import { requestPinned, responseHeader } from "./network-transport.ts";
@@ -51,12 +51,15 @@ function responseTooLargeMessage(
51
51
  /**
52
52
  * Sends a request to a specific validated network address.
53
53
  *
54
+ * The default Accept header prefers Markdown over other supported text formats.
55
+ *
54
56
  * @param target - The validated request target.
55
57
  * @param address - The IPv4 or IPv6 address to use for the connection.
56
58
  * @param signal - Signal used to cancel the request.
57
- * @param attemptTimeoutMs - Maximum time allowed for the connection attempt.
58
- * @returns The received response.
59
- * @throws If `address` is not a valid IPv4 or IPv6 address.
59
+ * @param attemptTimeoutMs - Deadline in milliseconds until response headers arrive.
60
+ * @param headers - Request headers that can override the default Accept and User-Agent headers.
61
+ * @returns The response once headers arrive, regardless of HTTP status; its body is not read.
62
+ * @throws If the address is invalid, the deadline expires, the signal aborts, or the HTTP request fails.
60
63
  */
61
64
  async function requestOnce(
62
65
  target: ValidatedTarget,
@@ -113,7 +116,8 @@ async function requestOnce(
113
116
  lookup,
114
117
  signal: controller.signal,
115
118
  headers: {
116
- Accept: "text/markdown, text/html, text/plain, application/json;q=0.9, */*;q=0.1",
119
+ Accept:
120
+ "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, application/xhtml+xml;q=0.7, application/json;q=0.7, application/xml;q=0.7, */*;q=0.1",
117
121
  "User-Agent": "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)",
118
122
  ...headers,
119
123
  },
package/src/service.ts CHANGED
@@ -193,13 +193,6 @@ export interface WebFetchDetails {
193
193
  fragment?: { requested: string; matched: boolean; offset?: number };
194
194
  }
195
195
 
196
- const llmsNoticeOrigins = new Set<string>();
197
-
198
- /** Clears per-origin notice history so subsequent fetches can show llms.txt index notices again. */
199
- export function resetLlmsNoticeOrigins(): void {
200
- llmsNoticeOrigins.clear();
201
- }
202
-
203
196
  interface WebFetchUpdate {
204
197
  content: Array<{ type: "text"; text: string }>;
205
198
  details: Record<string, never>;
@@ -286,7 +279,24 @@ interface FetchAcquisition {
286
279
 
287
280
  const inflightFetches = new InflightCoalescer<string, FetchAcquisition>(MAX_INFLIGHT_REQUESTS);
288
281
 
289
- /** Executes a validated web fetch, including caching, focusing, and bounded pagination. */
282
+ /**
283
+ * Executes a validated web fetch, including caching, focusing, and bounded pagination.
284
+ *
285
+ * A URL fragment selects the starting heading, anchor, or source range unless
286
+ * an explicit offset is supplied. A query selects sections before pagination.
287
+ * Missing fragments may suggest up to three links to other pages with that
288
+ * fragment. Discovered llms.txt index URLs appear on every unblocked result,
289
+ * including cache hits. Detected bot-wall documents return a blocked result
290
+ * without page content.
291
+ *
292
+ * @param params - Fetch options; offsets and character limits count UTF-16 code units
293
+ * @param params.offset - Integer from 0 to 20,000,000; applies to the focused view when querying
294
+ * @param params.maxCharacters - Integer from 1,000 to 30,000; defaults to 6,000 before line/byte limits
295
+ * @param signal - Cancels acquisition; fresh cache hits do not consult this signal
296
+ * @param onUpdate - Receives cache or acquisition progress before content is returned
297
+ * @returns Bounded text output and metadata, including cache, fragment, and continuation details
298
+ * @throws For invalid parameter limits; acquisition, cancellation, and progress callback errors propagate
299
+ */
290
300
  export async function executeWebFetch(
291
301
  params: WebFetchParameters,
292
302
  signal: AbortSignal | undefined,
@@ -404,6 +414,35 @@ export async function executeWebFetch(
404
414
  const resolvedFragment = resolveFragmentOffset(document, params.url);
405
415
  const fragmentOffset = resolvedFragment.offset;
406
416
  const fragmentMatched = resolvedFragment.fragment !== undefined && fragmentOffset !== undefined;
417
+
418
+ const fragmentSuggestions =
419
+ resolvedFragment.fragment !== undefined && !fragmentMatched
420
+ ? [
421
+ ...(document.links?.internal ?? []).map((link) => link.url),
422
+ ...(document.links?.external ?? []).map((link) => link.url),
423
+ ...[...document.markdown.matchAll(/\]\(([^)]+)\)/g)].map((match) => match[1]),
424
+ ]
425
+ .filter((url) => {
426
+ try {
427
+ const linkedUrl = new URL(url, document.url);
428
+
429
+ if (decodeURIComponent(linkedUrl.hash.slice(1)) !== resolvedFragment.fragment) {
430
+ return false;
431
+ }
432
+
433
+ linkedUrl.hash = "";
434
+ const sourceUrl = new URL(document.url);
435
+ sourceUrl.hash = "";
436
+
437
+ return linkedUrl.href !== sourceUrl.href;
438
+ } catch {
439
+ return false;
440
+ }
441
+ })
442
+ .slice(0, 3)
443
+ .map((url) => redactUrlForDisplay(new URL(url, document.url).href))
444
+ : [];
445
+
407
446
  const startingOffset = explicitOffset ?? fragmentOffset ?? FETCH_DEFAULT_OFFSET;
408
447
 
409
448
  const hasFragmentRange =
@@ -460,13 +499,7 @@ export async function executeWebFetch(
460
499
  const contentKind = classifyContentKind(rawFinalUrl, result.extractor, shellSuspected);
461
500
  const confidence = classifyConfidence(result.extractor, shellSuspected, result.markdown.length);
462
501
 
463
- const llmsNoticeOrigin = result.llmsTxtIndexUrl
464
- ? new URL(result.llmsTxtIndexUrl).origin
465
- : undefined;
466
-
467
- const showLlmsNotice = Boolean(llmsNoticeOrigin && !llmsNoticeOrigins.has(llmsNoticeOrigin));
468
-
469
- if (showLlmsNotice && llmsNoticeOrigin) llmsNoticeOrigins.add(llmsNoticeOrigin);
502
+ const showLlmsNotice = Boolean(result.llmsTxtIndexUrl);
470
503
 
471
504
  const output = blocked
472
505
  ? "Blocked by an anti-bot challenge. Requested page content is unavailable."
@@ -489,7 +522,13 @@ export async function executeWebFetch(
489
522
  ? [`llms.txt index: ${redactUrlForDisplay(result.llmsTxtIndexUrl)}`, ""]
490
523
  : []),
491
524
  ...(resolvedFragment.fragment !== undefined && !fragmentMatched
492
- ? ["[The requested URL fragment was not found; showing the page from the beginning.]", ""]
525
+ ? [
526
+ "[The requested URL fragment was not found; showing the page from the beginning.]",
527
+ ...(fragmentSuggestions.length
528
+ ? [`[This page links to the requested fragment: ${fragmentSuggestions.join(", ")}]`]
529
+ : []),
530
+ "",
531
+ ]
493
532
  : []),
494
533
  ...(fragmentMatched ? ["[Starting at the requested URL fragment.]", ""] : []),
495
534
  ...(focused