@zeldrisho/pi-web-fetch 0.8.2 → 0.9.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/src/fetch.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  FETCH_MAX_BYTES,
12
12
  readResponseBytes,
13
13
  responseHeader,
14
+ responseHeaderValues,
14
15
  } from "./network-transport";
15
16
 
16
17
  /**
@@ -27,31 +28,43 @@ import {
27
28
  export function normalizeGitHubRawUrl(rawUrl: string): string {
28
29
  try {
29
30
  const url = new URL(rawUrl);
31
+
30
32
  if (url.protocol !== "https:") return rawUrl;
33
+
31
34
  if (url.hostname === "github.com") {
32
35
  if (url.pathname.includes("/blob/")) {
33
36
  return `https://raw.githubusercontent.com${url.pathname.replace("/blob/", "/")}${url.search}`;
34
37
  }
38
+
35
39
  // Parse pathname segments: /owner/repo/tree/ref/path
36
40
  const segments = url.pathname.split("/").filter(Boolean);
41
+
37
42
  if (segments.length >= 4 && segments[2] === "tree") {
38
43
  const lastSegment = segments[segments.length - 1];
44
+
39
45
  if (lastSegment.includes(".")) {
40
46
  // Reconstruct path: /owner/repo/ref/path (remove "tree" segment)
41
47
  const newPath = `/${segments[0]}/${segments[1]}/${segments.slice(3).join("/")}`;
48
+
42
49
  return `https://raw.githubusercontent.com${newPath}${url.search}`;
43
50
  }
51
+
44
52
  return rawUrl;
45
53
  }
54
+
46
55
  return rawUrl;
47
56
  }
57
+
48
58
  if (url.hostname === "gist.github.com") {
49
59
  const segments = url.pathname.split("/").filter(Boolean);
60
+
50
61
  // Only rewrite bare gist pages. Subpages such as `/revisions`, `/forks`, or an
51
62
  // explicit `/raw` path already point at a usable resource and stay untouched.
52
63
  if (segments.length !== 2) return rawUrl;
64
+
53
65
  return `https://gist.github.com/${segments[0]}/${segments[1]}/raw${url.search}`;
54
66
  }
67
+
55
68
  return rawUrl;
56
69
  } catch {
57
70
  return rawUrl;
@@ -90,10 +103,14 @@ export function parseLinkHeaderForAgentHints(
90
103
  let current = "";
91
104
  let inQuotes = false;
92
105
  let inAngleBrackets = false;
106
+
93
107
  for (const character of header) {
94
108
  if (character === '"') inQuotes = !inQuotes;
109
+
95
110
  if (character === "<") inAngleBrackets = true;
111
+
96
112
  if (character === ">" && !inQuotes) inAngleBrackets = false;
113
+
97
114
  if (character === "," && !inQuotes && !inAngleBrackets) {
98
115
  directives.push(current);
99
116
  current = "";
@@ -101,39 +118,52 @@ export function parseLinkHeaderForAgentHints(
101
118
  current += character;
102
119
  }
103
120
  }
121
+
104
122
  directives.push(current);
105
123
 
106
124
  let describedBy: string | undefined;
107
125
  let markdownAlternate: string | undefined;
126
+
108
127
  for (const directive of directives) {
109
128
  const match = /^\s*<([^>]*)>\s*(.*)$/.exec(directive);
129
+
110
130
  if (!match) continue;
111
131
  const [, rawTarget, rawParameters] = match;
112
132
  const parameters = new Map<string, string>();
133
+
113
134
  for (const parameter of rawParameters.split(";")) {
114
135
  const equals = parameter.indexOf("=");
136
+
115
137
  if (equals === -1) continue;
116
138
  const key = parameter.slice(0, equals).trim().toLowerCase();
139
+
117
140
  const value = parameter
118
141
  .slice(equals + 1)
119
142
  .trim()
120
143
  .replace(/^"|"$/g, "");
144
+
121
145
  parameters.set(key, value);
122
146
  }
147
+
123
148
  const relations = (parameters.get("rel") ?? "").toLowerCase().split(/\s+/);
124
149
  const type = (parameters.get("type") ?? "").toLowerCase();
125
150
  let resolved: string | undefined;
151
+
126
152
  try {
127
153
  resolved = new URL(rawTarget.trim(), baseUrl).href;
128
154
  } catch {
129
155
  continue;
130
156
  }
157
+
131
158
  if (!resolved) continue;
159
+
132
160
  if (!describedBy && relations.includes("describedby")) describedBy = resolved;
161
+
133
162
  if (!markdownAlternate && relations.includes("alternate") && type === "text/markdown") {
134
163
  markdownAlternate = resolved;
135
164
  }
136
165
  }
166
+
137
167
  return { describedBy, markdownAlternate };
138
168
  }
139
169
 
@@ -142,6 +172,43 @@ export interface FetchRemoteDependencies extends RedirectDependencies {
142
172
  timeoutMs?: number;
143
173
  }
144
174
 
175
+ /**
176
+ * Returns a usable media type from a response header.
177
+ *
178
+ * Some servers incorrectly emit an equivalent media type more than once (for example,
179
+ * `text/html,text/html; charset=utf-8`). Treat that as the declared type, but reject
180
+ * conflicting media types rather than guessing which representation to parse.
181
+ */
182
+ function normalizeContentType(header: string): string | undefined {
183
+ const mediaTypes: string[] = [];
184
+ let current = "";
185
+ let quoted = false;
186
+
187
+ for (const character of header) {
188
+ if (character === '"') quoted = !quoted;
189
+
190
+ if (character === "," && !quoted) {
191
+ mediaTypes.push(current);
192
+ current = "";
193
+ } else current += character;
194
+ }
195
+
196
+ mediaTypes.push(current);
197
+ const normalized: string[] = [];
198
+
199
+ for (const value of mediaTypes) {
200
+ const mediaType = value.split(";", 1)[0].trim().toLowerCase();
201
+
202
+ if (mediaType) normalized.push(mediaType);
203
+ }
204
+
205
+ if (normalized.length === 0 || normalized.some((value) => value !== normalized[0])) {
206
+ return undefined;
207
+ }
208
+
209
+ return normalized[0];
210
+ }
211
+
145
212
  /**
146
213
  * Converts a successful HTTP response into a complete document.
147
214
  *
@@ -160,47 +227,62 @@ async function documentFromResponse(
160
227
  extractHtml: typeof extractHtmlToMarkdown,
161
228
  ): Promise<CompleteDocument> {
162
229
  const status = response.statusCode ?? 0;
230
+
163
231
  if (status < 200 || status >= 300) {
164
232
  response.resume();
233
+
165
234
  const authenticationHint = [401, 403, 404].includes(status)
166
235
  ? " The page may be missing, private, or require authentication."
167
236
  : "";
237
+
168
238
  throw new Error(`web_fetch returned HTTP ${status}.${authenticationHint}`);
169
239
  }
170
240
 
171
- const contentTypeHeader = responseHeader(response, "content-type") ?? "text/plain";
172
- const contentType = contentTypeHeader.split(";", 1)[0].trim().toLowerCase();
241
+ const contentTypeHeader =
242
+ responseHeaderValues(response, "content-type").join(", ") || "text/plain";
243
+
244
+ const contentType = normalizeContentType(contentTypeHeader);
245
+
173
246
  const linkHints = parseLinkHeaderForAgentHints(
174
247
  responseHeader(response, "link") ?? "",
175
248
  target.url,
176
249
  );
250
+
177
251
  const allowed =
178
- contentType.startsWith("text/") ||
179
- [
180
- "application/json",
181
- "application/markdown",
182
- "application/x-markdown",
183
- "application/xml",
184
- "application/xhtml+xml",
185
- ].includes(contentType);
252
+ contentType !== undefined &&
253
+ (contentType.startsWith("text/") ||
254
+ [
255
+ "application/json",
256
+ "application/markdown",
257
+ "application/x-markdown",
258
+ "application/xml",
259
+ "application/xhtml+xml",
260
+ ].includes(contentType));
261
+
186
262
  if (!allowed) {
187
263
  response.destroy();
188
- throw new Error(`web_fetch does not support ${contentType || "this content type"}.`);
264
+ throw new Error(
265
+ `web_fetch does not support ${contentType ?? (contentTypeHeader || "this content type")}.`,
266
+ );
189
267
  }
190
268
 
191
269
  const raw = decodeResponse(
192
270
  await readResponseBytes(response, FETCH_MAX_BYTES, signal),
193
271
  contentTypeHeader,
194
272
  );
273
+
195
274
  let markdown: string;
196
275
  let title: string | undefined;
276
+ let fragmentOffsets: Record<string, number> | undefined;
197
277
  let extractor: CompleteDocument["extractor"] = "raw";
198
278
  let describedBy: string | undefined = linkHints.describedBy;
199
279
  let markdownAlternateUrl: string | undefined = linkHints.markdownAlternate;
280
+
200
281
  if (contentType === "text/html" || contentType === "application/xhtml+xml") {
201
282
  const extracted = await awaitWithAbort(extractHtml(raw, target.url), signal);
202
283
  markdown = extracted.markdown;
203
284
  title = extracted.title;
285
+ fragmentOffsets = extracted.fragmentOffsets;
204
286
  extractor = extracted.extractor;
205
287
  describedBy = describedBy ?? extracted.describedByLink;
206
288
  markdownAlternateUrl = markdownAlternateUrl ?? extracted.markdownAlternateLink;
@@ -214,9 +296,11 @@ async function documentFromResponse(
214
296
 
215
297
  const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
216
298
  const extractionDiagnostics = isHtml ? diagnoseExtraction(raw, markdown) : undefined;
299
+
217
300
  const shellSuspected = extractionDiagnostics
218
301
  ? hasExtractionWarning(extractionDiagnostics)
219
302
  : false;
303
+
220
304
  const etag = responseHeader(response, "etag");
221
305
  const lastModified = responseHeader(response, "last-modified");
222
306
 
@@ -224,6 +308,7 @@ async function documentFromResponse(
224
308
  url: target.url.toString(),
225
309
  contentType,
226
310
  markdown: markdown.replace(/<\/untrusted_web_content>/gi, "&lt;/untrusted_web_content&gt;"),
311
+ fragmentOffsets,
227
312
  title,
228
313
  extractor,
229
314
  shellSuspected,
@@ -245,14 +330,17 @@ async function documentFromResponse(
245
330
  */
