@zeldrisho/pi-web-fetch 0.7.1 → 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 +21 -1
- package/README.md +12 -2
- package/package.json +1 -1
- package/src/cache.ts +36 -0
- package/src/content.ts +10 -1
- package/src/evidence.ts +118 -0
- package/src/extract.ts +135 -2
- package/src/fetch.ts +59 -41
- package/src/focus.ts +125 -0
- package/src/index.ts +19 -0
- package/src/inflight.ts +22 -0
- package/src/limits.ts +3 -0
- package/src/network-redirects.ts +125 -10
- package/src/network-transport.ts +5 -1
- package/src/outline.ts +240 -0
- package/src/service.ts +86 -16
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 {
|
|
@@ -329,6 +339,8 @@ function resolveCacheDirectory(name: string): string {
|
|
|
329
339
|
/** Parameters for a web fetch operation. */
|
|
330
340
|
export interface WebFetchParameters {
|
|
331
341
|
url: string;
|
|
342
|
+
/** Optional query used to select matching Markdown sections before continuation slicing. */
|
|
343
|
+
query?: string;
|
|
332
344
|
offset?: number;
|
|
333
345
|
maxCharacters?: number;
|
|
334
346
|
}
|
|
@@ -349,15 +361,24 @@ export interface WebFetchDetails {
|
|
|
349
361
|
title?: string;
|
|
350
362
|
extractor: CompleteDocument["extractor"];
|
|
351
363
|
contentKind: ContentKind;
|
|
364
|
+
/** @deprecated Use extractionDiagnostics for explicit quality evidence. */
|
|
352
365
|
shellSuspected: boolean;
|
|
366
|
+
extractionDiagnostics?: CompleteDocument["extractionDiagnostics"];
|
|
367
|
+
links?: CompleteDocument["links"];
|
|
353
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;
|
|
354
373
|
/** True when the returned content is the site's /llms.txt served instead of the requested page. */
|
|
355
374
|
llmsTxtFallback: boolean;
|
|
356
375
|
/** True when the returned content is the page's advertised Markdown version served instead of a low-quality page. */
|
|
357
376
|
markdownAlternateFallback: boolean;
|
|
358
377
|
/** The site's /llms.txt index URL when one exists and the returned content is not that index itself. */
|
|
359
378
|
llmsTxtUrl?: string;
|
|
379
|
+
/** True for a fresh cache hit or a successful 304 revalidation. */
|
|
360
380
|
cached: boolean;
|
|
381
|
+
cacheStatus: CacheStatus;
|
|
361
382
|
truncated: boolean;
|
|
362
383
|
offset: number;
|
|
363
384
|
nextOffset?: number;
|
|
@@ -385,7 +406,11 @@ const fetchCache = new ExpiringLruCache<string, CompleteDocument>(
|
|
|
385
406
|
undefined,
|
|
386
407
|
fetchCachePersistence,
|
|
387
408
|
);
|
|
388
|
-
|
|
409
|
+
interface FetchAcquisition {
|
|
410
|
+
document: CompleteDocument;
|
|
411
|
+
cacheStatus: Exclude<CacheStatus, "hit">;
|
|
412
|
+
}
|
|
413
|
+
const inflightFetches = new InflightCoalescer<string, FetchAcquisition>(MAX_INFLIGHT_REQUESTS);
|
|
389
414
|
|
|
390
415
|
/**
|
|
391
416
|
* Fetches a web page and returns formatted content with pagination and truncation metadata.
|
|
@@ -417,34 +442,66 @@ export async function executeWebFetch(
|
|
|
417
442
|
`web_fetch maxCharacters must be an integer between ${FETCH_MIN_MAX_CHARACTERS} and ${FETCH_MAX_CHARACTERS}.`,
|
|
418
443
|
);
|
|
419
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
|
+
}
|
|
420
453
|
let document = fetchCache.get(params.url);
|
|
421
|
-
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";
|
|
422
459
|
onUpdate?.({
|
|
423
460
|
content: [
|
|
424
461
|
{
|
|
425
462
|
type: "text",
|
|
426
|
-
text:
|
|
463
|
+
text: fresh
|
|
464
|
+
? `Using cached content for ${params.url}…`
|
|
465
|
+
: document
|
|
466
|
+
? `Revalidating cached content for ${params.url}…`
|
|
467
|
+
: `Fetching ${params.url}…`,
|
|
427
468
|
},
|
|
428
469
|
],
|
|
429
470
|
details: {},
|
|
430
471
|
});
|
|
431
|
-
if (!
|
|
432
|
-
|
|
472
|
+
if (!fresh) {
|
|
473
|
+
const stale = document;
|
|
474
|
+
const acquisition = await inflightFetches.run(
|
|
433
475
|
params.url,
|
|
434
476
|
async (sharedSignal) => {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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 };
|
|
442
493
|
},
|
|
443
494
|
signal,
|
|
444
495
|
"web_fetch was cancelled.",
|
|
445
496
|
);
|
|
497
|
+
document = acquisition.document;
|
|
498
|
+
cacheStatus = acquisition.cacheStatus;
|
|
446
499
|
}
|
|
447
|
-
|
|
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);
|
|
448
505
|
const requestedUrl = params.url;
|
|
449
506
|
const finalUrl = result.url;
|
|
450
507
|
const shellSuspected = result.shellSuspected;
|
|
@@ -471,8 +528,16 @@ export async function executeWebFetch(
|
|
|
471
528
|
"",
|
|
472
529
|
]
|
|
473
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
|
+
: []),
|
|
474
539
|
`<untrusted_web_content source=${JSON.stringify(result.url)}>`,
|
|
475
|
-
result.markdown || "[The page contained no readable text.]",
|
|
540
|
+
result.markdown || (focused ? "" : "[The page contained no readable text.]"),
|
|
476
541
|
"</untrusted_web_content>",
|
|
477
542
|
].join("\n");
|
|
478
543
|
const outputTruncation = truncateHead(output, {
|
|
@@ -491,11 +556,16 @@ export async function executeWebFetch(
|
|
|
491
556
|
extractor: result.extractor,
|
|
492
557
|
contentKind,
|
|
493
558
|
shellSuspected,
|
|
559
|
+
extractionDiagnostics: result.extractionDiagnostics,
|
|
560
|
+
links: result.links,
|
|
494
561
|
confidence,
|
|
562
|
+
outline: createDocumentOutline(document.markdown),
|
|
563
|
+
focus: focused?.details,
|
|
495
564
|
llmsTxtFallback: Boolean(result.llmsTxtFallback),
|
|
496
565
|
markdownAlternateFallback: Boolean(result.markdownAlternateFallback),
|
|
497
566
|
llmsTxtUrl: result.llmsTxtIndexUrl,
|
|
498
|
-
cached,
|
|
567
|
+
cached: cacheStatus !== "miss",
|
|
568
|
+
cacheStatus,
|
|
499
569
|
truncated,
|
|
500
570
|
offset: result.offset,
|
|
501
571
|
nextOffset: result.nextOffset,
|