@zeldrisho/pi-web-fetch 0.8.1 → 0.9.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/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
  }
@@ -5,15 +5,20 @@ import { requestPinned, responseHeader } from "./network-transport";
5
5
 
6
6
  /** Maximum number of HTTP redirects to follow before aborting. */
7
7
  export const FETCH_MAX_REDIRECTS = 5;
8
+
8
9
  const MAX_ORIGIN_CONCURRENCY = 4;
10
+
9
11
  const MAX_RETRIES = 2;
12
+
10
13
  const MAX_RETRY_DELAY_MS = 10_000;
14
+
11
15
  const BASE_RETRY_DELAY_MS = 250;
12
16
 
13
17
  interface OriginState {
14
18
  active: number;
15
19
  waiting: Array<() => void>;
16
20
  }
21
+
17
22
  const origins = new Map<string, OriginState>();
18
23
 
19
24
  /** Optional dependencies for redirect handling (used for testing). */
@@ -28,21 +33,27 @@ export interface RedirectDependencies {
28
33
  sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
29
34
  }
30
35
 
36
+ /** Creates the consistent abort error used by redirect and retry coordination. */
31
37
  function abortError(): Error {
32
38
  const error = new Error("Operation aborted.");
33
39
  error.name = "AbortError";
40
+
34
41
  return error;
35
42
  }
36
43
 
44
+ /** Reserves an origin-concurrency slot and returns a function that releases it. */
37
45
  async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() => void> {
38
46
  const state = origins.get(origin) ?? { active: 0, waiting: [] };
39
47
  origins.set(origin, state);
40
48
  let reserved = false;
49
+
41
50
  const release = () => {
42
51
  state.active -= 1;
43
52
  state.waiting.shift()?.();
53
+
44
54
  if (state.active === 0 && state.waiting.length === 0) origins.delete(origin);
45
55
  };
56
+
46
57
  if (state.active >= MAX_ORIGIN_CONCURRENCY) {
47
58
  await new Promise<void>((resolve, reject) => {
48
59
  const enter = () => {
@@ -51,11 +62,14 @@ async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() =>
51
62
  reserved = true;
52
63
  resolve();
53
64
  };
65
+
54
66
  const cancel = () => {
55
67
  const index = state.waiting.indexOf(enter);
68
+
56
69
  if (index >= 0) state.waiting.splice(index, 1);
57
70
  reject(abortError());
58
71
  };
72
+
59
73
  if (signal.aborted) cancel();
60
74
  else {
61
75
  state.waiting.push(enter);
@@ -63,40 +77,52 @@ async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() =>
63
77
  }
64
78
  });
65
79
  }
80
+
66
81
  if (signal.aborted) {
67
82
  if (reserved) release();
68
83
  throw abortError();
69
84
  }
85
+
70
86
  if (!reserved) state.active += 1;
87
+
71
88
  return release;
72
89
  }
73
90
 
91
+ /** Waits for a retry delay while allowing the caller to cancel immediately. */
74
92
  async function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
75
93
  await new Promise<void>((resolve, reject) => {
76
94
  const timer = setTimeout(done, milliseconds);
95
+
77
96
  function done(): void {
78
97
  signal.removeEventListener("abort", cancel);
79
98
  resolve();
80
99
  }
100
+
81
101
  function cancel(): void {
82
102
  clearTimeout(timer);
83
103
  reject(abortError());
84
104
  }
105
+
85
106
  if (signal.aborted) cancel();
86
107
  else signal.addEventListener("abort", cancel, { once: true });
87
108
  });
88
109
  }
89
110
 
111
+ /** Parses a Retry-After header into a bounded delay in milliseconds. */
90
112
  function retryAfterMilliseconds(value: string | undefined, now = Date.now()): number | undefined {
91
113
  if (!value) return undefined;
92
114
  const seconds = Number(value.trim());
115
+
93
116
  if (Number.isFinite(seconds) && seconds >= 0)
94
117
  return Math.min(seconds * 1_000, MAX_RETRY_DELAY_MS);
95
118
  const date = Date.parse(value);
119
+
96
120
  if (!Number.isFinite(date)) return undefined;
121
+
97
122
  return Math.min(Math.max(0, date - now), MAX_RETRY_DELAY_MS);
98
123
  }
99
124
 