246
331
  function assertAbsoluteHttpUrlForFetch(value: string): URL {
247
332
  let url: URL;
333
+
248
334
  try {
249
335
  url = new URL(value);
250
336
  } catch {
251
337
  throw new Error("web_fetch received an invalid URL.");
252
338
  }
339
+
253
340
  if (url.protocol !== "http:" && url.protocol !== "https:") {
254
341
  throw new Error(`web_fetch only supports http(s) URLs: ${redactUrlForDisplay(url)}`);
255
342
  }
343
+
256
344
  return url;
257
345
  }
258
346
 
@@ -276,14 +364,18 @@ async function fetchDocument(
276
364
  const timeoutMs = dependencies.timeoutMs ?? REQUEST_TIMEOUT_MS;
277
365
  const extractHtml = dependencies.extractHtml ?? extractHtmlToMarkdown;
278
366
  let timedOut = false;
367
+
279
368
  const timeout = setTimeout(() => {
280
369
  timedOut = true;
281
370
  controller.abort();
282
371
  }, timeoutMs);
372
+
283
373
  const cancel = () => controller.abort();
284
374
  signal?.addEventListener("abort", cancel, { once: true });
285
375
  const conditionalHeaders: Record<string, string> = {};
376
+
286
377
  if (cached?.validators?.etag) conditionalHeaders["If-None-Match"] = cached.validators.etag;
378
+
287
379
  if (cached?.validators?.lastModified)
288
380
  conditionalHeaders["If-Modified-Since"] = cached.validators.lastModified;
289
381
 
@@ -294,11 +386,14 @@ async function fetchDocument(
294
386
  dependencies,
295
387
  conditionalHeaders,
296
388
  );
389
+
297
390
  if (response.statusCode === 304 && cached) {
298
391
  response.resume();
299
392
  const etag = responseHeader(response, "etag") ?? cached.validators?.etag;
393
+
300
394
  const lastModified =
301
395
  responseHeader(response, "last-modified") ?? cached.validators?.lastModified;
396
+
302
397
  return {
303
398
  document: {
304
399
  ...cached,
@@ -308,12 +403,14 @@ async function fetchDocument(
308
403
  revalidated: true,
309
404
  };
310
405
  }
406
+
311
407
  return {
312
408
  document: await documentFromResponse(target, response, controller.signal, extractHtml),
313
409
  revalidated: false,
314
410
  };
315
411
  } catch (error) {
316
412
  if (timedOut) throw new Error(`web_fetch timed out after ${timeoutMs / 1000} seconds.`);
413
+
317
414
  if (signal?.aborted) throw new Error("web_fetch was cancelled.");
318
415
  throw error;
319
416
  } finally {
package/src/focus.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  const WORD_PATTERN = /[\p{L}\p{N}_+#]+/gu;
2
+
2
3
  const HEADING_PATTERN = /^#{1,6}\s+\S/;
3
4
 
4
5
  /** Metadata describing deterministic query-focused extraction. */
@@ -35,24 +36,31 @@ function splitSections(markdown: string): Section[] {
35
36
  .split(/\n{2,}/)
36
37
  .map((block) => block.trim())
37
38
  .filter(Boolean);
39
+
38
40
  if (blocks.length === 0) return [];
39
41
 
40
42
  const lines = markdown.split(/\r?\n/);
41
43
  const hasHeadings = lines.some((line) => HEADING_PATTERN.test(line));
44
+
42
45
  if (!hasHeadings) return blocks.map((block) => ({ markdown: block, tokens: tokens(block) }));
43
46
 
44
47
  const sections: string[] = [];
45
48
  let current: string[] = [];
49
+
46
50
  const flush = () => {
47
51
  const section = current.join("\n").trim();
52
+
48
53
  if (section) sections.push(section);
49
54
  current = [];
50
55
  };
56
+
51
57
  for (const line of lines) {
52
58
  if (HEADING_PATTERN.test(line)) flush();
53
59
  current.push(line);
54
60
  }
61
+
55
62
  flush();
63
+
56
64
  return sections.map((section) => ({ markdown: section, tokens: tokens(section) }));
57
65
  }
58
66
 
@@ -83,7 +91,9 @@ export function focusMarkdown(markdown: string, query: string): FocusResult {
83
91
 
84
92
  const averageLength =
85
93
  sections.reduce((sum, section) => sum + section.tokens.length, 0) / sections.length || 1;
94
+
86
95
  const documentFrequency = new Map<string, number>();
96
+
87
97
  for (const term of queryTerms) {
88
98
  documentFrequency.set(
89
99
  term,
@@ -93,24 +103,31 @@ export function focusMarkdown(markdown: string, query: string): FocusResult {
93
103
 
94
104
  const selected = sections.filter((section) => {
95
105
  const frequencies = new Map<string, number>();
106
+
96
107
  for (const token of section.tokens) frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
97
108
  const lengthNormalization = 1 - 0.75 + 0.75 * (section.tokens.length / averageLength);
98
109
  let score = 0;
110
+
99
111
  for (const term of queryTerms) {
100
112
  const frequency = frequencies.get(term) ?? 0;
113
+
101
114
  if (frequency === 0) continue;
102
115
  const frequencyInDocuments = documentFrequency.get(term) ?? 0;
116
+
103
117
  const inverseDocumentFrequency = Math.log(
104
118
  1 + (sections.length - frequencyInDocuments + 0.5) / (frequencyInDocuments + 0.5),
105
119
  );
120
+
106
121
  score +=
107
122
  inverseDocumentFrequency *
108
123
  ((frequency * (1.2 + 1)) / (frequency + 1.2 * lengthNormalization));
109
124
  }
125
+
110
126
  return score > 0;
111
127
  });
112
128
 
113
129
  const focused = selected.map((section) => section.markdown).join("\n\n");
130
+
114
131
  return {
115
132
  markdown: focused,
116
133
  details: {
package/src/index.ts CHANGED
@@ -15,7 +15,9 @@ import { formatCollapsibleOutput } from "./render";
15
15
  import { executeWebFetch } from "./service";
16
16
 
17
17
  export { ExpiringLruCache } from "./cache";
18
+
18
19
  export type { FetchResult } from "./content";
20
+
19
21
  export {
20
22
  diagnoseExtraction,
21
23
  extractDocumentLinks,
@@ -23,10 +25,15 @@ export {
23
25
  type ExtractedLinks,
24
26
  type ExtractionDiagnostics,
25
27
  } from "./evidence";
28
+
26
29
  export { fetchRemoteContent, type FetchRemoteDependencies } from "./fetch";
30
+
27
31
  export { focusMarkdown, type FocusDetails, type FocusResult } from "./focus";
32
+
28
33
  export { createDocumentOutline, type DocumentOutline, type OutlineHeading } from "./outline";
34
+
29
35
  export { redactUrlForDisplay } from "./redact";
36
+
30
37
  export {
31
38
  executeWebFetch,
32
39
  type CacheStatus,
@@ -34,6 +41,7 @@ export {
34
41
  type WebFetchParameters,
35
42
  type WebFetchTruncationDetails,
36
43
  } from "./service";
44
+
37
45
  export {
38
46
  FETCH_MAX_BYTES,
39
47
  isPrivateAddress,
@@ -85,7 +93,7 @@ export default function (pi: ExtensionAPI) {
85
93
  name: "web_fetch",
86
94
  label: "Web Fetch",
87
95
  description:
88
- "Fetch a public HTTP(S) page and return a bounded Markdown content chunk with continuation metadata.",
96
+ "Fetch a public HTTP(S) page and return a bounded Markdown content chunk with continuation metadata. URL fragments start at the matching heading or anchor; explicit offsets take precedence.",
89
97
  promptSnippet: "Read a public web page as bounded Markdown",
90
98
  promptGuidelines: [
91
99
  "Use web_fetch for a user-provided URL or to inspect relevant sources found with web_search.",
@@ -110,6 +118,7 @@ export default function (pi: ExtensionAPI) {
110
118
  if (isPartial) return new Text(theme.fg("warning", "Fetching…"), 0, 0);
111
119
 
112
120
  const content = result.content.find((item) => item.type === "text");
121
+
113
122
  return new Text(
114
123
  content?.type === "text"
115
124
  ? formatCollapsibleOutput(content.text, expanded, theme)
package/src/inflight.ts CHANGED
@@ -19,12 +19,16 @@ function waitForCaller<T>(
19
19
  cancelledMessage: string,
20
20
  ): Promise<T> {
21
21
  if (!signal) return operation;
22
+
22
23
  return new Promise((resolve, reject) => {
23
24
  const abort = (): void => reject(new Error(cancelledMessage));
25
+
24
26
  if (signal.aborted) {
25
27
  abort();
28
+
26
29
  return;
27
30
  }
31
+
28
32
  signal.addEventListener("abort", abort, { once: true });
29
33
  operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
30
34
  });
@@ -60,7 +64,10 @@ export class InflightCoalescer<K, V> {
60
64
  signal: AbortSignal | undefined,
61
65
  cancelledMessage: string,
62
66
  ): Promise<V> {
67
+ if (signal?.aborted) throw new Error(cancelledMessage);
68
+
63
69
  let entry = this.#entries.get(key);
70
+
64
71
  if (!entry) {
65
72
  if (this.#entries.size >= this.maxEntries) return operation(signal);
66
73
  const controller = new AbortController();
@@ -79,16 +86,19 @@ export class InflightCoalescer<K, V> {
79
86
  }
80
87
 
81
88
  entry.waiters += 1;
89
+
82
90
  try {
83
91
  return await waitForCaller(entry.promise, signal, cancelledMessage);
84
92
  } finally {
85
93
  entry.waiters -= 1;
94
+
86
95
  if (entry.waiters === 0 && !entry.settled) entry.controller.abort();
87
96
  }
88
97
  }
89
98
 
90
99
  #settle(key: K, entry: InflightEntry<V>): void {
91
100
  entry.settled = true;
101
+
92
102
  if (this.#entries.get(key) === entry) this.#entries.delete(key);
93
103
  }
94
104
  }
@@ -64,15 +64,19 @@ const GLOBALLY_REACHABLE_IPV6_EXCEPTIONS = [
64
64
  ] as const;
65
65
 
66
66
  const blockedIPv4Addresses = new BlockList();
67
+
67
68
  const blockedIPv6Addresses = new BlockList();
69
+
68
70
  const allowedIPv6Addresses = new BlockList();
69
71
 
70
72
  for (const [network, prefix] of BLOCKED_IPV4_RANGES) {
71
73
  blockedIPv4Addresses.addSubnet(network, prefix, "ipv4");
72
74
  }
75
+
73
76
  for (const [network, prefix] of BLOCKED_IPV6_RANGES) {
74
77
  blockedIPv6Addresses.addSubnet(network, prefix, "ipv6");
75
78
  }
79
+
76
80
  for (const [network, prefix] of GLOBALLY_REACHABLE_IPV6_EXCEPTIONS) {
77
81
  allowedIPv6Addresses.addSubnet(network, prefix, "ipv6");
78
82
  }
@@ -106,11 +110,15 @@ export type ResolveAddresses = (hostname: string) => Promise<string[]>;
106
110
  */
107
111
  export function isPrivateAddress(address: string): boolean {
108
112
  const family = isIP(address);
113
+
109
114
  if (family === 4) return blockedIPv4Addresses.check(address, "ipv4");
115
+
110
116
  if (family === 6) {
111
117
  if (allowedIPv6Addresses.check(address, "ipv6")) return false;
118
+
112
119
  return blockedIPv6Addresses.check(address, "ipv6");
113
120
  }
121
+
114
122
  return true;
115
123
  }
116
124
 
@@ -136,8 +144,10 @@ export async function validateRemoteUrl(
136
144
  resolveHostname?: ResolveAddresses,
137
145
  ): Promise<ValidatedTarget> {
138
146
  const url = value instanceof URL ? value : new URL(value);
147
+
139
148
  if (url.protocol !== "http:" && url.protocol !== "https:")
140
149
  throw new Error("web_fetch only supports HTTP and HTTPS URLs.");
150
+
141
151
  if (url.username || url.password)
142
152
  throw new Error("web_fetch blocks URLs containing credentials.");
143
153
 
@@ -145,24 +155,31 @@ export async function validateRemoteUrl(
145
155
  .toLowerCase()
146
156
  .replace(/^\[|\]$/g, "")
147
157
  .replace(/\.$/, "");
158
+
148
159
  if (!hostname || hostname === "localhost" || hostname.endsWith(".localhost")) {
149
160
  throw new Error("web_fetch blocks local hostnames.");
150
161
  }
151
162
 
152
163
  let addresses: string[];
164
+
153
165
  if (isIP(hostname)) addresses = [hostname];
154
166
  else if (resolveHostname) addresses = await resolveHostname(hostname);
155
167
  else {
156
168
  const records = await dnsLookup(hostname, { all: true, verbatim: true });
157
169
  addresses = [];
170
+
158
171
  for (const record of records) addresses.push(record.address);
159
172
  }
173
+
160
174
  if (addresses.length === 0 || addresses.some(isPrivateAddress)) {
161
175
  throw new Error(`web_fetch blocks private or reserved network targets (${hostname}).`);
162
176
  }
177
+
163
178
  const ordered = preferIpv4First(addresses);
164
179
  const address = ordered[0];
165
180
  const family = isIP(address);
181
+
166
182
  if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${hostname}.`);
183
+
167
184
  return { url, address, family, addresses: ordered };
168
185
  }