@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/src/focus.ts ADDED
@@ -0,0 +1,125 @@
1
+ const WORD_PATTERN = /[\p{L}\p{N}_+#]+/gu;
2
+ const HEADING_PATTERN = /^#{1,6}\s+\S/;
3
+
4
+ /** Metadata describing deterministic query-focused extraction. */
5
+ export interface FocusDetails {
6
+ query: string;
7
+ matchedSections: number;
8
+ totalSections: number;
9
+ omittedSections: number;
10
+ originalCharacters: number;
11
+ focusedCharacters: number;
12
+ }
13
+
14
+ export interface FocusResult {
15
+ markdown: string;
16
+ details: FocusDetails;
17
+ }
18
+
19
+ interface Section {
20
+ markdown: string;
21
+ tokens: string[];
22
+ }
23
+
24
+ function tokens(value: string): string[] {
25
+ return (value.toLowerCase().match(WORD_PATTERN) ?? []).filter((token) => token.length > 1);
26
+ }
27
+
28
+ /**
29
+ * Splits Markdown into sections. Headings stay attached to their following content; a
30
+ * headingless document falls back to paragraphs so focused extraction remains useful for
31
+ * plain-text and basic-extractor responses.
32
+ */
33
+ function splitSections(markdown: string): Section[] {
34
+ const blocks = markdown
35
+ .split(/\n{2,}/)
36
+ .map((block) => block.trim())
37
+ .filter(Boolean);
38
+ if (blocks.length === 0) return [];
39
+
40
+ const lines = markdown.split(/\r?\n/);
41
+ const hasHeadings = lines.some((line) => HEADING_PATTERN.test(line));
42
+ if (!hasHeadings) return blocks.map((block) => ({ markdown: block, tokens: tokens(block) }));
43
+
44
+ const sections: string[] = [];
45
+ let current: string[] = [];
46
+ const flush = () => {
47
+ const section = current.join("\n").trim();
48
+ if (section) sections.push(section);
49
+ current = [];
50
+ };
51
+ for (const line of lines) {
52
+ if (HEADING_PATTERN.test(line)) flush();
53
+ current.push(line);
54
+ }
55
+ flush();
56
+ return sections.map((section) => ({ markdown: section, tokens: tokens(section) }));
57
+ }
58
+
59
+ /**
60
+ * Produces a deterministic, query-focused view of Markdown using a compact BM25-style
61
+ * section ranker. Matching sections retain source order so the result remains readable
62
+ * and attributable to the fetched page. The complete document is never mutated.
63
+ */
64
+ export function focusMarkdown(markdown: string, query: string): FocusResult {
65
+ const normalizedQuery = query.trim();
66
+ const queryTerms = [...new Set(tokens(normalizedQuery))];
67
+ const sections = splitSections(markdown);
68
+ const originalCharacters = markdown.length;
69
+
70
+ if (queryTerms.length === 0 || sections.length === 0) {
71
+ return {
72
+ markdown: "",
73
+ details: {
74
+ query: normalizedQuery,
75
+ matchedSections: 0,
76
+ totalSections: sections.length,
77
+ omittedSections: sections.length,
78
+ originalCharacters,
79
+ focusedCharacters: 0,
80
+ },
81
+ };
82
+ }
83
+
84
+ const averageLength =
85
+ sections.reduce((sum, section) => sum + section.tokens.length, 0) / sections.length || 1;
86
+ const documentFrequency = new Map<string, number>();
87
+ for (const term of queryTerms) {
88
+ documentFrequency.set(
89
+ term,
90
+ sections.reduce((count, section) => count + (section.tokens.includes(term) ? 1 : 0), 0),
91
+ );
92
+ }
93
+
94
+ const selected = sections.filter((section) => {
95
+ const frequencies = new Map<string, number>();
96
+ for (const token of section.tokens) frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
97
+ const lengthNormalization = 1 - 0.75 + 0.75 * (section.tokens.length / averageLength);
98
+ let score = 0;
99
+ for (const term of queryTerms) {
100
+ const frequency = frequencies.get(term) ?? 0;
101
+ if (frequency === 0) continue;
102
+ const frequencyInDocuments = documentFrequency.get(term) ?? 0;
103
+ const inverseDocumentFrequency = Math.log(
104
+ 1 + (sections.length - frequencyInDocuments + 0.5) / (frequencyInDocuments + 0.5),
105
+ );
106
+ score +=
107
+ inverseDocumentFrequency *
108
+ ((frequency * (1.2 + 1)) / (frequency + 1.2 * lengthNormalization));
109
+ }
110
+ return score > 0;
111
+ });
112
+
113
+ const focused = selected.map((section) => section.markdown).join("\n\n");
114
+ return {
115
+ markdown: focused,
116
+ details: {
117
+ query: normalizedQuery,
118
+ matchedSections: selected.length,
119
+ totalSections: sections.length,
120
+ omittedSections: sections.length - selected.length,
121
+ originalCharacters,
122
+ focusedCharacters: focused.length,
123
+ },
124
+ };
125
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  FETCH_DEFAULT_OFFSET,
7
7
  FETCH_MAX_CHARACTERS,
8
8
  FETCH_MAX_OFFSET_CHARACTERS,
9
+ FETCH_MAX_QUERY_CHARACTERS,
9
10
  FETCH_MAX_URL_CHARACTERS,
10
11
  FETCH_MIN_MAX_CHARACTERS,
11
12
  } from "./limits";
@@ -14,9 +15,19 @@ import { executeWebFetch } from "./service";
14
15
 
15
16
  export { ExpiringLruCache } from "./cache";
16
17
  export type { FetchResult } from "./content";
18
+ export {
19
+ diagnoseExtraction,
20
+ extractDocumentLinks,
21
+ type ExtractedLink,
22
+ type ExtractedLinks,
23
+ type ExtractionDiagnostics,
24
+ } from "./evidence";
17
25
  export { fetchRemoteContent, type FetchRemoteDependencies } from "./fetch";
26
+ export { focusMarkdown, type FocusDetails, type FocusResult } from "./focus";
27
+ export { createDocumentOutline, type DocumentOutline, type OutlineHeading } from "./outline";
18
28
  export {
19
29
  executeWebFetch,
30
+ type CacheStatus,
20
31
  type WebFetchDetails,
21
32
  type WebFetchParameters,
22
33
  type WebFetchTruncationDetails,
@@ -35,6 +46,14 @@ export const webFetchParameters = Type.Object({
35
46
  maxLength: FETCH_MAX_URL_CHARACTERS,
36
47
  description: "Public HTTP or HTTPS URL to fetch",
37
48
  }),
49
+ query: Type.Optional(
50
+ Type.String({
51
+ minLength: 1,
52
+ maxLength: FETCH_MAX_QUERY_CHARACTERS,
53
+ description:
54
+ "Optional focus query; returns matching source sections in document order (offsets apply to the focused view)",
55
+ }),
56
+ ),
38
57
  offset: Type.Optional(
39
58
  Type.Integer({
40
59
  minimum: 0,
package/src/inflight.ts CHANGED
@@ -5,6 +5,14 @@ interface InflightEntry<V> {
5
5
  waiters: number;
6
6
  }
7
7
 
8
+ /**
9
+ * Wraps a promise to respect a caller's abort signal without cancelling the underlying operation.
10
+ *
11
+ * @param operation - Promise to wait for
12
+ * @param signal - Optional abort signal for this caller
13
+ * @param cancelledMessage - Error message to use when signal is aborted
14
+ * @returns Promise that rejects if signal aborts, otherwise resolves with operation result
15
+ */
8
16
  function waitForCaller<T>(
9
17
  operation: Promise<T>,
10
18
  signal: AbortSignal | undefined,
@@ -28,10 +36,24 @@ export class InflightCoalescer<K, V> {
28
36
 
29
37
  constructor(readonly maxEntries: number) {}
30
38
 
39
+ /**
40
+ * Gets the number of currently in-flight operations.
41
+ *
42
+ * @returns Count of active operations
43
+ */
31
44
  get size(): number {
32
45
  return this.#entries.size;
33
46
  }
34
47
 
48
+ /**
49
+ * Runs an operation, coalescing with any identical in-flight operation.
50
+ *
51
+ * @param key - Operation key for coalescing identical operations
52
+ * @param operation - Function that performs the operation
53
+ * @param signal - Optional abort signal for this caller
54
+ * @param cancelledMessage - Error message when signal is aborted
55
+ * @returns Promise resolving to the operation result
56
+ */
35
57
  async run(
36
58
  key: K,
37
59
  operation: (signal: AbortSignal | undefined) => Promise<V>,
package/src/limits.ts CHANGED
@@ -13,5 +13,8 @@ export const FETCH_MAX_CHARACTERS = 30_000;
13
13
  /** Maximum allowed offset for content extraction (characters). */
14
14
  export const FETCH_MAX_OFFSET_CHARACTERS = 20_000_000;
15
15
 
16
+ /** Maximum allowed length for an optional focus query (characters). */
17
+ export const FETCH_MAX_QUERY_CHARACTERS = 400;
18
+
16
19
  /** Maximum allowed length for input URLs (characters). */
17
20
  export const FETCH_MAX_URL_CHARACTERS = 2_048;
@@ -5,33 +5,146 @@ 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
+ const MAX_ORIGIN_CONCURRENCY = 4;
9
+ const MAX_RETRIES = 2;
10
+ const MAX_RETRY_DELAY_MS = 10_000;
11
+ const BASE_RETRY_DELAY_MS = 250;
12
+
13
+ interface OriginState {
14
+ active: number;
15
+ waiting: Array<() => void>;
16
+ }
17
+ const origins = new Map<string, OriginState>();
8
18
 
9
19
  /** Optional dependencies for redirect handling (used for testing). */
10
20
  export interface RedirectDependencies {
11
21
  validateUrl?: (value: string | URL) => Promise<ValidatedTarget>;
12
- request?: (target: ValidatedTarget, signal: AbortSignal) => Promise<IncomingMessage>;
22
+ request?: (
23
+ target: ValidatedTarget,
24
+ signal: AbortSignal,
25
+ headers?: Readonly<Record<string, string>>,
26
+ ) => Promise<IncomingMessage>;
27
+ random?: () => number;
28
+ sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
29
+ }
30
+
31
+ function abortError(): Error {
32
+ const error = new Error("Operation aborted.");
33
+ error.name = "AbortError";
34
+ return error;
35
+ }
36
+
37
+ async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() => void> {
38
+ const state = origins.get(origin) ?? { active: 0, waiting: [] };
39
+ origins.set(origin, state);
40
+ let reserved = false;
41
+ const release = () => {
42
+ state.active -= 1;
43
+ state.waiting.shift()?.();
44
+ if (state.active === 0 && state.waiting.length === 0) origins.delete(origin);
45
+ };
46
+ if (state.active >= MAX_ORIGIN_CONCURRENCY) {
47
+ await new Promise<void>((resolve, reject) => {
48
+ const enter = () => {
49
+ signal.removeEventListener("abort", cancel);
50
+ state.active += 1;
51
+ reserved = true;
52
+ resolve();
53
+ };
54
+ const cancel = () => {
55
+ const index = state.waiting.indexOf(enter);
56
+ if (index >= 0) state.waiting.splice(index, 1);
57
+ reject(abortError());
58
+ };
59
+ if (signal.aborted) cancel();
60
+ else {
61
+ state.waiting.push(enter);
62
+ signal.addEventListener("abort", cancel, { once: true });
63
+ }
64
+ });
65
+ }
66
+ if (signal.aborted) {
67
+ if (reserved) release();
68
+ throw abortError();
69
+ }
70
+ if (!reserved) state.active += 1;
71
+ return release;
72
+ }
73
+
74
+ async function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
75
+ await new Promise<void>((resolve, reject) => {
76
+ const timer = setTimeout(done, milliseconds);
77
+ function done(): void {
78
+ signal.removeEventListener("abort", cancel);
79
+ resolve();
80
+ }
81
+ function cancel(): void {
82
+ clearTimeout(timer);
83
+ reject(abortError());
84
+ }
85
+ if (signal.aborted) cancel();
86
+ else signal.addEventListener("abort", cancel, { once: true });
87
+ });
88
+ }
89
+
90
+ function retryAfterMilliseconds(value: string | undefined, now = Date.now()): number | undefined {
91
+ if (!value) return undefined;
92
+ const seconds = Number(value.trim());
93
+ if (Number.isFinite(seconds) && seconds >= 0)
94
+ return Math.min(seconds * 1_000, MAX_RETRY_DELAY_MS);
95
+ const date = Date.parse(value);
96
+ if (!Number.isFinite(date)) return undefined;
97
+ return Math.min(Math.max(0, date - now), MAX_RETRY_DELAY_MS);
98
+ }
99
+
100
+ async function coordinatedRequest(
101
+ target: ValidatedTarget,
102
+ signal: AbortSignal,
103
+ headers: Readonly<Record<string, string>>,
104
+ dependencies: RedirectDependencies,
105
+ ): Promise<IncomingMessage> {
106
+ const request =
107
+ dependencies.request ??
108
+ ((value, requestSignal, requestHeaders) =>
109
+ requestPinned(value, requestSignal, { headers: requestHeaders }));
110
+ const random = dependencies.random ?? Math.random;
111
+ const sleep = dependencies.sleep ?? defaultSleep;
112
+ for (let attempt = 0; ; attempt += 1) {
113
+ const release = await acquireOrigin(target.url.origin, signal);
114
+ let response: IncomingMessage;
115
+ try {
116
+ response = await request(target, signal, headers);
117
+ } finally {
118
+ release();
119
+ }
120
+ const status = response.statusCode ?? 0;
121
+ if (![429, 503].includes(status) || attempt >= MAX_RETRIES) return response;
122
+ // The retry decision uses headers only. Drop an untrusted error body so it cannot
123
+ // consume unbounded bandwidth while the caller is backing off.
124
+ response.destroy();
125
+ const advertised = retryAfterMilliseconds(responseHeader(response, "retry-after"));
126
+ const exponential = BASE_RETRY_DELAY_MS * 2 ** attempt;
127
+ const jittered = Math.round(exponential * (0.75 + Math.min(1, Math.max(0, random())) * 0.5));
128
+ await sleep(advertised ?? Math.min(jittered, MAX_RETRY_DELAY_MS), signal);
129
+ }
13
130
  }
14
131
 
15
132
  /**
16
- * Requests a URL and follows supported HTTP redirects.
17
- *
18
- * @param value - The initial URL to request
19
- * @param signal - Signal used to cancel validation and requests
20
- * @param dependencies - Optional URL-validation and request implementations
21
- * @returns The final validated target and its HTTP response
22
- * @throws If a redirect lacks a `Location` header or the redirect limit is exceeded
133
+ * Requests a URL and follows supported HTTP redirects. Every attempt, redirect, and retry
134
+ * remains inside URL validation, DNS pinning, origin coordination, and caller cancellation.
23
135
  */
24
136
  export async function requestFollowingRedirects(
25
137
  value: string | URL,
26
138
  signal: AbortSignal,
27
139
  dependencies: RedirectDependencies = {},
140
+ headers: Readonly<Record<string, string>> = {},
28
141
  ): Promise<{ target: ValidatedTarget; response: IncomingMessage }> {
29
142
  const validateUrl = dependencies.validateUrl ?? validateRemoteUrl;
30
- const request = dependencies.request ?? requestPinned;
31
143
  let target = await awaitWithAbort(validateUrl(value), signal);
144
+ let requestHeaders = headers;
32
145
 
33
146
  for (let redirects = 0; redirects <= FETCH_MAX_REDIRECTS; redirects += 1) {
34
- const response = await request(target, signal);
147
+ const response = await coordinatedRequest(target, signal, requestHeaders, dependencies);
35
148
  const status = response.statusCode ?? 0;
36
149
  if (![301, 302, 303, 307, 308].includes(status)) return { target, response };
37
150
 
@@ -41,6 +154,8 @@ export async function requestFollowingRedirects(
41
154
  throw new Error("web_fetch followed too many redirects.");
42
155
  response.resume();
43
156
  target = await awaitWithAbort(validateUrl(new URL(location, target.url)), signal);
157
+ // Validators describe the originally cached resource and must not leak to a redirect target.
158
+ requestHeaders = {};
44
159
  }
45
160
  throw new Error("web_fetch followed too many redirects.");
46
161
  }
@@ -60,6 +60,7 @@ async function requestOnce(
60
60
  address: string,
61
61
  signal: AbortSignal,
62
62
  attemptTimeoutMs: number,
63
+ headers: Readonly<Record<string, string>>,
63
64
  ): Promise<IncomingMessage> {
64
65
  const family = isIP(address);
65
66
  if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${address}.`);
@@ -101,6 +102,7 @@ async function requestOnce(
101
102
  headers: {
102
103
  Accept: "text/markdown, text/html, text/plain, application/json;q=0.9, */*;q=0.1",
103
104
  "User-Agent": "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)",
105
+ ...headers,
104
106
  },
105
107
  },
106
108
  (response) => {
@@ -126,6 +128,8 @@ async function requestOnce(
126
128
  export interface RequestPinnedOptions {
127
129
  /** Per-address connect deadline used to fall back to the next address. */
128
130
  attemptTimeoutMs?: number;
131
+ /** Additional fixed request headers, used for conditional cache revalidation. */
132
+ headers?: Readonly<Record<string, string>>;
129
133
  }
130
134
 
131
135
  /**
@@ -146,7 +150,7 @@ export async function requestPinned(
146
150
  let lastError: unknown;
147
151
  for (const address of addresses) {
148
152
  try {
149
- return await requestOnce(target, address, signal, attemptTimeoutMs);
153
+ return await requestOnce(target, address, signal, attemptTimeoutMs, options.headers ?? {});
150
154
  } catch (error) {
151
155
  lastError = error;
152
156
  if (signal.aborted) throw error;
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
+ }