crawldna 0.1.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.
@@ -0,0 +1,555 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // The AI judgment layer.
4
+ //
5
+ // PHASE 1 (crawl) — three jobs, all of which keep the captured content VERBATIM:
6
+ // - aiSelectRevealers: which interactive controls actually HIDE content worth
7
+ // revealing (the discovery core — "don't miss anything").
8
+ // - aiScopeContent: keep only the sections relevant to the task (drop the
9
+ // landing/footer/cookie/marketing chrome) — the "stay focused" core. Output
10
+ // is the ORIGINAL text of the kept sections; the model never rewrites content.
11
+ // - aiSelectLinks: which discovered links lead to more task-relevant pages.
12
+ // All three bias toward completeness: when the model is unsure or errors, KEEP.
13
+ //
14
+ // PHASE 2 (reshape) — aiReshape: a separate, AFTER-the-crawl step that reworks the
15
+ // already-extracted files on request (a table, a split, a filtered subset), reusing
16
+ // the same extraction as context like a knowledge base. Value-faithful: it copies
17
+ // every kept value exactly and never invents. This is the ONLY place AI reshapes
18
+ // output; the crawl itself stays verbatim.
19
+
20
+ // All model communication goes through the provider-agnostic transport layer.
21
+ // These functions take an `llm` descriptor ({ provider, model, baseUrl, apiKey })
22
+ // and never care whether it is backed by Ollama or an OpenAI-compatible API.
23
+ //
24
+ // NO-AI MODE (`noAi: true` → provider 'none'): every phase-1 judgment below
25
+ // short-circuits to its completeness-bias fallback WITHOUT building a prompt or
26
+ // touching the transport — heuristic reveal triage, keep pages whole, follow all
27
+ // in-scope links, no targeted navigation. Identical behaviour to a model outage
28
+ // (the fallbacks are shared), but intentional, warned once, and free of the
29
+ // failed calls' latency. Zero tokens by construction.
30
+ import { chat, llmDisabled } from '../lib/llm.mjs';
31
+ import { selectRelevant, sectionizeDoc } from '../lib/retrieve.mjs';
32
+ import { semanticSectionScores } from '../lib/semantic.mjs';
33
+
34
+ /** Pull the first JSON value out of a model reply. */
35
+ function parseJson(text) {
36
+ if (!text) return null;
37
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
38
+ const body = fenced ? fenced[1] : text;
39
+ const m = body.match(/[[{][\s\S]*[\]}]/);
40
+ if (!m) return null;
41
+ try {
42
+ return JSON.parse(m[0]);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ // PROMPT-CACHE CONTRACT (#4): every judgment call's SYSTEM prompt below is a plain
49
+ // string literal — byte-identical across all calls of its type. That identity is what
50
+ // lets remote providers (OpenAI, DeepSeek, vLLM, OpenRouter) serve the repeated prefix
51
+ // from their prompt cache (~10× cheaper input) on a crawl's thousands of calls. Keep
52
+ // per-call data (task, page text, links, controls) in the USER message — never
53
+ // interpolate it into a system prompt. Locked by a test (decide.test.mjs).
54
+
55
+ // JSON shapes the crawl-judgment calls must return. Passed to the transport for
56
+ // CONSTRAINED DECODING (Ollama `format`) / guaranteed JSON (OpenAI `response_format`),
57
+ // so the model can't emit fences, prose or a wrong shape — the #1 cause of a parse
58
+ // failure (which would force the completeness-bias "follow/keep ALL" fallback and waste
59
+ // the crawl). Per-call downstream validation is unchanged (it stays the safety net).
60
+ const SCHEMAS = {
61
+ reveal: { type: 'object', properties: { click: { type: 'array', items: { type: 'integer' } } }, required: ['click'], additionalProperties: false },
62
+ links: { type: 'object', properties: { follow: { type: 'array', items: { type: 'integer' } } }, required: ['follow'], additionalProperties: false },
63
+ keep: { type: 'object', properties: { keep: { type: 'array', items: { type: 'integer' } } }, required: ['keep'], additionalProperties: false },
64
+ plan: { type: 'object', properties: { direction: { type: ['integer', 'null'] }, target: { type: ['string', 'null'] } }, required: ['direction', 'target'], additionalProperties: false },
65
+ };
66
+
67
+ /** Split markdown into heading-delimited sections (verbatim text preserved). */
68
+ function sectionize(markdown) {
69
+ const lines = markdown.split('\n');
70
+ const sections = [];
71
+ let cur = { heading: '(intro)', lines: [] };
72
+ let inFence = false;
73
+ for (const line of lines) {
74
+ if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
75
+ const h = !inFence && line.match(/^(#{1,3})\s+(.*)/);
76
+ if (h) {
77
+ if (cur.lines.length || sections.length === 0) sections.push(cur);
78
+ cur = { heading: h[2].trim().slice(0, 100), lines: [line] };
79
+ } else {
80
+ cur.lines.push(line);
81
+ }
82
+ }
83
+ sections.push(cur);
84
+ return sections.map((s, i) => ({ index: i, heading: s.heading, text: s.lines.join('\n').trim() }));
85
+ }
86
+
87
+ // =========================================================================
88
+ // PHASE 2 — reshape (the "chat with your extraction" step)
89
+ // =========================================================================
90
+
91
+ /**
92
+ * Parse a reshape reply into `{ reply, files }`. The model emits deliverables as
93
+ * FILE BLOCKS — `===FILE: name.md===` … `===END===` — and anything outside the
94
+ * blocks is the conversational reply. Robust to large content (no JSON escaping)
95
+ * and to an accidental whole-file code fence around a block's body.
96
+ */
97
+ function parseReshape(text) {
98
+ const out = { reply: '', files: [] };
99
+ const raw = String(text || '');
100
+ if (!raw.trim()) return out;
101
+
102
+ const re = /===FILE:\s*([^\n=]+?)\s*===\r?\n([\s\S]*?)\r?\n===END===/g;
103
+ const replyParts = [];
104
+ let last = 0;
105
+ let m;
106
+ while ((m = re.exec(raw))) {
107
+ replyParts.push(raw.slice(last, m.index));
108
+ last = re.lastIndex;
109
+ const filename = m[1].trim();
110
+ let content = m[2].replace(/^\s*\n/, '').replace(/\s+$/, '');
111
+ const fence = content.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i);
112
+ if (fence) content = fence[1];
113
+ // A local model sometimes DOUBLES the block markers (a nested `===FILE: …===`
114
+ // first line and a stray `===END` fragment at the end) — strip them so the
115
+ // artefact never leaks into the saved file.
116
+ content = content
117
+ .replace(/^===FILE:[^\n]*===\s*\r?\n/, '')
118
+ .replace(/\r?\n={2,}\s*END=*\s*$/, '')
119
+ .trim();
120
+ if (filename && content.trim()) out.files.push({ filename, content });
121
+ }
122
+ replyParts.push(raw.slice(last));
123
+ out.reply = replyParts.join('').replace(/\n{3,}/g, '\n\n').trim();
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * Is a chat reply "document-worthy" — i.e. is it itself a deliverable the user
129
+ * would want as a file (a table, several sections, or a long list), rather than a
130
+ * short conversational answer? Models often produce such content inline instead
131
+ * of in a FILE BLOCK; this lets the caller promote it to a saved document so the
132
+ * user always gets a file when the answer is one. Short, unstructured Q&A stays
133
+ * a plain chat message.
134
+ */
135
+ function isDocumentWorthy(text) {
136
+ const t = String(text || '').trim();
137
+ if (!t) return false;
138
+ const lines = t.split('\n');
139
+ // a Markdown table (a header row followed by a |---|---| separator)
140
+ for (let i = 0; i < lines.length - 1; i++) {
141
+ if (lines[i].includes('|') && /-/.test(lines[i + 1]) && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1])) return true;
142
+ }
143
+ const headings = (t.match(/^#{1,6}\s+\S/gm) || []).length;
144
+ const bullets = (t.match(/^\s*([-*+]|\d+\.)\s+\S/gm) || []).length;
145
+ return headings >= 2 || bullets >= 5;
146
+ }
147
+
148
+ /** Derive a readable .md filename for a promoted document (sanitised later). */
149
+ function deriveDocName(instruction, reply) {
150
+ const h = String(reply || '').match(/^#{1,6}\s+(.+?)\s*$/m);
151
+ const base = (h ? h[1] : String(instruction || 'answer')).replace(/[*_`#|]/g, '').trim();
152
+ return (base.slice(0, 60) || 'answer') + '.md';
153
+ }
154
+
155
+ // How many characters of source content to send the model per turn. Large crawls
156
+ // can exceed a model's context window; we cap and flag truncation rather than fail
157
+ // (the full extraction always stays on disk).
158
+ const RESHAPE_CAP = 60000;
159
+
160
+ /** One-line identity for a document so the model can be told (and the user can
161
+ * reference) exactly which file it is: name · size · title · source URL(s). */
162
+ function docLabel(d, i, kind) {
163
+ return [
164
+ d.filename ? `"${d.filename}"` : `${kind} ${i + 1}`,
165
+ d.bytes || d.bytes === 0 ? `${d.bytes} bytes` : '',
166
+ d.title ? `titled "${d.title}"` : '',
167
+ d.sources && d.sources.length ? `crawled from ${d.sources.join(' , ')}` : '',
168
+ d.partial ? 'PARTIAL EXTRACT — only the sections relevant to this request are shown' : '',
169
+ ]
170
+ .filter(Boolean)
171
+ .join(' · ');
172
+ }
173
+
174
+ /** Render a labelled set of documents, each under a header that states its identity. */
175
+ function renderDocs(set, kind) {
176
+ return set
177
+ .map((d, i) => `===== ${kind} [${i + 1}] — ${docLabel(d, i, kind)} =====\n${String(d.content || '').trim()}`)
178
+ .join('\n\n');
179
+ }
180
+
181
+ /**
182
+ * Rework already-extracted content to fulfil a user request, like answering from a
183
+ * knowledge base built over the whole crawl output. This is Phase 2: it runs on
184
+ * the SAVED files, on demand, as many times as the user wants — the crawl itself
185
+ * (Phase 1) never reshapes. The model MAY filter, reorder, regroup and reformat
186
+ * (e.g. into a Markdown table); it MUST keep every kept value (name/number/price/
187
+ * time/URL/string) EXACTLY as written and never invent or alter one.
188
+ *
189
+ * Context is passed as IDENTIFIABLE DOCUMENTS, not an anonymous blob, so the model
190
+ * knows what it's looking at and can honour references like "the original md" or
191
+ * "the 4574 bytes file":
192
+ * - `documents` — the ORIGINAL crawled extraction (the default thing to reshape),
193
+ * each `{ filename, bytes, title?, sources?, content }`.
194
+ * - `produced` — files the user produced earlier in THIS chat (so they can
195
+ * iterate on one), each `{ filename, bytes?, content }`. Clearly separated.
196
+ * - `corpus` — back-compat: a bare string becomes a single unnamed document.
197
+ *
198
+ * @param {object} a
199
+ * @param {{provider,model,baseUrl,apiKey}} a.llm
200
+ * @param {string} a.instruction the user's latest message
201
+ * @param {Array<{role:string, content:string}>} [a.history] prior turns (this session)
202
+ * @param {Array<object>} [a.documents] original crawled source documents
203
+ * @param {Array<object>} [a.produced] files produced earlier in this chat
204
+ * @param {string} [a.corpus] back-compat: unnamed source content
205
+ * @returns {Promise<{ reply: string, files: Array<{ filename, content }>, truncated: boolean }>}
206
+ */
207
+ export async function aiReshape({ llm, instruction, history = [], documents = null, produced = [], corpus = '' }) {
208
+ let docs = Array.isArray(documents) ? documents : null;
209
+ if (!docs) docs = String(corpus || '').trim() ? [{ filename: '', content: String(corpus) }] : [];
210
+ docs = docs.filter((d) => String(d.content || '').trim());
211
+ if (!docs.length) return { reply: 'There is no extracted content to work from yet.', files: [], truncated: false, contextMode: 'full' };
212
+
213
+ // #11 root cause: when the sources exceed the model budget, RETRIEVE the sections
214
+ // relevant to the instruction instead of blindly sending the first RESHAPE_CAP chars.
215
+ // The blind head-slice is what made the model "answer" out-of-budget topics from its
216
+ // own memory (a fabricated v-alert props table, live). Verbatim subsets, document
217
+ // order preserved, omissions marked; 'head' mode = the legacy slice, kept only when
218
+ // nothing in the instruction discriminates (e.g. "tidy everything up").
219
+ // #22: with an embedModel configured, sections are ranked SEMANTICALLY (each by its
220
+ // heading + head window) so a cross-language request still pulls the right ones;
221
+ // null (tier off / backend down) keeps the lexical retrieval byte-identical. Only
222
+ // computed when the sources actually exceed the budget — a fitting corpus embeds nothing.
223
+ let sectionScore = null;
224
+ if (llm && llm.embedModel && !llmDisabled(llm)) {
225
+ const total = docs.reduce((n, d) => n + String(d.content || '').length, 0);
226
+ if (total > RESHAPE_CAP) {
227
+ sectionScore = await semanticSectionScores({ llm, instruction, docSections: docs.map((d) => sectionizeDoc(d.content)) });
228
+ }
229
+ }
230
+ const sel = selectRelevant(docs, instruction, RESHAPE_CAP, sectionScore);
231
+ let contextMode = sel.mode;
232
+ let truncated = sel.truncated;
233
+ let sourcesBlock = renderDocs(sel.docs, 'SOURCE DOCUMENT');
234
+ if (sourcesBlock.length > RESHAPE_CAP) {
235
+ sourcesBlock = sourcesBlock.slice(0, RESHAPE_CAP);
236
+ truncated = true;
237
+ if (contextMode === 'full') contextMode = 'head';
238
+ }
239
+ if (sel.omittedDocs > 0) {
240
+ sourcesBlock += `\n\n(${sel.omittedDocs} other source document(s) contained nothing relevant to this request and were omitted.)`;
241
+ }
242
+ // Prior chat outputs as secondary context, only with whatever budget remains so
243
+ // they can never crowd out the originals.
244
+ const prod = (produced || []).filter((d) => String(d.content || '').trim());
245
+ let producedBlock = prod.length ? renderDocs(prod, 'FILE YOU PRODUCED EARLIER IN THIS CHAT') : '';
246
+ const budget = Math.max(0, RESHAPE_CAP - sourcesBlock.length);
247
+ if (producedBlock.length > budget) producedBlock = producedBlock.slice(0, budget);
248
+
249
+ const system =
250
+ 'You help a user reshape and answer questions over ALREADY-EXTRACTED website content, ' +
251
+ 'like a knowledge base. The content is given as one or more SOURCE DOCUMENTS — the original, ' +
252
+ 'verbatim crawl output — each labelled with its filename and byte size. STRICT RULES:\n' +
253
+ '- The SOURCE DOCUMENTS are your only source of facts AND the DEFAULT thing to work on. When ' +
254
+ 'the user says "the original", "the original md", a filename, or a size (e.g. "the 4574 bytes ' +
255
+ 'file"), they mean the matching SOURCE DOCUMENT — operate on THAT document.\n' +
256
+ '- NEVER supply a fact, value, prop, price, example or code snippet from your own knowledge ' +
257
+ 'of the site, product or framework — even when you are confident you know it. If the SOURCE ' +
258
+ 'DOCUMENTS do not contain what the user asks for, say so plainly ("the extraction does not ' +
259
+ 'contain X") — an honest gap beats an invented answer every time.\n' +
260
+ '- Never invent, add, infer or alter a value: keep every name, number, price, time, URL and ' +
261
+ 'string EXACTLY as written. You may select, drop, reorder, regroup, reformat (e.g. into a ' +
262
+ 'Markdown table) and tidy the layout — but never change the actual content or values.\n' +
263
+ '- "Redo/rewrite the original better without changing its content" means reproduce that WHOLE ' +
264
+ 'document, tidied and well-structured, with every value preserved verbatim.\n' +
265
+ '- Files labelled "FILE YOU PRODUCED EARLIER IN THIS CHAT" are your own prior outputs; revise ' +
266
+ 'one only when the user clearly refers to it. They are NOT the originals.\n' +
267
+ '- When you produce deliverable content, emit it as one or more FILE BLOCKS, each in this EXACT ' +
268
+ 'format on their own lines:\n' +
269
+ '===FILE: name.md===\n' +
270
+ '<the file\'s Markdown>\n' +
271
+ '===END===\n' +
272
+ 'You may emit SEVERAL files (e.g. split by category or by day). Use short, descriptive .md ' +
273
+ 'filenames. Do NOT wrap a block\'s body in a code fence.\n' +
274
+ '- Only emit FILE BLOCKS when the user asks you to CREATE, RESHAPE, REDO, SPLIT, FILTER or ' +
275
+ 'REFORMAT a deliverable (or the answer is itself inherently a document — a table, several ' +
276
+ 'sections, a long list). A QUESTION is NOT such a request: when the user ASKS something (e.g. ' +
277
+ '"what street is it on?", "how many slots are free?"), answer in plain text with NO file blocks, ' +
278
+ 'and do NOT reproduce a SOURCE DOCUMENT as a file. Put any explanation OUTSIDE the blocks, brief.\n' +
279
+ '- If the request cannot be satisfied from the documents, say so plainly (no blocks).';
280
+
281
+ const convo = (history || [])
282
+ .map((h) => `${h.role === 'assistant' ? 'Assistant' : 'User'}: ${h.content}`)
283
+ .join('\n');
284
+ const user =
285
+ 'SOURCE DOCUMENTS — the original crawled extraction (verbatim). By DEFAULT these are what you ' +
286
+ 'reshape; refer to them by filename or size when the user does:\n\n' +
287
+ sourcesBlock +
288
+ '\n\n' +
289
+ (producedBlock
290
+ ? 'FILES YOU PRODUCED EARLIER IN THIS CHAT (you may revise one if asked; NOT the originals):\n\n' +
291
+ producedBlock +
292
+ '\n\n'
293
+ : '') +
294
+ (convo ? 'Conversation so far:\n' + convo + '\n\n' : '') +
295
+ 'User: ' +
296
+ String(instruction || '');
297
+
298
+ let ans;
299
+ try {
300
+ ans = await chat(llm, system, user, null, 'reshape');
301
+ } catch (err) {
302
+ // Surface the real reason (bad key, unreachable URL, unknown model) — this is
303
+ // a user-facing chat turn, not a silent crawl decision.
304
+ return {
305
+ reply:
306
+ 'The model call failed: ' +
307
+ ((err && err.message) || String(err)) +
308
+ '. Check the selected model, and (for an API provider) the base URL and API key.',
309
+ files: [],
310
+ truncated,
311
+ contextMode,
312
+ };
313
+ }
314
+ const parsed = parseReshape(ans);
315
+ if (!parsed.reply && !parsed.files.length) {
316
+ return { reply: 'The model did not return a usable response. Try rephrasing, or check the model is reachable.', files: [], truncated, contextMode };
317
+ }
318
+ // "Auto" mode safety net: if the model answered with document-worthy content but
319
+ // didn't wrap it in a FILE BLOCK, promote that content to a saved document so the
320
+ // user gets a file — not just a chat message. Short Q&A is left as a message.
321
+ if (!parsed.files.length && isDocumentWorthy(parsed.reply)) {
322
+ parsed.files.push({ filename: deriveDocName(instruction, parsed.reply), content: parsed.reply });
323
+ parsed.reply = '';
324
+ }
325
+ return { ...parsed, truncated, contextMode };
326
+ }
327
+
328
+ // =========================================================================
329
+ // PHASE 1 — crawl-time judgment (scope, links, reveal)
330
+ // =========================================================================
331
+
332
+ /**
333
+ * Keep only task-relevant sections, verbatim. This is the "stay focused" step:
334
+ * for a "documentation" task it drops the landing page, footer, pricing, etc.;
335
+ * for "the pizza menu" it keeps only the menu. It never rewrites content — it
336
+ * returns the ORIGINAL text of the kept sections — and biases toward KEEP.
337
+ * @returns {Promise<{ markdown: string, relevant: boolean }>}
338
+ */
339
+ export async function aiScopeContent({ llm, task, title, markdown }) {
340
+ if (llmDisabled(llm)) return { markdown, relevant: !!markdown }; // no-AI: keep whole
341
+ if (!markdown || markdown.length < 1200) return { markdown, relevant: !!markdown };
342
+
343
+ const sections = sectionize(markdown);
344
+ if (sections.length <= 1) {
345
+ // Single blob: keep it whole, no model call. The only thing a relevance verdict
346
+ // could do here is DROP the page, and the drop-guard was "irrelevant AND thin
347
+ // (< 600 chars)" — unreachable, since everything under 1200 chars already
348
+ // returned above. So the call could never change the outcome; it only burned
349
+ // one scope call per single-section page. Narrowing a kept page to the task is
350
+ // the job of section-scoping below and of Phase 2 (reshape).
351
+ return { markdown, relevant: true };
352
+ }
353
+
354
+ const outline = sections
355
+ .map((s) => `${s.index}: ${s.heading} — ${s.text.replace(/\s+/g, ' ').slice(0, 140)}`)
356
+ .join('\n');
357
+
358
+ const ans = await chat(
359
+ llm,
360
+ 'You select which sections of a page belong to a user extraction task. ' +
361
+ 'Keep every section that contains task-relevant content. Drop only clearly-irrelevant ' +
362
+ 'sections such as site navigation, footers, cookie/consent notices, marketing call-to-action, ' +
363
+ 'newsletter signups, "related/recommended" widgets, comments, or unrelated topics. ' +
364
+ 'When unsure, KEEP. Answer with JSON only.',
365
+ `Task: "${task}"\nPage title: ${title || ''}\n\nSections (index: heading — preview):\n${outline}\n\n` +
366
+ 'Reply with {"keep": [list of section indexes to keep]}.',
367
+ SCHEMAS.keep,
368
+ 'scope',
369
+ ).catch(() => '');
370
+
371
+ const j = parseJson(ans);
372
+ if (!j || !Array.isArray(j.keep)) return { markdown, relevant: true };
373
+
374
+ const keep = new Set(j.keep.map(Number).filter((n) => Number.isInteger(n)));
375
+ if (keep.size === 0) return { markdown, relevant: true }; // keep-bias on empty
376
+ const kept = sections.filter((s) => keep.has(s.index)).map((s) => s.text).filter(Boolean);
377
+ const out = kept.join('\n\n').trim();
378
+ return { markdown: out || markdown, relevant: out.length > 0 };
379
+ }
380
+
381
+ /**
382
+ * Choose which links to follow for the task.
383
+ * @param {object} a
384
+ * @param {Array<{href,label}>} a.links in-scope candidates
385
+ * @returns {Promise<string[]>} hrefs to enqueue
386
+ */
387
+ export async function aiSelectLinks({ llm, task, links }) {
388
+ const capped = links.slice(0, 160);
389
+ if (capped.length === 0) return [];
390
+ if (llmDisabled(llm)) return capped.map((l) => l.href); // no-AI: follow all in-scope
391
+
392
+ const list = capped.map((l, i) => `${i}: ${l.label ? l.label.slice(0, 60) + ' — ' : ''}${l.href}`).join('\n');
393
+ const ans = await chat(
394
+ llm,
395
+ 'You decide which links to follow while crawling to fulfil an extraction task. ' +
396
+ 'You are given raw destinations exactly as they appear on the page — the crawler makes NO ' +
397
+ 'assumptions about their shape, so YOU must recognise what is a real, separate page. A real ' +
398
+ 'page can be a normal URL, a single-page-app route carried in the URL fragment ' +
399
+ '(e.g. #/contact, #!/features, #/products/42) or in the query string (e.g. ?view=pricing, ' +
400
+ '?page=2), or any other site-specific routing/pagination scheme. Treat all of these as real ' +
401
+ 'pages. Do NOT follow: same-page anchors that merely jump within the CURRENT page ' +
402
+ '(e.g. #overview, #section-3, #top — a fragment with no route-like path), links that clearly ' +
403
+ 'reload the current page, mailto/tel, or external sites. ' +
404
+ 'Among real pages, follow a link ONLY if its destination is the SAME KIND of content the task ' +
405
+ 'asks for. For a documentation task the right kind is reference / guide / tutorial / API / ' +
406
+ 'concept / configuration (incl. release notes / changelog); avoid blog/news, marketing/landing, ' +
407
+ 'pricing, about/team/careers, community/showcase, login/signup, legal. For any other task ' +
408
+ '(a menu, prices, contact info, products, …) apply the same principle for that category. ' +
409
+ 'When unsure whether something is on-task, prefer to follow (completeness matters more than speed). ' +
410
+ 'Judge by the label and the whole destination string. Answer with JSON only.',
411
+ `Task: "${task}"\n\nDestinations (index: label — href):\n${list}\n\n` +
412
+ 'Reply with {"follow": [indexes to follow]}. Include every real, on-task page; exclude same-page anchors and off-task links.',
413
+ SCHEMAS.links,
414
+ 'links',
415
+ ).catch(() => '');
416
+
417
+ const j = parseJson(ans);
418
+ if (!j || !Array.isArray(j.follow)) return capped.map((l) => l.href); // follow all in-scope on failure
419
+ const idx = new Set(j.follow.map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < capped.length));
420
+ // A non-empty answer whose indexes are ALL out of range is garbage → treat as failure.
421
+ // A well-formed EMPTY list is a legitimate verdict ("none of these are on-task pages")
422
+ // and must be honoured — overriding it to follow-all is what sent crawls wandering
423
+ // through 160 off-task links whenever a page had nothing worth following.
424
+ if (j.follow.length && idx.size === 0) return capped.map((l) => l.href);
425
+ return capped.filter((_, i) => idx.has(i)).map((l) => l.href);
426
+ }
427
+
428
+ /**
429
+ * Plan navigation ONCE for a multi-view page — the crawl4ai-inspired split: the AI
430
+ * makes a single high-level PLAN, then the reveal loop EXECUTES it deterministically
431
+ * (no LLM in the click loop, which is what made a per-step navigator flaky on a local
432
+ * model). A page can be a sequence/graph of views reached by clicking controls that
433
+ * move between them: paginators ("next"/"previous"/page N), calendar month arrows,
434
+ * wizard steps, "load next", view switchers.
435
+ *
436
+ * The model answers one easy question: for THIS task, which control ADVANCES toward
437
+ * the target, and what literal TEXT marks the target view (so the loop can stop by a
438
+ * plain substring check, not another model call)? For an open-ended task
439
+ * ("everything"/"all") there is no single target — it returns `direction: null` and
440
+ * the loop explores every control instead.
441
+ *
442
+ * @param {object} a
443
+ * @param {{provider,model,baseUrl,apiKey}} a.llm
444
+ * @param {string} a.task
445
+ * @param {{title?:string, snippet?:string}} a.current the view we're on now
446
+ * @param {Array<{signature:string, kind?:string, label?:string, context?:string}>} a.controls
447
+ * @returns {Promise<{direction:number|null, target:string|null}|null>}
448
+ * direction = index into `controls` of the advancing control; target = literal text
449
+ * (in the PAGE's language) that appears in the target view; or null on failure.
450
+ */
451
+ export async function aiPlanNavigation({ llm, task, current = {}, controls = [] }) {
452
+ const list = (controls || []).slice(0, 60);
453
+ if (list.length === 0) return { direction: null, target: null };
454
+ // no-AI: no targeted walk — the reveal loop explores every control open-ended.
455
+ if (llmDisabled(llm)) return { direction: null, target: null };
456
+
457
+ const lines = list
458
+ .map((c, i) => `${i}: [${c.kind || 'control'}] "${(c.label || '(no label)').slice(0, 70)}"` + (c.context ? ` — under "${c.context}"` : ''))
459
+ .join('\n');
460
+
461
+ const ans = await chat(
462
+ llm,
463
+ 'You plan how to navigate a web page to fulfil an extraction task. Some pages are a ' +
464
+ 'SEQUENCE of views reached by clicking a control repeatedly (a "next month" arrow on ' +
465
+ 'a calendar, a "next page" paginator, wizard steps). Decide ONE plan:\n' +
466
+ '- If the task targets a SPECIFIC view reachable by such navigation (a particular ' +
467
+ 'month+year, page, section or date), return the index of the control that ADVANCES ' +
468
+ 'toward it (e.g. the next/forward/"successivo" arrow to reach a LATER month; the ' +
469
+ 'previous/"precedente" one for an EARLIER month — reason from the current view) AND a ' +
470
+ 'short "target" string that will literally appear in that target view. The target ' +
471
+ 'string MUST be written in the SAME LANGUAGE/spelling as the page (look at the current ' +
472
+ 'view text: if months read "GIUGNO", "LUGLIO" then August is "agosto", not "august").\n' +
473
+ '- If the task is open-ended ("everything", "all", "the whole …") or needs no such ' +
474
+ 'navigation, return {"direction": null, "target": null}.\n' +
475
+ 'Answer with JSON only.',
476
+ `Task: "${task || ''}"\n\n` +
477
+ `Current view (title + visible text):\n${(current.title || '').slice(0, 100)}\n${(current.snippet || '').replace(/\s+/g, ' ').slice(0, 700)}\n\n` +
478
+ `Controls on the page (index: [kind] "label"):\n${lines}\n\n` +
479
+ 'Reply with {"direction": <index or null>, "target": "<text that marks the target view, or null>"}.',
480
+ SCHEMAS.plan,
481
+ 'nav-plan',
482
+ ).catch(() => '');
483
+
484
+ const j = parseJson(ans);
485
+ if (!j) return null; // signal: caller falls back to open-ended exploration
486
+ // null-safe on purpose: Number(null) is 0, so a model answering the documented
487
+ // "no navigation" form {"direction": null} used to be misread as "direction =
488
+ // the first control" — which the reveal loop then RESERVED for a targeted walk
489
+ // that never ran, so that control was never clicked at all (found by the #21
490
+ // closed-loop tests; the schema explicitly allows null here).
491
+ const dir = j.direction == null ? NaN : Number(j.direction);
492
+ const direction = Number.isInteger(dir) && dir >= 0 && dir < list.length ? dir : null;
493
+ const target = typeof j.target === 'string' && j.target.trim() ? j.target.trim() : null;
494
+ return { direction, target };
495
+ }
496
+
497
+ /**
498
+ * Decide which interactive controls on a page actually HIDE content worth
499
+ * revealing — the AI-driven core of discovery. The model reads each candidate
500
+ * (label, kind, class, nearby heading) like a human and judges whether clicking
501
+ * it would surface currently-hidden readable content (tabs, accordions, "show
502
+ * more", variant switches), versus controls that reveal nothing (copy/share,
503
+ * theme toggles, live-demo widgets, plain navigation). This is what lets the
504
+ * crawler find content in non-obvious places on ANY site without per-site rules.
505
+ *
506
+ * Completeness-biased: "when unsure, include". Returns a Set of the chosen
507
+ * candidates' `signature`s, or null on parse failure so the caller can fall back
508
+ * to the deterministic heuristic (no missed content if the model is down).
509
+ *
510
+ * @param {object} a
511
+ * @param {Array<{signature:string, kind:string, label:string, cls?:string, context?:string}>} a.candidates
512
+ * @returns {Promise<Set<string>|null>}
513
+ */
514
+ export async function aiSelectRevealers({ llm, task, candidates }) {
515
+ const list = (candidates || []).slice(0, 150);
516
+ if (list.length === 0) return new Set();
517
+ // no-AI: null = "use the per-candidate DOM heuristic" (same signal as a model
518
+ // outage), so reveal coverage never drops below the deterministic baseline.
519
+ if (llmDisabled(llm)) return null;
520
+
521
+ const lines = list
522
+ .map(
523
+ (c, i) =>
524
+ `${i}: [${c.kind || 'control'}] "${(c.label || '(no label)').slice(0, 80)}"` +
525
+ (c.cls ? ` .${c.cls}` : '') +
526
+ (c.context ? ` — under "${c.context}"` : ''),
527
+ )
528
+ .join('\n');
529
+
530
+ const ans = await chat(
531
+ llm,
532
+ 'You are reading a web page like a human in order to extract ALL of its content, ' +
533
+ 'including content that stays hidden until you interact. You are given the ' +
534
+ 'interactive controls found in the main content area. Decide which ones, WHEN ' +
535
+ 'CLICKED, would reveal additional readable content that is currently hidden — ' +
536
+ 'e.g. tabs that swap in different text/code, accordions and expanders, ' +
537
+ '"show more"/"read more"/"load more"/"see details", version or platform or ' +
538
+ 'variant switchers. Do NOT pick controls that reveal no new text: ' +
539
+ 'copy/share/print buttons, theme or dark-mode toggles, pure interactive demos ' +
540
+ 'or playgrounds (date pickers, sliders, colour pickers, steppers, rating stars, ' +
541
+ 'carousels of the same widget), cookie notices, or plain links/navigation. ' +
542
+ 'When you are unsure whether a control reveals hidden content, INCLUDE it — ' +
543
+ 'missing content is far worse than one wasted click. Answer with JSON only.',
544
+ `Task (for context only — reveal everything regardless): "${task || ''}"\n\n` +
545
+ `Controls (index: [kind] "label" .class — context):\n${lines}\n\n` +
546
+ 'Reply with {"click":[indexes of controls that reveal hidden content]}.',
547
+ SCHEMAS.reveal,
548
+ 'reveal',
549
+ ).catch(() => '');
550
+
551
+ const j = parseJson(ans);
552
+ if (!j || !Array.isArray(j.click)) return null; // signal: use the fallback
553
+ const keep = new Set(j.click.map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < list.length));
554
+ return new Set([...keep].map((i) => list[i].signature));
555
+ }