@zeldrisho/pi-web-fetch 0.7.0 → 0.8.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 +30 -1
- package/README.md +12 -2
- package/package.json +2 -2
- package/src/cache.ts +36 -0
- package/src/content.ts +34 -1
- package/src/evidence.ts +118 -0
- package/src/extract.ts +214 -8
- package/src/fetch.ts +114 -43
- package/src/focus.ts +125 -0
- package/src/index.ts +27 -0
- package/src/inflight.ts +22 -0
- package/src/limits.ts +14 -0
- package/src/network-policy.ts +21 -5
- package/src/network-redirects.ts +127 -10
- package/src/network-transport.ts +35 -3
- package/src/outline.ts +240 -0
- package/src/service.ts +138 -16
package/src/network-transport.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isIP, type LookupFunction } from "node:net";
|
|
|
4
4
|
import { formatSize } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { ValidatedTarget } from "./network-policy";
|
|
6
6
|
|
|
7
|
+
/** Maximum size in bytes for raw HTTP response bodies. */
|
|
7
8
|
export const FETCH_MAX_BYTES = 5 * 1_024 * 1_024;
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -59,12 +60,18 @@ async function requestOnce(
|
|
|
59
60
|
address: string,
|
|
60
61
|
signal: AbortSignal,
|
|
61
62
|
attemptTimeoutMs: number,
|
|
63
|
+
headers: Readonly<Record<string, string>>,
|
|
62
64
|
): Promise<IncomingMessage> {
|
|
63
65
|
const family = isIP(address);
|
|
64
66
|
if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${address}.`);
|
|
65
67
|
const lookup: LookupFunction = (_hostname, options, callback) => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
// Match dns.lookup's asynchronous callback contract. Calling back inline
|
|
69
|
+
// lets an immediately unreachable address emit a socket error before
|
|
70
|
+
// node:http has finished installing its forwarding listeners.
|
|
71
|
+
queueMicrotask(() => {
|
|
72
|
+
if (options.all) callback(null, [{ address, family }]);
|
|
73
|
+
else callback(null, address, family);
|
|
74
|
+
});
|
|
68
75
|
};
|
|
69
76
|
const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
70
77
|
return await new Promise((resolve, reject) => {
|
|
@@ -95,6 +102,7 @@ async function requestOnce(
|
|
|
95
102
|
headers: {
|
|
96
103
|
Accept: "text/markdown, text/html, text/plain, application/json;q=0.9, */*;q=0.1",
|
|
97
104
|
"User-Agent": "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)",
|
|
105
|
+
...headers,
|
|
98
106
|
},
|
|
99
107
|
},
|
|
100
108
|
(response) => {
|
|
@@ -120,6 +128,8 @@ async function requestOnce(
|
|
|
120
128
|
export interface RequestPinnedOptions {
|
|
121
129
|
/** Per-address connect deadline used to fall back to the next address. */
|
|
122
130
|
attemptTimeoutMs?: number;
|
|
131
|
+
/** Additional fixed request headers, used for conditional cache revalidation. */
|
|
132
|
+
headers?: Readonly<Record<string, string>>;
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
/**
|
|
@@ -140,7 +150,7 @@ export async function requestPinned(
|
|
|
140
150
|
let lastError: unknown;
|
|
141
151
|
for (const address of addresses) {
|
|
142
152
|
try {
|
|
143
|
-
return await requestOnce(target, address, signal, attemptTimeoutMs);
|
|
153
|
+
return await requestOnce(target, address, signal, attemptTimeoutMs, options.headers ?? {});
|
|
144
154
|
} catch (error) {
|
|
145
155
|
lastError = error;
|
|
146
156
|
if (signal.aborted) throw error;
|
|
@@ -161,6 +171,18 @@ export function responseHeader(response: IncomingMessage, name: string): string
|
|
|
161
171
|
return Array.isArray(value) ? value[0] : value;
|
|
162
172
|
}
|
|
163
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Read and buffer the complete response body.
|
|
176
|
+
*
|
|
177
|
+
* Enforces the maximum byte limit and respects abort signals during the read.
|
|
178
|
+
* Destroys the response stream if limits are exceeded or abort is signaled.
|
|
179
|
+
*
|
|
180
|
+
* @param response - HTTP response to read
|
|
181
|
+
* @param maxBytes - Maximum allowed response size in bytes
|
|
182
|
+
* @param signal - Optional abort signal to cancel the read
|
|
183
|
+
* @returns Buffered response body as bytes
|
|
184
|
+
* @throws When response exceeds maxBytes or abort signal fires
|
|
185
|
+
*/
|
|
164
186
|
export async function readResponseBytes(
|
|
165
187
|
response: IncomingMessage,
|
|
166
188
|
maxBytes: number,
|
|
@@ -208,6 +230,16 @@ export async function readResponseBytes(
|
|
|
208
230
|
return output;
|
|
209
231
|
}
|
|
210
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Decode HTTP response bytes to a string using the charset from Content-Type.
|
|
235
|
+
*
|
|
236
|
+
* Extracts the charset parameter from the Content-Type header and uses it
|
|
237
|
+
* to decode the response. Falls back to UTF-8 if charset is missing or invalid.
|
|
238
|
+
*
|
|
239
|
+
* @param bytes - Raw response bytes to decode
|
|
240
|
+
* @param contentTypeHeader - Value of the Content-Type response header
|
|
241
|
+
* @returns Decoded response string
|
|
242
|
+
*/
|
|
211
243
|
export function decodeResponse(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
212
244
|
const charset = contentTypeHeader.match(/(?:^|;)\s*charset\s*=\s*["']?([^;"'\s]+)/i)?.[1];
|
|
213
245
|
try {
|
package/src/outline.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
const FENCE = /^[ \t]{0,3}(`{3,}|~{3,})/;
|
|
2
|
+
const MAX_FALLBACK_HEADING_CHARACTERS = 60;
|
|
3
|
+
export const MAX_OUTLINE_HEADINGS = 12;
|
|
4
|
+
export const MAX_OUTLINE_HEADING_CHARACTERS = 120;
|
|
5
|
+
|
|
6
|
+
/** One bounded heading exposed as untrusted document-shape metadata. */
|
|
7
|
+
export interface OutlineHeading {
|
|
8
|
+
level: number;
|
|
9
|
+
text: string;
|
|
10
|
+
words: number;
|
|
11
|
+
inferred: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Bounded structural summary of a complete extracted document. */
|
|
15
|
+
export interface DocumentOutline {
|
|
16
|
+
totalWords: number;
|
|
17
|
+
totalHeadings: number;
|
|
18
|
+
headings: OutlineHeading[];
|
|
19
|
+
omittedHeadings: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface HeadingLocation {
|
|
23
|
+
level: number;
|
|
24
|
+
text: string;
|
|
25
|
+
line: number;
|
|
26
|
+
inferred: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface HeadingCollection {
|
|
30
|
+
locations: HeadingLocation[];
|
|
31
|
+
total: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ParsedAtxHeading {
|
|
35
|
+
level: number;
|
|
36
|
+
text: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface MarkdownFence {
|
|
40
|
+
character: string;
|
|
41
|
+
length: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function updateFence(line: string, fence: MarkdownFence | undefined): MarkdownFence | undefined {
|
|
45
|
+
const match = FENCE.exec(line);
|
|
46
|
+
if (!match) return fence;
|
|
47
|
+
const marker = match[1];
|
|
48
|
+
if (!fence) return { character: marker[0], length: marker.length };
|
|
49
|
+
const closes =
|
|
50
|
+
marker[0] === fence.character &&
|
|
51
|
+
marker.length >= fence.length &&
|
|
52
|
+
!line.slice(match[0].length).trim();
|
|
53
|
+
return closes ? undefined : fence;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks if a character is a space or tab.
|
|
58
|
+
*
|
|
59
|
+
* @param character - The character to check
|
|
60
|
+
* @returns `true` if the character is a space or tab
|
|
61
|
+
*/
|
|
62
|
+
function isSpaceOrTab(character: string | undefined): boolean {
|
|
63
|
+
return character === " " || character === "\t";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parses the bounded ATX prefix with a linear scan to avoid backtracking on remote input. */
|
|
67
|
+
function parseAtxHeading(line: string): ParsedAtxHeading | undefined {
|
|
68
|
+
let index = 0;
|
|
69
|
+
while (index < 3 && isSpaceOrTab(line[index])) index += 1;
|
|
70
|
+
if (isSpaceOrTab(line[index])) return undefined;
|
|
71
|
+
|
|
72
|
+
const markerStart = index;
|
|
73
|
+
while (line[index] === "#") index += 1;
|
|
74
|
+
const level = index - markerStart;
|
|
75
|
+
if (level < 1 || level > 6 || !isSpaceOrTab(line[index])) return undefined;
|
|
76
|
+
|
|
77
|
+
while (isSpaceOrTab(line[index])) index += 1;
|
|
78
|
+
return { level, text: line.slice(index) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Counts the number of whitespace-separated words in a string.
|
|
83
|
+
*
|
|
84
|
+
* @param value - The string to count words in
|
|
85
|
+
* @returns The number of words, or 0 if the string is empty after trimming
|
|
86
|
+
*/
|
|
87
|
+
function countWords(value: string): number {
|
|
88
|
+
const trimmed = value.trim();
|
|
89
|
+
return trimmed ? trimmed.split(/\s+/).length : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Counts the total words in a document while respecting fenced code blocks.
|
|
94
|
+
*
|
|
95
|
+
* @param lines - The document lines to count words in
|
|
96
|
+
* @returns The total word count across all lines, including fenced content
|
|
97
|
+
*/
|
|
98
|
+
function countDocumentWords(lines: string[]): number {
|
|
99
|
+
let fence: MarkdownFence | undefined;
|
|
100
|
+
return lines.reduce((total, line) => {
|
|
101
|
+
const wasInsideFence = Boolean(fence);
|
|
102
|
+
fence = updateFence(line, fence);
|
|
103
|
+
const heading = wasInsideFence || fence ? undefined : parseAtxHeading(line);
|
|
104
|
+
return total + countWords(heading ? cleanHeading(heading.text) : line);
|
|
105
|
+
}, 0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Removes trailing whitespace and optional closing markers from heading text, then bounds the result.
|
|
110
|
+
*
|
|
111
|
+
* @param value - The raw heading text to clean
|
|
112
|
+
* @returns The cleaned and bounded heading text
|
|
113
|
+
*/
|
|
114
|
+
function cleanHeading(value: string): string {
|
|
115
|
+
let contentEnd = value.length;
|
|
116
|
+
while (contentEnd > 0 && isSpaceOrTab(value[contentEnd - 1])) contentEnd -= 1;
|
|
117
|
+
|
|
118
|
+
let markerStart = contentEnd;
|
|
119
|
+
while (markerStart > 0 && value[markerStart - 1] === "#") markerStart -= 1;
|
|
120
|
+
if (markerStart < contentEnd && markerStart > 0 && isSpaceOrTab(value[markerStart - 1])) {
|
|
121
|
+
contentEnd = markerStart - 1;
|
|
122
|
+
while (contentEnd > 0 && isSpaceOrTab(value[contentEnd - 1])) contentEnd -= 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return value.slice(0, contentEnd).trim().slice(0, MAX_OUTLINE_HEADING_CHARACTERS);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Collects ATX headings from Markdown lines while respecting fenced code blocks.
|
|
130
|
+
*
|
|
131
|
+
* @param lines - The document lines to scan for ATX headings
|
|
132
|
+
* @returns Collection of heading locations and total heading count
|
|
133
|
+
*/
|
|
134
|
+
function collectAtxHeadings(lines: string[]): HeadingCollection {
|
|
135
|
+
const locations: HeadingLocation[] = [];
|
|
136
|
+
let total = 0;
|
|
137
|
+
let fence: MarkdownFence | undefined;
|
|
138
|
+
|
|
139
|
+
for (const [lineNumber, line] of lines.entries()) {
|
|
140
|
+
const wasInsideFence = Boolean(fence);
|
|
141
|
+
fence = updateFence(line, fence);
|
|
142
|
+
if (wasInsideFence || fence) continue;
|
|
143
|
+
const heading = parseAtxHeading(line);
|
|
144
|
+
if (!heading) continue;
|
|
145
|
+
const text = cleanHeading(heading.text);
|
|
146
|
+
if (!text) continue;
|
|
147
|
+
total += 1;
|
|
148
|
+
if (locations.length <= MAX_OUTLINE_HEADINGS) {
|
|
149
|
+
locations.push({ level: heading.level, text, line: lineNumber, inferred: false });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { locations, total };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Heuristically determines if a line looks like a heading when ATX headings are unavailable.
|
|
157
|
+
*
|
|
158
|
+
* @param line - The current line to evaluate
|
|
159
|
+
* @param nextLine - The following non-empty line for context
|
|
160
|
+
* @returns `true` if the line appears to be a heading
|
|
161
|
+
*/
|
|
162
|
+
function looksLikeFallbackHeading(line: string, nextLine: string): boolean {
|
|
163
|
+
if (/^\s{4,}/.test(line)) return false;
|
|
164
|
+
const candidate = line.trim();
|
|
165
|
+
if (
|
|
166
|
+
!candidate ||
|
|
167
|
+
candidate.length > MAX_FALLBACK_HEADING_CHARACTERS ||
|
|
168
|
+
!/[A-Za-z]/.test(candidate) ||
|
|
169
|
+
/[.!?…,;:]$/.test(candidate) ||
|
|
170
|
+
/[,;]/.test(candidate) ||
|
|
171
|
+
/^([-*+]|\d+\.)\s/.test(candidate) ||
|
|
172
|
+
/^(>|\||https?:\/\/)/i.test(candidate)
|
|
173
|
+
) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (!/^[A-Z]/.test(candidate) && !/[`()._]/.test(candidate)) return false;
|
|
177
|
+
const following = nextLine.trim();
|
|
178
|
+
return following.length > MAX_FALLBACK_HEADING_CHARACTERS || /[.!?]$/.test(following);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Collects inferred headings using heuristics when no ATX headings are present.
|
|
183
|
+
*
|
|
184
|
+
* @param lines - The document lines to scan for heading-like content
|
|
185
|
+
* @returns Collection of inferred heading locations and total count
|
|
186
|
+
*/
|
|
187
|
+
function collectFallbackHeadings(lines: string[]): HeadingCollection {
|
|
188
|
+
const locations: HeadingLocation[] = [];
|
|
189
|
+
let total = 0;
|
|
190
|
+
const nextNonEmptyIndexes = new Int32Array(lines.length);
|
|
191
|
+
nextNonEmptyIndexes.fill(-1);
|
|
192
|
+
let nextNonEmptyIndex = -1;
|
|
193
|
+
for (let lineNumber = lines.length - 1; lineNumber >= 0; lineNumber -= 1) {
|
|
194
|
+
nextNonEmptyIndexes[lineNumber] = nextNonEmptyIndex;
|
|
195
|
+
if (lines[lineNumber].trim()) nextNonEmptyIndex = lineNumber;
|
|
196
|
+
}
|
|
197
|
+
let fence: MarkdownFence | undefined;
|
|
198
|
+
|
|
199
|
+
for (const [lineNumber, line] of lines.entries()) {
|
|
200
|
+
const wasInsideFence = Boolean(fence);
|
|
201
|
+
fence = updateFence(line, fence);
|
|
202
|
+
if (wasInsideFence || fence) continue;
|
|
203
|
+
const followingIndex = nextNonEmptyIndexes[lineNumber];
|
|
204
|
+
const following = followingIndex === -1 ? "" : lines[followingIndex];
|
|
205
|
+
if (looksLikeFallbackHeading(line, following)) {
|
|
206
|
+
total += 1;
|
|
207
|
+
if (locations.length <= MAX_OUTLINE_HEADINGS) {
|
|
208
|
+
locations.push({ level: 2, text: cleanHeading(line), line: lineNumber, inferred: true });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { locations, total };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Builds a bounded, fence-aware outline from complete extracted Markdown.
|
|
217
|
+
*
|
|
218
|
+
* ATX headings are preferred. Only documents with no ATX headings use conservative plain-text
|
|
219
|
+
* heading inference. Heading text is remote, untrusted metadata and is bounded before exposure.
|
|
220
|
+
*/
|
|
221
|
+
export function createDocumentOutline(markdown: string): DocumentOutline {
|
|
222
|
+
const lines = markdown.split("\n");
|
|
223
|
+
const explicit = collectAtxHeadings(lines);
|
|
224
|
+
const collection = explicit.total > 0 ? explicit : collectFallbackHeadings(lines);
|
|
225
|
+
const headings = collection.locations.slice(0, MAX_OUTLINE_HEADINGS).map((heading, index) => {
|
|
226
|
+
const next = collection.locations[index + 1]?.line ?? lines.length;
|
|
227
|
+
return {
|
|
228
|
+
level: heading.level,
|
|
229
|
+
text: heading.text,
|
|
230
|
+
words: countWords(lines.slice(heading.line + 1, next).join("\n")),
|
|
231
|
+
inferred: heading.inferred,
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
return {
|
|
235
|
+
totalWords: countDocumentWords(lines),
|
|
236
|
+
totalHeadings: collection.total,
|
|
237
|
+
headings,
|
|
238
|
+
omittedHeadings: Math.max(0, collection.total - headings.length),
|
|
239
|
+
};
|
|
240
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -7,17 +7,25 @@ import {
|
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { ExpiringLruCache, stableKeyHash, type CachePersistence } from "./cache";
|
|
9
9
|
import { sliceCompleteDocument, type CompleteDocument } from "./content";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
fetchCompleteDocument,
|
|
12
|
+
revalidateCompleteDocument,
|
|
13
|
+
type FetchRemoteDependencies,
|
|
14
|
+
} from "./fetch";
|
|
15
|
+
import { focusMarkdown, type FocusDetails } from "./focus";
|
|
11
16
|
import { InflightCoalescer } from "./inflight";
|
|
17
|
+
import { createDocumentOutline, type DocumentOutline } from "./outline";
|
|
12
18
|
import {
|
|
13
19
|
FETCH_DEFAULT_MAX_CHARACTERS,
|
|
14
20
|
FETCH_DEFAULT_OFFSET,
|
|
15
21
|
FETCH_MAX_CHARACTERS,
|
|
16
22
|
FETCH_MAX_OFFSET_CHARACTERS,
|
|
23
|
+
FETCH_MAX_QUERY_CHARACTERS,
|
|
17
24
|
FETCH_MIN_MAX_CHARACTERS,
|
|
18
25
|
} from "./limits";
|
|
19
26
|
|
|
20
27
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
28
|
+
const CACHE_STALE_RETENTION_MS = 7 * CACHE_TTL_MS;
|
|
21
29
|
const CACHE_MAX_ENTRIES = 100;
|
|
22
30
|
const CACHE_MAX_MARKDOWN_BYTES = 20 * 1_024 * 1_024;
|
|
23
31
|
const MAX_INFLIGHT_REQUESTS = 100;
|
|
@@ -38,6 +46,8 @@ export type ContentKind =
|
|
|
38
46
|
|
|
39
47
|
/** Confidence that the returned content faithfully represents the source page. */
|
|
40
48
|
export type FetchConfidence = "high" | "medium" | "low";
|
|
49
|
+
/** Cache evidence for this call. */
|
|
50
|
+
export type CacheStatus = "hit" | "revalidated" | "miss";
|
|
41
51
|
|
|
42
52
|
function safeUrl(value: string): URL | undefined {
|
|
43
53
|
try {
|
|
@@ -47,6 +57,17 @@ function safeUrl(value: string): URL | undefined {
|
|
|
47
57
|
}
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Classify the kind of content returned by a fetch operation.
|
|
62
|
+
*
|
|
63
|
+
* Determines the content type based on URL patterns, extractor used,
|
|
64
|
+
* and whether an app shell was detected.
|
|
65
|
+
*
|
|
66
|
+
* @param url - The fetched URL
|
|
67
|
+
* @param extractor - Extraction method used
|
|
68
|
+
* @param shellSuspected - Whether the page appears to be an app shell
|
|
69
|
+
* @returns Content classification
|
|
70
|
+
*/
|
|
50
71
|
export function classifyContentKind(
|
|
51
72
|
url: string,
|
|
52
73
|
extractor: CompleteDocument["extractor"],
|
|
@@ -69,6 +90,17 @@ export function classifyContentKind(
|
|
|
69
90
|
return "unknown";
|
|
70
91
|
}
|
|
71
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Classify confidence level for fetched content quality.
|
|
95
|
+
*
|
|
96
|
+
* Determines how confident we are that the extracted content accurately
|
|
97
|
+
* represents the source page, based on extraction method and content length.
|
|
98
|
+
*
|
|
99
|
+
* @param extractor - Extraction method used
|
|
100
|
+
* @param shellSuspected - Whether the page appears to be an app shell
|
|
101
|
+
* @param markdownLength - Length of extracted markdown content
|
|
102
|
+
* @returns Confidence level (high, medium, or low)
|
|
103
|
+
*/
|
|
72
104
|
export function classifyConfidence(
|
|
73
105
|
extractor: CompleteDocument["extractor"],
|
|
74
106
|
shellSuspected: boolean,
|
|
@@ -87,10 +119,30 @@ export function classifyConfidence(
|
|
|
87
119
|
* Returns them ordered shallow → deep, and an empty list for non-HTTP(S) targets or when
|
|
88
120
|
* the page itself is already a `llms.txt` path, so a fallback never retries itself.
|
|
89
121
|
*/
|
|
122
|
+
function isGitHubLikeHost(hostname: string): boolean {
|
|
123
|
+
return (
|
|
124
|
+
hostname === "github.com" ||
|
|
125
|
+
hostname === "raw.githubusercontent.com" ||
|
|
126
|
+
hostname === "gist.github.com" ||
|
|
127
|
+
hostname === "gist.githubusercontent.com"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build candidate /llms.txt URLs to probe for a given page.
|
|
133
|
+
*
|
|
134
|
+
* Generates a list of potential /llms.txt index URLs by checking the site root
|
|
135
|
+
* and ancestor directories up to a maximum depth. Returns URLs ordered from
|
|
136
|
+
* shallowest to deepest.
|
|
137
|
+
*
|
|
138
|
+
* @param rawUrl - The original page URL
|
|
139
|
+
* @returns Array of candidate /llms.txt URLs to probe (empty for non-HTTP/S or GitHub)
|
|
140
|
+
*/
|
|
90
141
|
export function buildLlmsTxtCandidateUrls(rawUrl: string): URL[] {
|
|
91
142
|
try {
|
|
92
143
|
const url = new URL(rawUrl);
|
|
93
144
|
if (url.protocol !== "https:" && url.protocol !== "http:") return [];
|
|
145
|
+
if (isGitHubLikeHost(url.hostname)) return [];
|
|
94
146
|
if (url.pathname === "/llms.txt" || url.pathname.endsWith("/llms.txt")) return [];
|
|
95
147
|
// Ancestor directories only, one level deep: for `/r2/buckets/x` probe
|
|
96
148
|
// `/llms.txt` and `/r2/llms.txt`, never deeper. A trailing slash marks the final
|
|
@@ -232,6 +284,13 @@ async function fetchDocumentWithLlmsTxtSupport(
|
|
|
232
284
|
signal: AbortSignal | undefined,
|
|
233
285
|
dependencies: FetchRemoteDependencies,
|
|
234
286
|
): Promise<CompleteDocument> {
|
|
287
|
+
try {
|
|
288
|
+
if (isGitHubLikeHost(new URL(rawUrl).hostname)) {
|
|
289
|
+
return fetchCompleteDocument(rawUrl, signal, dependencies);
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
// Invalid URL falls through to fetchCompleteDocument which will throw.
|
|
293
|
+
}
|
|
235
294
|
const candidates = buildLlmsTxtCandidateUrls(rawUrl);
|
|
236
295
|
const [primary, blindIndex] = await Promise.all([
|
|
237
296
|
fetchCompleteDocument(rawUrl, signal, dependencies),
|
|
@@ -277,18 +336,23 @@ function resolveCacheDirectory(name: string): string {
|
|
|
277
336
|
return base;
|
|
278
337
|
}
|
|
279
338
|
|
|
339
|
+
/** Parameters for a web fetch operation. */
|
|
280
340
|
export interface WebFetchParameters {
|
|
281
341
|
url: string;
|
|
342
|
+
/** Optional query used to select matching Markdown sections before continuation slicing. */
|
|
343
|
+
query?: string;
|
|
282
344
|
offset?: number;
|
|
283
345
|
maxCharacters?: number;
|
|
284
346
|
}
|
|
285
347
|
|
|
348
|
+
/** Details about content truncation and pagination strategy. */
|
|
286
349
|
export interface WebFetchTruncationDetails {
|
|
287
350
|
truncated: boolean;
|
|
288
351
|
strategy: "continuation" | "none";
|
|
289
352
|
nextOffset?: number;
|
|
290
353
|
}
|
|
291
354
|
|
|
355
|
+
/** Comprehensive metadata about a web fetch result. */
|
|
292
356
|
export interface WebFetchDetails {
|
|
293
357
|
url: string;
|
|
294
358
|
requestedUrl: string;
|
|
@@ -297,15 +361,24 @@ export interface WebFetchDetails {
|
|
|
297
361
|
title?: string;
|
|
298
362
|
extractor: CompleteDocument["extractor"];
|
|
299
363
|
contentKind: ContentKind;
|
|
364
|
+
/** @deprecated Use extractionDiagnostics for explicit quality evidence. */
|
|
300
365
|
shellSuspected: boolean;
|
|
366
|
+
extractionDiagnostics?: CompleteDocument["extractionDiagnostics"];
|
|
367
|
+
links?: CompleteDocument["links"];
|
|
301
368
|
confidence: FetchConfidence;
|
|
369
|
+
/** Bounded document-shape metadata for the complete, unfiltered document. Heading text is untrusted remote content. */
|
|
370
|
+
outline: DocumentOutline;
|
|
371
|
+
/** Deterministic focused-extraction evidence when a query was supplied. */
|
|
372
|
+
focus?: FocusDetails;
|
|
302
373
|
/** True when the returned content is the site's /llms.txt served instead of the requested page. */
|
|
303
374
|
llmsTxtFallback: boolean;
|
|
304
375
|
/** True when the returned content is the page's advertised Markdown version served instead of a low-quality page. */
|
|
305
376
|
markdownAlternateFallback: boolean;
|
|
306
377
|
/** The site's /llms.txt index URL when one exists and the returned content is not that index itself. */
|
|
307
378
|
llmsTxtUrl?: string;
|
|
379
|
+
/** True for a fresh cache hit or a successful 304 revalidation. */
|
|
308
380
|
cached: boolean;
|
|
381
|
+
cacheStatus: CacheStatus;
|
|
309
382
|
truncated: boolean;
|
|
310
383
|
offset: number;
|
|
311
384
|
nextOffset?: number;
|
|
@@ -333,7 +406,11 @@ const fetchCache = new ExpiringLruCache<string, CompleteDocument>(
|
|
|
333
406
|
undefined,
|
|
334
407
|
fetchCachePersistence,
|
|
335
408
|
);
|
|
336
|
-
|
|
409
|
+
interface FetchAcquisition {
|
|
410
|
+
document: CompleteDocument;
|
|
411
|
+
cacheStatus: Exclude<CacheStatus, "hit">;
|
|
412
|
+
}
|
|
413
|
+
const inflightFetches = new InflightCoalescer<string, FetchAcquisition>(MAX_INFLIGHT_REQUESTS);
|
|
337
414
|
|
|
338
415
|
/**
|
|
339
416
|
* Fetches a web page and returns formatted content with pagination and truncation metadata.
|
|
@@ -365,34 +442,66 @@ export async function executeWebFetch(
|
|
|
365
442
|
`web_fetch maxCharacters must be an integer between ${FETCH_MIN_MAX_CHARACTERS} and ${FETCH_MAX_CHARACTERS}.`,
|
|
366
443
|
);
|
|
367
444
|
}
|
|
445
|
+
if (
|
|
446
|
+
params.query !== undefined &&
|
|
447
|
+
(params.query.trim().length === 0 || params.query.length > FETCH_MAX_QUERY_CHARACTERS)
|
|
448
|
+
) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`web_fetch query must contain between 1 and ${FETCH_MAX_QUERY_CHARACTERS} characters.`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
368
453
|
let document = fetchCache.get(params.url);
|
|
369
|
-
const
|
|
454
|
+
const now = Date.now();
|
|
455
|
+
const fresh = Boolean(
|
|
456
|
+
document && (document.cachedAt === undefined || now - document.cachedAt < CACHE_TTL_MS),
|
|
457
|
+
);
|
|
458
|
+
let cacheStatus: CacheStatus = fresh ? "hit" : "miss";
|
|
370
459
|
onUpdate?.({
|
|
371
460
|
content: [
|
|
372
461
|
{
|
|
373
462
|
type: "text",
|
|
374
|
-
text:
|
|
463
|
+
text: fresh
|
|
464
|
+
? `Using cached content for ${params.url}…`
|
|
465
|
+
: document
|
|
466
|
+
? `Revalidating cached content for ${params.url}…`
|
|
467
|
+
: `Fetching ${params.url}…`,
|
|
375
468
|
},
|
|
376
469
|
],
|
|
377
470
|
details: {},
|
|
378
471
|
});
|
|
379
|
-
if (!
|
|
380
|
-
|
|
472
|
+
if (!fresh) {
|
|
473
|
+
const stale = document;
|
|
474
|
+
const acquisition = await inflightFetches.run(
|
|
381
475
|
params.url,
|
|
382
476
|
async (sharedSignal) => {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
477
|
+
let fetched: CompleteDocument;
|
|
478
|
+
let acquisitionStatus: FetchAcquisition["cacheStatus"] = "miss";
|
|
479
|
+
if (stale?.validators && !stale.llmsTxtFallback && !stale.markdownAlternateFallback) {
|
|
480
|
+
const outcome = await revalidateCompleteDocument(
|
|
481
|
+
params.url,
|
|
482
|
+
stale,
|
|
483
|
+
sharedSignal,
|
|
484
|
+
dependencies,
|
|
485
|
+
);
|
|
486
|
+
fetched = outcome.document;
|
|
487
|
+
acquisitionStatus = outcome.revalidated ? "revalidated" : "miss";
|
|
488
|
+
} else {
|
|
489
|
+
fetched = await fetchDocumentWithLlmsTxtSupport(params.url, sharedSignal, dependencies);
|
|
490
|
+
}
|
|
491
|
+
fetchCache.set(params.url, fetched, Date.now() + CACHE_STALE_RETENTION_MS);
|
|
492
|
+
return { document: fetched, cacheStatus: acquisitionStatus };
|
|
390
493
|
},
|
|
391
494
|
signal,
|
|
392
495
|
"web_fetch was cancelled.",
|
|
393
496
|
);
|
|
497
|
+
document = acquisition.document;
|
|
498
|
+
cacheStatus = acquisition.cacheStatus;
|
|
394
499
|
}
|
|
395
|
-
|
|
500
|
+
if (!document) throw new Error("web_fetch failed to acquire a document.");
|
|
501
|
+
const focused =
|
|
502
|
+
params.query === undefined ? undefined : focusMarkdown(document.markdown, params.query);
|
|
503
|
+
const outputDocument = focused ? { ...document, markdown: focused.markdown } : document;
|
|
504
|
+
const result = sliceCompleteDocument(outputDocument, offset, maxCharacters);
|
|
396
505
|
const requestedUrl = params.url;
|
|
397
506
|
const finalUrl = result.url;
|
|
398
507
|
const shellSuspected = result.shellSuspected;
|
|
@@ -419,8 +528,16 @@ export async function executeWebFetch(
|
|
|
419
528
|
"",
|
|
420
529
|
]
|
|
421
530
|
: []),
|
|
531
|
+
...(focused
|
|
532
|
+
? [
|
|
533
|
+
focused.details.matchedSections > 0
|
|
534
|
+
? `[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.]`
|
|
535
|
+
: "[No source sections matched the focus query. Omit query to read the complete document.]",
|
|
536
|
+
"",
|
|
537
|
+
]
|
|
538
|
+
: []),
|
|
422
539
|
`<untrusted_web_content source=${JSON.stringify(result.url)}>`,
|
|
423
|
-
result.markdown || "[The page contained no readable text.]",
|
|
540
|
+
result.markdown || (focused ? "" : "[The page contained no readable text.]"),
|
|
424
541
|
"</untrusted_web_content>",
|
|
425
542
|
].join("\n");
|
|
426
543
|
const outputTruncation = truncateHead(output, {
|
|
@@ -439,11 +556,16 @@ export async function executeWebFetch(
|
|
|
439
556
|
extractor: result.extractor,
|
|
440
557
|
contentKind,
|
|
441
558
|
shellSuspected,
|
|
559
|
+
extractionDiagnostics: result.extractionDiagnostics,
|
|
560
|
+
links: result.links,
|
|
442
561
|
confidence,
|
|
562
|
+
outline: createDocumentOutline(document.markdown),
|
|
563
|
+
focus: focused?.details,
|
|
443
564
|
llmsTxtFallback: Boolean(result.llmsTxtFallback),
|
|
444
565
|
markdownAlternateFallback: Boolean(result.markdownAlternateFallback),
|
|
445
566
|
llmsTxtUrl: result.llmsTxtIndexUrl,
|
|
446
|
-
cached,
|
|
567
|
+
cached: cacheStatus !== "miss",
|
|
568
|
+
cacheStatus,
|
|
447
569
|
truncated,
|
|
448
570
|
offset: result.offset,
|
|
449
571
|
nextOffset: result.nextOffset,
|