@zeldrisho/pi-web-fetch 0.14.1 → 0.15.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,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0] - 2026-10-28
11
+
12
+ ### Added
13
+
14
+ - Return minified JavaScript, JSON, and YAML responses as indented, fenced code blocks.
15
+ - Identify OpenAPI and Swagger JSON and YAML documents and JavaScript bundles containing literal specs as `openapi-spec`.
16
+
17
+ ### Changed
18
+
19
+ - Focus large fenced source documents using line-aligned excerpts.
20
+ - Shorten documentation index notices.
21
+ - Probe one more ancestor directory for `/llms.txt`, so locale-prefixed documentation sites resolve to their section index.
22
+
23
+ ### Fixed
24
+
25
+ - Report AWS WAF challenge responses as blocks instead of empty pages.
26
+ - Fetch Markdown documentation that static hosts serve as `application/octet-stream`.
27
+ - Fall back to a discovered documentation index when a bot challenge withholds the requested page.
28
+
10
29
  ## [0.14.1] - 2026-10-25
11
30
 
12
31
  ### Changed
@@ -270,7 +289,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
270
289
 
271
290
  - Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
272
291
 
273
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.1...HEAD
292
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.15.0...HEAD
293
+ [0.15.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.1...pi-web-fetch-v0.15.0
274
294
  [0.14.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.0...pi-web-fetch-v0.14.1
275
295
  [0.14.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.13.0...pi-web-fetch-v0.14.0
276
296
  [0.13.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.12.0...pi-web-fetch-v0.13.0
package/README.md CHANGED
@@ -15,7 +15,7 @@ Use `pi config` to enable or disable the extension, and `pi list` to see configu
15
15
 
16
16
  ## Usage
17
17
 
18
- The `web_fetch` tool accepts public HTTP and HTTPS URLs and supports HTML, Markdown, plain text, JSON, and XML. HTML is converted to Markdown; images are omitted. URLs with credentials, local or private network targets, unsafe redirects, downloads over 5 MiB, and unsupported content types are rejected.
18
+ The `web_fetch` tool accepts public HTTP and HTTPS URLs and supports HTML, Markdown, plain text, JSON, and XML. HTML is converted to Markdown; images are omitted. Minified JavaScript, JSON, and YAML are returned as indented code blocks so long single-line payloads stay readable. URLs with credentials, local or private network targets, unsafe redirects, downloads over 5 MiB, and unsupported content types are rejected.
19
19
 
20
20
  | Parameter | Description |
21
21
  | --------- | --------------------------------------------------------------------------------------------------------- |
@@ -23,13 +23,13 @@ The `web_fetch` tool accepts public HTTP and HTTPS URLs and supports HTML, Markd
23
23
  | `query` | Optional; selects matching sections from a long page. Sections remain in source order. |
24
24
  | `offset` | Optional character offset for continuing through extracted content; takes precedence over a URL fragment. |
25
25
 
26
- Output is bounded to 6,000 characters by default. For long pages, use the returned `nextOffset` to continue. A query selects a focused view of the fetched page; omit it to read the complete document. Pi's preview renders Markdown with the active theme and is collapsed by default (`Ctrl+O` expands it). The call preview shows the URL; the focus query and character offset are not repeated there.
26
+ Output is bounded to 6,000 characters by default. For long pages, use the returned `nextOffset` to continue. A query selects a focused view of the fetched page; omit it to read the complete document. Large fenced source files can be focused in line-aligned excerpts; excerpts are not necessarily valid standalone source. OpenAPI and Swagger JSON or YAML documents and JavaScript bundles containing literal specs can be reported as `openapi-spec`; detection is best-effort and limited to source bodies up to 512 KiB. Pi's preview renders Markdown with the active theme and is collapsed by default (`Ctrl+O` expands it). The call preview shows the URL; the focus query and character offset are not repeated there.
27
27
 
28
28
  For example, a URL such as `https://github.com/shadcn-ui/lint#get-started` starts at the matching heading. GitHub `blob` links and bare gists are converted to raw source URLs; repository roots use their README. GitHub directory listings are not supported.
29
29
 
30
30
  ### Page alternatives and bot challenges
31
31
 
32
- The tool can discover advertised Markdown versions and `/llms.txt` indexes. When an index is discovered, its URL is shown in the result so you can follow it if the fetched page is insufficient; when the index replaces the requested page, the result explains that fallback. Anubis proof-of-work challenges are supported; unsupported challenges and JavaScript CAPTCHAs return a blocked result. There is no browser fallback. Challenge cookies are not reused between fetches.
32
+ The tool can discover advertised Markdown versions and `/llms.txt` indexes. When an index is discovered, its URL is shown in the result so you can follow it if the fetched page is insufficient; when the index replaces the requested page, the result explains that fallback. Markdown files served with a generic binary content type are read as text. Anubis proof-of-work challenges are supported, and a page withheld by an AWS WAF or Cloudflare bot challenge falls back to the site's documentation index when one exists. Other unsupported challenges and JavaScript CAPTCHAs return a blocked result. There is no browser fallback. Challenge cookies are not reused between fetches.
33
33
 
34
34
  Pages with HTML nested beyond 512 elements are rejected.
35
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-web-fetch",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
5
5
  "keywords": [
6
6
  "pi",
@@ -30,8 +30,11 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
+ "acorn": "^8.19.0",
34
+ "acorn-walk": "^8.3.5",
33
35
  "defuddle": "^0.19.5",
34
- "linkedom": "^0.18.13"
36
+ "linkedom": "^0.18.13",
37
+ "yaml": "^2.9.1"
35
38
  },
36
39
  "devDependencies": {
37
40
  "@earendil-works/pi-coding-agent": "^1.4.0",
@@ -27,6 +27,8 @@ import {
27
27
  } from "../document/evidence.ts";
28
28
  import { extractHtmlToMarkdown } from "../document/extract.ts";
29
29
  import { assertConversionDepth } from "../document/complexity.ts";
30
+ import { formatMinifiedSource } from "../document/source-format.ts";
31
+ import { isOpenapiSource } from "../document/openapi.ts";
30
32
  import {
31
33
  requestFollowingRedirects,
32
34
  type RedirectDependencies,
@@ -154,17 +156,47 @@ function isHtmlContentType(
154
156
  return value === "text/html" || value === "application/xhtml+xml";
155
157
  }
156
158
 
159
+ /**
160
+ * Path suffixes whose content is read as plain text when a server labels it
161
+ * `application/octet-stream`. Static hosts commonly serve Markdown documentation
162
+ * with that generic binary type; restricting the exception to these suffixes keeps
163
+ * real binary downloads out of extraction.
164
+ */
165
+ const TEXT_DOCUMENT_PATH_PATTERN = /\.(?:md|markdown|mdx|txt)$/i;
166
+
157
167
  /** Narrows a media type to those web_fetch is willing to read. */
158
- function isAllowedContentType(value: string | undefined): value is string {
159
- return (
160
- value !== undefined && (value.startsWith("text/") || ALLOWED_CONTENT_TYPES.includes(value))
161
- );
168
+ function isAllowedContentType(value: string | undefined, pathname: string): value is string {
169
+ if (value === undefined) return false;
170
+
171
+ if (value.startsWith("text/") || ALLOWED_CONTENT_TYPES.includes(value)) return true;
172
+
173
+ return value === "application/octet-stream" && TEXT_DOCUMENT_PATH_PATTERN.test(pathname);
174
+ }
175
+
176
+ /**
177
+ * Identifies a page a bot-protection layer refused to serve.
178
+ *
179
+ * The response carries no page content, so callers may substitute a site index the
180
+ * same page would otherwise have been extracted from.
181
+ */
182
+ export class ChallengeBlockedError extends Error {
183
+ constructor(message: string) {
184
+ super(message);
185
+ this.name = "ChallengeBlockedError";
186
+ }
162
187
  }
163
188
 
164
189
  /** Throws the established error for a response whose status is not successful. */
165
190
  function assertSuccessfulResponse(response: IncomingMessage): void {
166
191
  const status = response.statusCode ?? 0;
167
192
 
193
+ if (responseHeader(response, "x-amzn-waf-action")?.trim().toLowerCase() === "challenge") {
194
+ response.resume();
195
+ throw new ChallengeBlockedError(
196
+ `web_fetch was blocked by an AWS WAF anti-bot challenge (HTTP ${status}).`,
197
+ );
198
+ }
199
+
168
200
  if (status >= 200 && status < 300) return;
169
201
 
170
202
  const cloudflareChallenge =
@@ -173,7 +205,9 @@ function assertSuccessfulResponse(response: IncomingMessage): void {
173
205
  response.resume();
174
206
 
175
207
  if (cloudflareChallenge) {
176
- throw new Error(`web_fetch was blocked by Cloudflare's anti-bot challenge (HTTP ${status}).`);
208
+ throw new ChallengeBlockedError(
209
+ `web_fetch was blocked by Cloudflare's anti-bot challenge (HTTP ${status}).`,
210
+ );
177
211
  }
178
212
 
179
213
  const authenticationHint = [401, 403, 404].includes(status)
@@ -243,6 +277,8 @@ async function documentFromDecoded(
243
277
  let extractor: CompleteDocument["extractor"] = "raw";
244
278
  let describedBy: string | undefined = linkHints.describedBy;
245
279
  let markdownAlternateUrl: string | undefined = linkHints.markdownAlternate;
280
+ let sourceLanguage: string | undefined;
281
+ const openapiSpec = isOpenapiSource(raw, contentType);
246
282
 
247
283
  if (isHtmlContentType(contentType)) {
248
284
  const extracted = await awaitWithAbort(extractHtml(raw, target.url), signal);
@@ -254,12 +290,23 @@ async function documentFromDecoded(
254
290
  markdownAlternateUrl = markdownAlternateUrl ?? extracted.markdownAlternateLink;
255
291
  } else if (contentType === "application/json") {
256
292
  try {
257
- markdown = `\`\`\`json\n${JSON.stringify(JSON.parse(raw), null, 2)}\n\`\`\``;
293
+ const parsed: unknown = JSON.parse(raw);
294
+
295
+ markdown = `\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``;
296
+ sourceLanguage = "json";
258
297
  } catch {
259
- markdown = raw;
298
+ // Minified or JSON-like bodies are still worth structuring even when they do
299
+ // not parse as strict JSON.
300
+ const formatted = formatMinifiedSource(raw, contentType);
301
+
302
+ markdown = formatted?.markdown ?? raw;
303
+ sourceLanguage = formatted?.language;
260
304
  }
261
305
  } else {
262
- markdown = raw;
306
+ const formatted = formatMinifiedSource(raw, contentType);
307
+
308
+ markdown = formatted?.markdown ?? raw;
309
+ sourceLanguage = formatted?.language;
263
310
 
264
311
  const markdownContent = /\bmarkdown\b|\.md(?:$|[?#])/i.test(
265
312
  contentTypeHeader + target.url.pathname,
@@ -306,6 +353,8 @@ async function documentFromDecoded(
306
353
  cachedAt: Date.now(),
307
354
  llmsTxtDescribedBy: describedBy,
308
355
  markdownAlternateUrl,
356
+ sourceLanguage,
357
+ openapiSpec,
309
358
  };
310
359
  }
311
360
 
@@ -334,7 +383,7 @@ async function documentFromResponse(
334
383
  const contentTypeHeader = responseContentTypeHeader(response);
335
384
  const contentType = normalizeContentType(contentTypeHeader);
336
385
 
337
- if (!isAllowedContentType(contentType)) {
386
+ if (!isAllowedContentType(contentType, target.url.pathname)) {
338
387
  response.destroy();
339
388
  throw new Error(
340
389
  `web_fetch does not support ${contentType ?? (contentTypeHeader || "this content type")}.`,
@@ -368,6 +417,7 @@ async function documentFromAcquisition(
368
417
  dependencies: FetchRemoteDependencies,
369
418
  allowSolve: boolean,
370
419
  ): Promise<CompleteDocument> {
420
+ assertSuccessfulResponse(response);
371
421
  const status = response.statusCode ?? 0;
372
422
  const contentTypeHeader = responseContentTypeHeader(response);
373
423
  const contentType = normalizeContentType(contentTypeHeader);
@@ -458,6 +508,7 @@ async function fetchDocument(
458
508
  dependencies: FetchRemoteDependencies,
459
509
  cached?: CompleteDocument,
460
510
  allowEmptyContent = false,
511
+ retryRepresentationMiss = true,
461
512
  ): Promise<{ document: CompleteDocument; revalidated: boolean }> {
462
513
  assertAbsoluteHttpUrlForFetch(normalizeGitHubRawUrl(rawUrl));
463
514
  const controller = new AbortController();
@@ -499,8 +550,10 @@ async function fetchDocument(
499
550
  // Some servers route Markdown negotiation to a missing representation even
500
551
  // though their HTML page exists (for example, Redis documentation URLs).
501
552
  // Retry only these representation-related statuses, through the same
502
- // validated redirect/network boundary, with HTML preferred.
503
- if ([404, 406].includes(response.statusCode ?? 0)) {
553
+ // validated redirect/network boundary, with HTML preferred. Callers that probe
554
+ // for one exact representation opt out: HTML from that URL is never the
555
+ // representation they are looking for.
556
+ if (retryRepresentationMiss && [404, 406].includes(response.statusCode ?? 0)) {
504
557
  response.resume();
505
558
  ({ target, response } = await requestFollowingRedirects(
506
559
  target.url,
@@ -566,13 +619,30 @@ async function fetchDocument(
566
619
  }
567
620
  }
568
621
 
622
+ /**
623
+ * Fetches a complete document for one URL.
624
+ *
625
+ * @param allowEmptyContent - Whether an empty body is acceptable
626
+ * @param retryRepresentationMiss - Whether a 404/406 may be retried as HTML. Probe
627
+ * callers that want one exact representation pass false.
628
+ */
569
629
  export async function fetchCompleteDocument(
570
630
  rawUrl: string,
571
631
  signal: AbortSignal | undefined,
572
632
  dependencies: FetchRemoteDependencies,
573
633
  allowEmptyContent = false,
634
+ retryRepresentationMiss = true,
574
635
  ): Promise<CompleteDocument> {
575
- return (await fetchDocument(rawUrl, signal, dependencies, undefined, allowEmptyContent)).document;
636
+ return (
637
+ await fetchDocument(
638
+ rawUrl,
639
+ signal,
640
+ dependencies,
641
+ undefined,
642
+ allowEmptyContent,
643
+ retryRepresentationMiss,
644
+ )
645
+ ).document;
576
646
  }
577
647
 
578
648
  /** Revalidates a stale representation with its ETag and Last-Modified validators. */
@@ -1,6 +1,10 @@
1
1
  import { ExpiringLruCache } from "../cache";
2
2
  import type { CompleteDocument } from "../document/content.ts";
3
- import { fetchCompleteDocument, type FetchRemoteDependencies } from "./fetch.ts";
3
+ import {
4
+ ChallengeBlockedError,
5
+ fetchCompleteDocument,
6
+ type FetchRemoteDependencies,
7
+ } from "./fetch.ts";
4
8
  import { InflightCoalescer } from "../inflight";
5
9
 
6
10
  const CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
@@ -97,8 +101,13 @@ interface LlmsTxtProbe {
97
101
 
98
102
  const MAX_LLMS_TXT_PROBE_ENTRIES = 512;
99
103
 
100
- /** Deepest ancestor-directory `/llms.txt` probed, e.g. `/r2/x/y` probes `/r2/llms.txt`. */
101
- const MAX_LLMS_TXT_DIRECTORY_DEPTH = 1;
104
+ /**
105
+ * Deepest ancestor-directory `/llms.txt` probed, e.g. `/a/b/c` also probes
106
+ * `/a/b/llms.txt`. Locale-prefixed documentation sites publish their section index
107
+ * one level below the locale root, so two levels are needed to reach
108
+ * `/en/docs/llms.txt` from `/en/docs/catalog/...`.
109
+ */
110
+ const MAX_LLMS_TXT_DIRECTORY_DEPTH = 2;
102
111
 
103
112
  const llmsTxtProbes = new ExpiringLruCache<string, LlmsTxtProbe>(
104
113
  MAX_LLMS_TXT_PROBE_ENTRIES,
@@ -179,10 +188,16 @@ async function probeUsableRawText(
179
188
  if (existing) return existing;
180
189
 
181
190
  try {
182
- const document = await fetchCompleteDocument(candidate.toString(), sharedSignal, {
183
- ...dependencies,
184
- solveAnubisChallenges: false,
185
- });
191
+ const document = await fetchCompleteDocument(
192
+ candidate.toString(),
193
+ sharedSignal,
194
+ { ...dependencies, solveAnubisChallenges: false },
195
+ // An index probe never accepts an empty body.
196
+ false,
197
+ // A probe wants exactly this representation; retrying as HTML only burns
198
+ // a request on a URL that is already known to be missing.
199
+ false,
200
+ );
186
201
 
187
202
  const available = isUsableLlmsTxtIndex(document) ? document : undefined;
188
203
  const result = { document: available, expires: Date.now() + CACHE_TTL_MS };
@@ -226,8 +241,8 @@ function isSafeToProbe(candidate: URL | undefined, primaryUrl: string): boolean
226
241
  *
227
242
  * - Probes the site's `/llms.txt` index once per origin per TTL window, in parallel with
228
243
  * the primary fetch, so later calls can advertise it.
229
- * - Serves the index instead of the page when the page looks like an app shell or carries
230
- * too little readable text.
244
+ * - Serves the index instead of the page when the page looks like an app shell, carries
245
+ * too little readable text, or was withheld by a bot-protection challenge.
231
246
  * - Otherwise annotates the page with the index URL so agents can discover sibling pages.
232
247
  *
233
248
  * A missing or useless index can never degrade the primary fetch.
@@ -247,16 +262,32 @@ export async function fetchDocumentWithLlmsTxtSupport(
247
262
 
248
263
  const candidates = buildLlmsTxtCandidateUrls(rawUrl);
249
264
 
250
- const [primary, blindIndex] = await Promise.all([
265
+ // A withheld page must not discard an index that is still usable, so the pair is
266
+ // settled instead of rejected together.
267
+ const [primarySettled, indexSettled] = await Promise.allSettled([
251
268
  fetchCompleteDocument(rawUrl, signal, dependencies, true),
252
269
  candidates.length > 0
253
270
  ? ensureLlmsTxtIndex(candidates, signal, dependencies)
254
271
  : Promise.resolve(undefined),
255
272
  ]);
256
273
 
274
+ // An index probe only rejects when the caller cancels, which must still surface.
275
+ if (indexSettled.status === "rejected") throw indexSettled.reason;
276
+
277
+ if (primarySettled.status === "rejected") {
278
+ // A bot-protection layer replaced the page with a challenge that carries no
279
+ // content; the discovered index is the only remaining pointer to that content.
280
+ if (indexSettled.value && primarySettled.reason instanceof ChallengeBlockedError)
281
+ return { ...indexSettled.value.document, llmsTxtFallback: true };
282
+
283
+ throw primarySettled.reason;
284
+ }
285
+
286
+ const primary = primarySettled.value;
287
+
257
288
  // A `describedby` advertisement names the covering index authoritatively per
258
289
  // llmstxt.org v2, so it outranks the blind root/section probes whenever usable.
259
- let index = blindIndex;
290
+ let index = indexSettled.value;
260
291
 
261
292
  if (primary.llmsTxtDescribedBy) {
262
293
  const describedBy = toAbsoluteUrl(primary.llmsTxtDescribedBy);
@@ -33,6 +33,10 @@ export interface CompleteDocument {
33
33
  cachedAt?: number;
34
34
  /** True when this document is the site's /llms.txt served instead of a low-quality page. */
35
35
  llmsTxtFallback?: boolean;
36
+ /** Code-fence language when the body was returned as reformatted source. */
37
+ sourceLanguage?: string;
38
+ /** Whether the response is a standalone OpenAPI or Swagger document. */
39
+ openapiSpec?: boolean;
36
40
  /** Set when the site publishes a usable /llms.txt index and this document is not that index. */
37
41
  llmsTxtIndexUrl?: string;
38
42
  /** Absolute URL advertised via `rel="describedby"` (header or HTML link) for this page. */
@@ -22,27 +22,130 @@ interface Section {
22
22
  tokens: string[];
23
23
  }
24
24
 
25
+ interface SplitSections {
26
+ sections: Section[];
27
+ /** Separator that rejoins selected sections; windowed source stays contiguous. */
28
+ separator: "\n" | "\n\n";
29
+ }
30
+
31
+ /** Target size of one section when a block has no headings and no blank lines. */
32
+ const WINDOW_TARGET_CHARACTERS = 8_000;
33
+
34
+ /** Fewest lines a headingless block needs before it is split into windows. */
35
+ const WINDOW_MIN_LINES = 80;
36
+
37
+ /** Fewest characters a headingless block needs before it is split into windows. */
38
+ const WINDOW_MIN_CHARACTERS = 12_000;
39
+
25
40
  function tokens(value: string): string[] {
26
41
  return (value.toLowerCase().match(WORD_PATTERN) ?? []).filter((token) => token.length > 1);
27
42
  }
28
43
 
44
+ /**
45
+ * Splits one headingless, blank-line-free block into line-aligned windows.
46
+ *
47
+ * Formatted source and log files have neither headings nor blank lines, so treating the
48
+ * whole body as a single section would make focus select everything. Windows are bounded
49
+ * by characters but never cut a line, and short blocks keep their single section.
50
+ */
51
+ function splitIntoWindows(block: string): string[] {
52
+ const lines = block.split(/\r?\n/);
53
+
54
+ if (lines.length < WINDOW_MIN_LINES && block.length < WINDOW_MIN_CHARACTERS) return [block];
55
+
56
+ const windows: string[] = [];
57
+ let current: string[] = [];
58
+ let size = 0;
59
+
60
+ for (const line of lines) {
61
+ if (size > 0 && size + line.length + 1 > WINDOW_TARGET_CHARACTERS) {
62
+ windows.push(current.join("\n"));
63
+ current = [];
64
+ size = 0;
65
+ }
66
+
67
+ current.push(line);
68
+ size += line.length + 1;
69
+ }
70
+
71
+ const last = current.join("\n");
72
+
73
+ if (last) windows.push(last);
74
+
75
+ return windows.filter(Boolean);
76
+ }
77
+
29
78
  /**
30
79
  * Splits Markdown into sections. Headings stay attached to their following content; a
31
- * headingless document falls back to paragraphs so focused extraction remains useful for
32
- * plain-text and basic-extractor responses.
80
+ * headingless document falls back to paragraphs, and a headingless block without blank
81
+ * lines falls back to line windows so focused extraction remains useful for plain-text,
82
+ * log, and reformatted-source responses.
33
83
  */
34
- function splitSections(markdown: string): Section[] {
84
+ function splitSections(markdown: string): SplitSections {
85
+ // Window a standalone fenced source body without leaving partial Markdown fences.
86
+ const fenced = /^( {0,3})(`{3,}|~{3,})([^\n\r]*)\r?\n([\s\S]*?)\r?\n\1\2\s*$/.exec(markdown);
87
+
88
+ const hasInteriorClose = fenced?.[4].split(/\r?\n/).some((line) => {
89
+ const close = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
90
+
91
+ return close && close[1][0] === fenced[2][0] && close[1].length >= fenced[2].length;
92
+ });
93
+
94
+ if (fenced && !hasInteriorClose) {
95
+ const windows = splitIntoWindows(fenced[4]);
96
+
97
+ if (windows.length > 1) {
98
+ return {
99
+ sections: windows.map((window) => ({
100
+ markdown: `${fenced[1]}${fenced[2]}${fenced[3]}\n${window}\n${fenced[1]}${fenced[2]}`,
101
+ tokens: tokens(window),
102
+ })),
103
+ separator: "\n\n",
104
+ };
105
+ }
106
+
107
+ return { sections: [{ markdown, tokens: tokens(fenced[4]) }], separator: "\n\n" };
108
+ }
109
+
35
110
  const blocks = markdown
36
111
  .split(/\n{2,}/)
37
112
  .map((block) => block.trim())
38
113
  .filter(Boolean);
39
114
 
40
- if (blocks.length === 0) return [];
115
+ if (blocks.length === 0) return { sections: [], separator: "\n\n" };
41
116
 
42
117
  const lines = markdown.split(/\r?\n/);
43
- const hasHeadings = lines.some((line) => HEADING_PATTERN.test(line));
118
+ let fence: { marker: string; length: number } | undefined;
119
+
120
+ const headingLines = lines.map((line) => {
121
+ const match = /^[ \t]{0,3}(`{3,}|~{3,})(.*)$/.exec(line);
122
+
123
+ if (match) {
124
+ if (!fence) fence = { marker: match[1][0], length: match[1].length };
125
+ else if (match[1][0] === fence.marker && match[1].length >= fence.length && !match[2].trim())
126
+ fence = undefined;
127
+
128
+ return false;
129
+ }
130
+
131
+ return !fence && HEADING_PATTERN.test(line);
132
+ });
133
+
134
+ const hasHeadings = headingLines.some(Boolean);
135
+
136
+ if (!hasHeadings) {
137
+ // Keep mixed fenced Markdown intact rather than splitting a fence at blank lines.
138
+ if (lines.some((line) => /^[ \t]{0,3}(`{3,}|~{3,})/.test(line))) {
139
+ return { sections: [{ markdown, tokens: tokens(markdown) }], separator: "\n\n" };
140
+ }
44
141
 
45
- if (!hasHeadings) return blocks.map((block) => ({ markdown: block, tokens: tokens(block) }));
142
+ const windowed = blocks.flatMap(splitIntoWindows);
143
+
144
+ return {
145
+ sections: windowed.map((block) => ({ markdown: block, tokens: tokens(block) })),
146
+ separator: blocks.length === 1 && windowed.length > 1 ? "\n" : "\n\n",
147
+ };
148
+ }
46
149
 
47
150
  const sections: string[] = [];
48
151
  let current: string[] = [];
@@ -54,14 +157,17 @@ function splitSections(markdown: string): Section[] {
54
157
  current = [];
55
158
  };
56
159
 
57
- for (const line of lines) {
58
- if (HEADING_PATTERN.test(line)) flush();
160
+ for (const [index, line] of lines.entries()) {
161
+ if (headingLines[index]) flush();
59
162
  current.push(line);
60
163
  }
61
164
 
62
165
  flush();
63
166
 
64
- return sections.map((section) => ({ markdown: section, tokens: tokens(section) }));
167
+ return {
168
+ sections: sections.map((section) => ({ markdown: section, tokens: tokens(section) })),
169
+ separator: "\n\n",
170
+ };
65
171
  }
66
172
 
67
173
  /**
@@ -72,7 +178,7 @@ function splitSections(markdown: string): Section[] {
72
178
  export function focusMarkdown(markdown: string, query: string): FocusResult {
73
179
  const normalizedQuery = query.trim();
74
180
  const queryTerms = [...new Set(tokens(normalizedQuery))];
75
- const sections = splitSections(markdown);
181
+ const { sections, separator } = splitSections(markdown);
76
182
  const originalCharacters = markdown.length;
77
183
 
78
184
  if (queryTerms.length === 0 || sections.length === 0) {
@@ -126,7 +232,7 @@ export function focusMarkdown(markdown: string, query: string): FocusResult {
126
232
  return score > 0;
127
233
  });
128
234
 
129
- const focused = selected.map((section) => section.markdown).join("\n\n");
235
+ const focused = selected.map((section) => section.markdown).join(separator);
130
236
 
131
237
  return {
132
238
  markdown: focused,
@@ -0,0 +1,80 @@
1
+ import { parse } from "acorn";
2
+ import { simple } from "acorn-walk";
3
+ import { parseDocument } from "yaml";
4
+ import { Type } from "typebox";
5
+ import { Check } from "typebox/value";
6
+
7
+ const schema = Type.Union([
8
+ Type.Object({
9
+ openapi: Type.String({ pattern: "^3\\.\\d+\\.\\d+$" }),
10
+ info: Type.Object({}),
11
+ paths: Type.Object({}),
12
+ }),
13
+ Type.Object({ swagger: Type.Literal("2.0"), info: Type.Object({}), paths: Type.Object({}) }),
14
+ ]);
15
+
16
+ // Parsing is synchronous and untrusted. Larger inputs remain readable without classification.
17
+ const MAX_CLASSIFICATION_CHARACTERS = 512 * 1024;
18
+
19
+ /** Conservatively recognizes a document or a literal object embedded in JavaScript. Never executes source. */
20
+ export function isOpenapiSource(raw: string, contentType: string): boolean {
21
+ if (raw.length > MAX_CLASSIFICATION_CHARACTERS) return false;
22
+
23
+ try {
24
+ if (contentType === "application/json") return Check(schema, JSON.parse(raw));
25
+
26
+ if (contentType === "text/yaml") {
27
+ const document = parseDocument(raw, { uniqueKeys: true });
28
+
29
+ return document.errors.length === 0 && Check(schema, document.toJS({ maxAliasCount: 0 }));
30
+ }
31
+
32
+ if (contentType !== "text/javascript") return false;
33
+ const ast = parse(raw, { ecmaVersion: "latest", sourceType: "module" });
34
+ let found = false;
35
+ simple(ast, {
36
+ ObjectExpression(node) {
37
+ let version = false;
38
+ let info = false;
39
+ let paths = false;
40
+
41
+ for (const property of node.properties) {
42
+ if (property.type !== "Property" || property.computed || property.kind !== "init")
43
+ continue;
44
+
45
+ const key =
46
+ property.key.type === "Identifier"
47
+ ? property.key.name
48
+ : property.key.type === "Literal"
49
+ ? property.key.value
50
+ : undefined;
51
+
52
+ if ((key === "openapi" || key === "swagger") && property.value.type === "Literal") {
53
+ const value = property.value.value;
54
+ version ||=
55
+ key === "swagger"
56
+ ? value === "2.0"
57
+ : Check(Type.String({ pattern: "^3\\.\\d+\\.\\d+$" }), value);
58
+ }
59
+
60
+ if (key === "info" && property.value.type === "ObjectExpression") info = true;
61
+
62
+ if (key === "paths" && property.value.type === "ObjectExpression") paths = true;
63
+ }
64
+
65
+ if (version && info && paths) found = true;
66
+ },
67
+ });
68
+
69
+ return found;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /** Reconstructs detection from older cached canonical representations, without a network request. */
76
+ export function isCachedOpenapi(markdown: string, contentType: string): boolean {
77
+ const match = /^```(?:json|js|yaml)\r?\n([\s\S]*)\r?\n```\s*$/.exec(markdown);
78
+
79
+ return isOpenapiSource(match ? match[1] : markdown, contentType);
80
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Source text that is read as code rather than prose, mapped to the code-fence
3
+ * language used when one of these bodies is reformatted.
4
+ */
5
+ const SOURCE_LANGUAGE_BY_CONTENT_TYPE = {
6
+ "application/json": "json",
7
+ "text/javascript": "js",
8
+ "text/yaml": "yaml",
9
+ } as const;
10
+
11
+ type SourceContentType = keyof typeof SOURCE_LANGUAGE_BY_CONTENT_TYPE;
12
+
13
+ /** Narrows a media type to the source types that are reformatted. */
14
+ function isSourceContentType(value: string): value is SourceContentType {
15
+ return Object.hasOwn(SOURCE_LANGUAGE_BY_CONTENT_TYPE, value);
16
+ }
17
+
18
+ /** Shortest body reformatted; below this a page is returned verbatim. */
19
+ const MINIFIED_MIN_CHARACTERS = 2_000;
20
+
21
+ /**
22
+ * Largest body reformatted. Reformatting triples transient memory for a document too
23
+ * large to cache, so bigger bodies keep their original single-line layout.
24
+ */
25
+ const MINIFIED_MAX_CHARACTERS = 2 * 1_024 * 1_024;
26
+
27
+ /**
28
+ * Shortest average line that still counts as machine-generated. Bundled source is
29
+ * packed into very long lines even when it keeps a few real line breaks inside block
30
+ * scalars, while hand-written source keeps short lines.
31
+ */
32
+ const MINIFIED_MIN_AVERAGE_LINE_CHARACTERS = 300;
33
+
34
+ /** Deepest nesting that still receives extra indentation. */
35
+ const MAX_FORMAT_DEPTH = 24;
36
+
37
+ const INDENT = " ";
38
+
39
+ const QUOTE_CHARACTERS = new Set(['"', "'", "`"]);
40
+
41
+ const OPENING_CHARACTERS = new Set(["{", "["]);
42
+
43
+ const CLOSING_CHARACTERS = new Set(["}", "]"]);
44
+
45
+ const LINE_ENDING_CHARACTERS = new Set([",", ";"]);
46
+
47
+ /**
48
+ * Returns whether a body is machine-generated source rather than hand-written code.
49
+ *
50
+ * Only minified bodies are reformatted: a hand-written file already carries the
51
+ * author's line structure, which is more useful than any generated layout.
52
+ */
53
+ function isMinified(value: string): boolean {
54
+ if (value.length < MINIFIED_MIN_CHARACTERS || value.length > MINIFIED_MAX_CHARACTERS)
55
+ return false;
56
+
57
+ let lineBreaks = 0;
58
+
59
+ for (let index = 0; index < value.length; index += 1) {
60
+ if (value[index] === "\n") lineBreaks += 1;
61
+ }
62
+
63
+ return value.length / (lineBreaks + 1) >= MINIFIED_MIN_AVERAGE_LINE_CHARACTERS;
64
+ }
65
+
66
+ /** Returns the fence language for a content type, or undefined when it is not source. */
67
+ export function sourceLanguageFor(contentType: string): string | undefined {
68
+ return isSourceContentType(contentType)
69
+ ? SOURCE_LANGUAGE_BY_CONTENT_TYPE[contentType]
70
+ : undefined;
71
+ }
72
+
73
+ /**
74
+ * Adds line breaks and indentation to minified source without interpreting it.
75
+ *
76
+ * Layout whitespace is normalized and every other character is preserved in order.
77
+ * String literals are tracked only far enough to keep their punctuation from changing
78
+ * the layout, so no value is parsed, evaluated, or re-serialized.
79
+ *
80
+ * @param value - The minified source
81
+ * @returns Indented source, or undefined when the input was not minified
82
+ */
83
+ export function indentMinifiedSource(value: string): string | undefined {
84
+ if (!isMinified(value)) return undefined;
85
+
86
+ const lines: string[] = [];
87
+ let current = "";
88
+ let depth = 0;
89
+ let quote: string | undefined;
90
+ let escaped = false;
91
+
92
+ const indent = (): string => INDENT.repeat(Math.min(depth, MAX_FORMAT_DEPTH));
93
+
94
+ const flush = (): void => {
95
+ const line = current.trimEnd();
96
+
97
+ if (line) lines.push(line);
98
+ current = "";
99
+ };
100
+
101
+ for (const character of value) {
102
+ if (quote !== undefined) {
103
+ current += character;
104
+
105
+ if (escaped) escaped = false;
106
+ else if (character === "\\") escaped = true;
107
+ else if (character === quote) quote = undefined;
108
+
109
+ continue;
110
+ }
111
+
112
+ if (QUOTE_CHARACTERS.has(character)) {
113
+ quote = character;
114
+ current += character;
115
+
116
+ continue;
117
+ }
118
+
119
+ if (OPENING_CHARACTERS.has(character)) {
120
+ current += character;
121
+ flush();
122
+ depth += 1;
123
+ current = indent();
124
+
125
+ continue;
126
+ }
127
+
128
+ if (CLOSING_CHARACTERS.has(character)) {
129
+ flush();
130
+ depth = Math.max(0, depth - 1);
131
+ current = indent() + character;
132
+
133
+ continue;
134
+ }
135
+
136
+ if (LINE_ENDING_CHARACTERS.has(character)) {
137
+ current += character;
138
+ flush();
139
+ current = indent();
140
+
141
+ continue;
142
+ }
143
+
144
+ if (character === "\n") {
145
+ flush();
146
+ current = indent();
147
+
148
+ continue;
149
+ }
150
+
151
+ if (character !== "\r") current += character;
152
+ }
153
+
154
+ flush();
155
+
156
+ return lines.join("\n");
157
+ }
158
+
159
+ /**
160
+ * Reformats minified JavaScript, JSON, or YAML as a fenced code block.
161
+ *
162
+ * @param raw - The decoded response body
163
+ * @param contentType - The normalized response media type
164
+ * @returns A fenced, indented Markdown block, or undefined when the body is not
165
+ * source that benefits from reformatting
166
+ */
167
+ export function formatMinifiedSource(
168
+ raw: string,
169
+ contentType: string,
170
+ ): { markdown: string; language: string } | undefined {
171
+ const language = sourceLanguageFor(contentType);
172
+
173
+ if (language === undefined) return undefined;
174
+
175
+ const indented = indentMinifiedSource(raw);
176
+
177
+ if (indented === undefined) return undefined;
178
+
179
+ return { language, markdown: `\`\`\`${language}\n${indented}\n\`\`\`` };
180
+ }
package/src/service.ts CHANGED
@@ -13,6 +13,7 @@ import { sliceCompleteDocument, type CompleteDocument } from "./document/content
13
13
  import { revalidateCompleteDocument, type FetchRemoteDependencies } from "./acquisition/fetch.ts";
14
14
  import { fetchDocumentWithLlmsTxtSupport } from "./acquisition/llms.ts";
15
15
  import { focusMarkdown, type FocusDetails } from "./document/focus.ts";
16
+ import { isCachedOpenapi } from "./document/openapi.ts";
16
17
  import { resolveFragmentOffset, urlWithoutFragment } from "./document/fragments.ts";
17
18
  import { InflightCoalescer } from "./inflight";
18
19
  import { createDocumentOutline, type DocumentOutline } from "./document/outline.ts";
@@ -43,6 +44,7 @@ const encoder = new TextEncoder();
43
44
  export type ContentKind =
44
45
  | "repository-readme"
45
46
  | "code-file"
47
+ | "openapi-spec"
46
48
  | "directory-listing"
47
49
  | "llms-index"
48
50
  | "article"
@@ -73,14 +75,24 @@ function safeUrl(value: string): URL | undefined {
73
75
  * @param url - The fetched URL
74
76
  * @param extractor - Extraction method used
75
77
  * @param shellSuspected - Whether the page appears to be an app shell
78
+ * @param sourceLanguage - Code-fence language when the body was reformatted as source
76
79
  * @returns Content classification
77
80
  */
78
81
  export function classifyContentKind(
79
82
  url: string,
80
83
  extractor: CompleteDocument["extractor"],
81
84
  shellSuspected: boolean,
85
+ sourceLanguage?: string,
86
+ openapiSpec = false,
82
87
  ): ContentKind {
83
88
  if (shellSuspected) return "markup-shell";
89
+
90
+ if (openapiSpec) return "openapi-spec";
91
+
92
+ // A reformatted body is code whatever host served it: the response was read as
93
+ // source, not as page prose.
94
+ if (sourceLanguage !== undefined) return "code-file";
95
+
84
96
  const parsed = safeUrl(url);
85
97
  const host = parsed?.hostname ?? "";
86
98
  const path = parsed?.pathname ?? "";
@@ -423,6 +435,7 @@ export async function executeWebFetch(
423
435
 
424
436
  const fresh = Boolean(
425
437
  document &&
438
+ !document.llmsTxtFallback &&
426
439
  !document.extractionDiagnostics?.botWall &&
427
440
  (document.cachedAt === undefined || now - document.cachedAt < CACHE_TTL_MS),
428
441
  );
@@ -576,7 +589,15 @@ export async function executeWebFetch(
576
589
  const rawFinalUrl = result.url;
577
590
  const finalUrl = redactUrlForDisplay(rawFinalUrl);
578
591
  const shellSuspected = result.shellSuspected;
579
- const contentKind = classifyContentKind(rawFinalUrl, result.extractor, shellSuspected);
592
+
593
+ const contentKind = classifyContentKind(
594
+ rawFinalUrl,
595
+ result.extractor,
596
+ shellSuspected,
597
+ result.sourceLanguage,
598
+ document.openapiSpec ?? isCachedOpenapi(document.markdown, document.contentType),
599
+ );
600
+
580
601
  const confidence = classifyConfidence(result.extractor, shellSuspected, result.markdown.length);
581
602
 
582
603
  const requestedPath = safeUrl(params.url)?.pathname.replace(/\/+$/u, "").toLowerCase();
@@ -603,10 +624,7 @@ export async function executeWebFetch(
603
624
  ]
604
625
  : []),
605
626
  ...(showLlmsNotice && result.llmsTxtIndexUrl
606
- ? [
607
- `Documentation index available: ${redactUrlForDisplay(result.llmsTxtIndexUrl)}. Use it if this page does not cover the topic.`,
608
- "",
609
- ]
627
+ ? [`Index: ${redactUrlForDisplay(result.llmsTxtIndexUrl)}`, ""]
610
628
  : []),
611
629
  ...(resolvedFragment.fragment !== undefined && !fragmentMatched
612
630
  ? [