125
+ /** Sends one origin-limited request and retries transient rate-limit responses. */
100
126
  async function coordinatedRequest(
101
127
  target: ValidatedTarget,
102
128
  signal: AbortSignal,
@@ -107,17 +133,22 @@ async function coordinatedRequest(
107
133
  dependencies.request ??
108
134
  ((value, requestSignal, requestHeaders) =>
109
135
  requestPinned(value, requestSignal, { headers: requestHeaders }));
136
+
110
137
  const random = dependencies.random ?? Math.random;
111
138
  const sleep = dependencies.sleep ?? defaultSleep;
139
+
112
140
  for (let attempt = 0; ; attempt += 1) {
113
141
  const release = await acquireOrigin(target.url.origin, signal);
114
142
  let response: IncomingMessage;
143
+
115
144
  try {
116
145
  response = await request(target, signal, headers);
117
146
  } finally {
118
147
  release();
119
148
  }
149
+
120
150
  const status = response.statusCode ?? 0;
151
+
121
152
  if (![429, 503].includes(status) || attempt >= MAX_RETRIES) return response;
122
153
  // The retry decision uses headers only. Drop an untrusted error body so it cannot
123
154
  // consume unbounded bandwidth while the caller is backing off.
@@ -146,10 +177,13 @@ export async function requestFollowingRedirects(
146
177
  for (let redirects = 0; redirects <= FETCH_MAX_REDIRECTS; redirects += 1) {
147
178
  const response = await coordinatedRequest(target, signal, requestHeaders, dependencies);
148
179
  const status = response.statusCode ?? 0;
180
+
149
181
  if (![301, 302, 303, 307, 308].includes(status)) return { target, response };
150
182
 
151
183
  const location = responseHeader(response, "location");
184
+
152
185
  if (!location) throw new Error("web_fetch received a redirect without a Location header.");
186
+
153
187
  if (redirects === FETCH_MAX_REDIRECTS)
154
188
  throw new Error("web_fetch followed too many redirects.");
155
189
  response.resume();
@@ -157,5 +191,6 @@ export async function requestFollowingRedirects(
157
191
  // Validators describe the originally cached resource and must not leak to a redirect target.
158
192
  requestHeaders = {};
159
193
  }
194
+
160
195
  throw new Error("web_fetch followed too many redirects.");
161
196
  }
@@ -20,6 +20,7 @@ const encoder = new TextEncoder();
20
20
  function abortedError(): Error {
21
21
  const error = new Error("Operation aborted.");
22
22
  error.name = "AbortError";
23
+
23
24
  return error;
24
25
  }
25
26
 
@@ -39,6 +40,7 @@ function responseTooLargeMessage(
39
40
  const size = sizeIsExact
40
41
  ? `is ${formatSize(receivedBytes)}`
41
42
  : `has reached at least ${formatSize(receivedBytes)}`;
43
+
42
44
  return [
43
45
  `web_fetch response ${size}, exceeding the ${formatSize(maxBytes)} raw download limit.`,
44
46
  "maxCharacters only controls returned output.",
@@ -63,7 +65,9 @@ async function requestOnce(
63
65
  headers: Readonly<Record<string, string>>,
64
66
  ): Promise<IncomingMessage> {
65
67
  const family = isIP(address);
68
+
66
69
  if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${address}.`);
70
+
67
71
  const lookup: LookupFunction = (_hostname, options, callback) => {
68
72
  // Match dns.lookup's asynchronous callback contract. Calling back inline
69
73
  // lets an immediately unreachable address emit a socket error before
@@ -73,18 +77,24 @@ async function requestOnce(
73
77
  else callback(null, address, family);
74
78
  });
75
79
  };
80
+
76
81
  const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
82
+
77
83
  return await new Promise((resolve, reject) => {
78
84
  const controller = new AbortController();
79
85
  let attemptExpired = false;
86
+
80
87
  const timer = setTimeout(() => {
81
88
  attemptExpired = true;
82
89
  controller.abort();
83
90
  }, attemptTimeoutMs);
91
+
84
92
  const forwardAbort = () => controller.abort();
93
+
85
94
  if (signal.aborted) controller.abort();
86
95
  else signal.addEventListener("abort", forwardAbort, { once: true });
87
96
  let settled = false;
97
+
88
98
  const finish = (callback: () => void) => {
89
99
  if (settled) return;
90
100
  settled = true;
@@ -92,8 +102,10 @@ async function requestOnce(
92
102
  signal.removeEventListener("abort", forwardAbort);
93
103
  callback();
94
104
  };
105
+
95
106
  const unreachableError = () =>
96
107
  new Error(`web_fetch could not reach ${address} within ${attemptTimeoutMs} ms.`);
108
+
97
109
  const outgoing = request(
98
110
  target.url,
99
111
  {
@@ -114,6 +126,7 @@ async function requestOnce(
114
126
  } else finish(() => resolve(response));
115
127
  },
116
128
  );
129
+
117
130
  outgoing.once("error", (error) => {
118
131
  // The per-attempt deadline surfaces as a raw AbortError from the HTTP
119
132
  // client; report it as an unreachable address instead so the next
@@ -148,14 +161,17 @@ export async function requestPinned(
148
161
  const attemptTimeoutMs = options.attemptTimeoutMs ?? CONNECT_ATTEMPT_TIMEOUT_MS;
149
162
  const addresses = target.addresses?.length ? target.addresses : [target.address];
150
163
  let lastError: unknown;
164
+
151
165
  for (const address of addresses) {
152
166
  try {
153
167
  return await requestOnce(target, address, signal, attemptTimeoutMs, options.headers ?? {});
154
168
  } catch (error) {
155
169
  lastError = error;
170
+
156
171
  if (signal.aborted) throw error;
157
172
  }
158
173
  }
174
+
159
175
  throw lastError ?? new Error("web_fetch could not connect.");
160
176
  }
161
177
 
@@ -168,6 +184,7 @@ export async function requestPinned(
168
184
  */
169
185
  export function responseHeader(response: IncomingMessage, name: string): string | undefined {
170
186
  const value = response.headers[name];
187
+
171
188
  return Array.isArray(value) ? value[0] : value;
172
189
  }
173
190
 
@@ -189,27 +206,33 @@ export async function readResponseBytes(
189
206
  signal?: AbortSignal,
190
207
  ): Promise<Uint8Array> {
191
208
  const declared = Number(responseHeader(response, "content-length"));
209
+
192
210
  if (Number.isFinite(declared) && declared > maxBytes) {
193
211
  response.destroy();
194
212
  throw new Error(responseTooLargeMessage(declared, maxBytes, true));
195
213
  }
214
+
196
215
  // Once response headers arrive the connect deadline and caller signal are no
197
216
  // longer wired to the socket, so a stalled body would otherwise hang the
198
217
  // fetch forever. Keep the caller signal attached for the whole body read and
199
218
  // drop the socket when it fires.
200
219
  const forwardAbort = () => response.destroy();
220
+
201
221
  if (signal?.aborted) response.destroy();
202
222
  else signal?.addEventListener("abort", forwardAbort, { once: true });
203
223
  const chunks: Uint8Array[] = [];
204
224
  let total = 0;
225
+
205
226
  try {
206
227
  for await (const value of response) {
207
228
  const chunk = value instanceof Uint8Array ? value : encoder.encode(value);
208
229
  total += chunk.byteLength;
230
+
209
231
  if (total > maxBytes) {
210
232
  response.destroy();
211
233
  throw new Error(responseTooLargeMessage(total, maxBytes, false));
212
234
  }
235
+
213
236
  chunks.push(chunk);
214
237
  }
215
238
  } catch (error) {
@@ -218,15 +241,18 @@ export async function readResponseBytes(
218
241
  } finally {
219
242
  signal?.removeEventListener("abort", forwardAbort);
220
243
  }
244
+
221
245
  // Destroying the socket can end the stream without an error; detect a
222
246
  // mid-read abort here as well so truncated bodies never look complete.
223
247
  if (signal?.aborted) throw abortedError();
224
248
  const output = new Uint8Array(total);
225
249
  let offset = 0;
250
+
226
251
  for (const chunk of chunks) {
227
252
  output.set(chunk, offset);
228
253
  offset += chunk.byteLength;
229
254
  }
255
+
230
256
  return output;
231
257
  }
232
258
 
@@ -242,6 +268,7 @@ export async function readResponseBytes(
242
268
  */
243
269
  export function decodeResponse(bytes: Uint8Array, contentTypeHeader: string): string {
244
270
  const charset = contentTypeHeader.match(/(?:^|;)\s*charset\s*=\s*["']?([^;"'\s]+)/i)?.[1];
271
+
245
272
  try {
246
273
  return new TextDecoder(charset || "utf-8").decode(bytes);
247
274
  } catch {
package/src/network.ts CHANGED
@@ -5,6 +5,7 @@ export {
5
5
  type ResolveAddresses,
6
6
  type ValidatedTarget,
7
7
  } from "./network-policy";
8
+
8
9
  export {
9
10
  decodeResponse,
10
11
  FETCH_MAX_BYTES,