crawlforge-mcp-server 5.6.11 → 5.9.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.
@@ -5,6 +5,9 @@
5
5
  * Mirrors the output shape of ScrapeWithActionsTool.generateFormats():
6
6
  * content.html, content.rawHtml, content.text, content.markdown,
7
7
  * content.links, content.metadata, content.screenshots, content.json
8
+ * plus the query-scoped formats (Phase 1): content.highlights for
9
+ * {type:"highlights"} and content.answer for {type:"question"}, both built
10
+ * from the markdown this same call produces, verbatim with offsets into it.
8
11
  *
9
12
  * onlyMainContent maps to Readability boilerplate removal (same as extractContent).
10
13
  * Partial success: per-format warnings[] never fail the whole call.
@@ -12,19 +15,38 @@
12
15
 
13
16
  import { z } from 'zod';
14
17
  import { load } from 'cheerio';
15
- import { documentVerdict } from 'crawlforge-extractors';
16
- import { SCRAPE_STRING_FORMATS, JsonFormatSchema, FormatSchema } from './formats.js';
18
+ import { documentVerdict, segmentUnits, rankUnits } from 'crawlforge-extractors';
19
+ import {
20
+ SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema,
21
+ scrapeFormatSurcharge
22
+ } from './formats.js';
23
+ import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from './escalation.js';
24
+ import { toPublicUnit, parseChosenIndexes, groundingCheck } from './_highlights.js';
25
+ import { setActualCost } from '../../server/requestContext.js';
26
+ import { fenceUntrusted } from '../../utils/untrustedContent.js';
17
27
  import { fetchAndParse } from '../extract/_fetchAndParse.js';
18
28
  import { extractMainContent, isThinMainContent } from './_mainContent.js';
19
29
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
20
30
  import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
21
31
  import { extractBlockText } from '../basic/extractText.js';
22
32
  import { pageTitle } from '../../utils/pageTitle.js';
23
- import { noteHostBlocked, clearHostBlocked } from '../../utils/hostRateLimiter.js';
33
+ import { noteHostBlocked, clearHostBlocked, getHostBlock } from '../../utils/hostRateLimiter.js';
24
34
 
25
35
  // ── Schema ────────────────────────────────────────────────────────────────────
26
36
 
27
- export { SCRAPE_STRING_FORMATS, JsonFormatSchema, FormatSchema };
37
+ export { SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema };
38
+ export { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS };
39
+
40
+ /** scrape's base price, as AuthManager's table spells it. */
41
+ const SCRAPE_BASE_CREDITS = 2;
42
+
43
+ // Above this, a markdown result is the whole page in the context window;
44
+ // the warning names the format that returns only the matching units (1.4).
45
+ const OVERSIZED_MARKDOWN_CHARS = 40000;
46
+ // How many extractive units a question's answer rests on.
47
+ const QUESTION_EVIDENCE_UNITS = 5;
48
+
49
+ const isQueryFormat = (fmt) => Boolean(fmt) && typeof fmt === 'object' && (fmt.type === 'highlights' || fmt.type === 'question');
28
50
 
29
51
  // The six public fields in tools/list order, each carrying the description
30
52
  // the client sees. server.js spreads this with COMPLIANCE_PARAMS instead of
@@ -44,7 +66,9 @@ export const SCRAPE_INPUT_SHAPE = {
44
66
  fullPage: z.boolean().optional().default(false).describe('Capture the full scrollable page'),
45
67
  format: z.enum(['png', 'jpeg']).optional().default('png'),
46
68
  quality: z.number().min(0).max(100).optional().describe('JPEG quality (jpeg only)')
47
- }).optional().describe('Options for the "screenshot" format')
69
+ }).optional().describe('Options for the "screenshot" format'),
70
+ // Opt-in second stage (Phase 3): the plain fetch still runs first.
71
+ ...SCRAPE_ESCALATION_SHAPE
48
72
  };
49
73
 
50
74
  export const UnifiedScrapeSchema = z.object({
@@ -177,6 +201,70 @@ export class UnifiedScrapeTool {
177
201
  // Optional shared ActionExecutor (injected from server.js so we reuse the
178
202
  // existing browser pool rather than spinning up a second one).
179
203
  this._actionExecutor = options.actionExecutor || null;
204
+ // Optional escalation stage (Phase 3), injected from server.js the same
205
+ // way. The tool module must never import StealthBrowserManager itself:
206
+ // that would pull a browser dependency into every unit test that loads
207
+ // `scrape`. Signature:
208
+ // ({ url, engine, respectRobots }) =>
209
+ // { html, url, title, text, status, engine, warnings? }
210
+ // The injected function owns the compliance gate (robots + blocklist) and
211
+ // the engine-name mapping, exactly as the stealth_mode tool does.
212
+ this._escalateScrape = options.escalateScrape || null;
213
+ this._mcpServer = null;
214
+ }
215
+
216
+ /** Wire the MCP server so the mode:"model" step can fall back to client sampling. */
217
+ setMcpServer(mcpServer) {
218
+ this._mcpServer = mcpServer;
219
+ }
220
+
221
+ /**
222
+ * One completion through the SamplingClient chain (Ollama → server keys →
223
+ * MCP sampling); throws when no route exists. Page text must already be
224
+ * fenced by the caller.
225
+ */
226
+ async _complete(prompt, options) {
227
+ const { SamplingClient } = await import('../../core/SamplingClient.js');
228
+ return new SamplingClient({ mcpServer: this._mcpServer }).complete(prompt, options);
229
+ }
230
+
231
+ /**
232
+ * mode:"model" for highlights: the model picks which of the extractive
233
+ * candidates to keep; it never writes text. Returns the chosen units in
234
+ * extractive order, or the extractive top `limit` when the reply does not
235
+ * name any candidate.
236
+ */
237
+ async _chooseHighlights(candidates, query, limit, warnings) {
238
+ const listing = candidates.map((unit, index) => `[${index}] ${unit.text}`).join('\n');
239
+ const { text } = await this._complete(
240
+ `${fenceUntrusted(listing, 'numbered page excerpts')}\nQuery: ${query}\n` +
241
+ `Reply with the numbers of the ${limit} excerpts most relevant to the query.`,
242
+ {
243
+ maxTokens: Math.max(64, limit * 4),
244
+ systemPrompt: 'You choose which numbered excerpts from a web page answer a query. ' +
245
+ 'Reply with the numbers only, comma-separated, most relevant first. Do not write anything else.'
246
+ }
247
+ );
248
+ const chosen = parseChosenIndexes(text, candidates.length, limit);
249
+ if (chosen.length === 0) {
250
+ warnings.push('highlights: the model named no candidate; the extractive order is returned');
251
+ return candidates.slice(0, limit);
252
+ }
253
+ return chosen.sort((a, b) => a - b).map((index) => candidates[index]);
254
+ }
255
+
256
+ /** mode:"model" for question: an answer written from the fenced evidence alone. */
257
+ async _answerQuestion(evidence, question) {
258
+ const { text } = await this._complete(
259
+ `${fenceUntrusted(evidence.map((unit) => unit.text).join('\n'), 'page evidence')}\nQuestion: ${question}\n` +
260
+ 'Answer from the evidence only; if the evidence does not say, say so. Reply with the answer only.',
261
+ {
262
+ maxTokens: 256,
263
+ systemPrompt: 'You answer a question from evidence quoted from a web page. Use only the evidence. ' +
264
+ 'If the evidence does not contain the answer, say that it does not. Reply with the answer only, no preamble.'
265
+ }
266
+ );
267
+ return text.trim();
180
268
  }
181
269
 
182
270
  /** Lazy-load ExtractWithLlm to avoid pulling in heavy deps unless needed. */
@@ -206,59 +294,183 @@ export class UnifiedScrapeTool {
206
294
  const validated = UnifiedScrapeSchema.parse(params);
207
295
  const { url, formats, onlyMainContent, timeoutMs, brandingOptions, screenshotOptions, resolveHiddenContent } = validated;
208
296
 
297
+ // The gate's warnings (e.g. a respect_robots override) travel with the
298
+ // per-format ones, so the caller sees the decision in the response.
299
+ const warnings = [];
300
+ // Whether a model completed for a mode:"model" format, and whether the
301
+ // stealth stage ran; the surcharge for each is charged only then.
302
+ let modelUsed = false;
303
+ let escalationRan = false;
304
+ let stealthEngine = null;
305
+
306
+ // One charge computation for every return path (3.2). The projection is
307
+ // the ceiling a caller saw before the call, so each stage that did not
308
+ // run only ever lowers the bill (G4): the model surcharge when nothing
309
+ // completed, the escalation surcharge when the plain fetch sufficed.
310
+ const surcharge = scrapeFormatSurcharge(formats);
311
+ const reportCost = () => setActualCost(
312
+ SCRAPE_BASE_CREDITS +
313
+ surcharge.query +
314
+ (modelUsed ? surcharge.model : 0) +
315
+ (escalationRan ? SCRAPE_ESCALATION_CREDITS : 0)
316
+ );
317
+
318
+ // Opt-in second stage (Phase 3): the plain fetch runs first and the
319
+ // stealth browser only follows a blocked verdict.
320
+ const escalate = validated.escalate === true;
321
+ // 3.3: a host that walled us within the last 24 hours (0.6's memory) has
322
+ // a doomed plain fetch ahead of it. Read ONLY when escalating — an
323
+ // ordinary scrape must behave exactly as it did before.
324
+ const remembered = escalate ? getHostBlock(url) : null;
325
+
209
326
  // Single fetch
210
- let html, $, finalUrl, fetchWarnings, status;
211
- try {
212
- ({ html, $, finalUrl, warnings: fetchWarnings, status } = await fetchAndParse(url, {
213
- timeoutMs,
214
- userAgent: validated.user_agent,
215
- respectRobots: validated.respect_robots,
216
- tool: 'scrape',
217
- stripTags: [], // we handle boilerplate ourselves
218
- // A real wall reaches a plain fetch as a 403 with the challenge in
219
- // the body; the verdict below needs that body and the status.
220
- errorDocuments: true
221
- }));
222
- } catch (err) {
223
- throw new Error(`scrape: fetch failed for ${url}: ${err.message}`);
327
+ let html, $, fetchWarnings, status, title, verdict;
328
+ let finalUrl = url;
329
+ if (remembered) {
330
+ warnings.push(
331
+ `escalate: ${remembered.vendor || 'a bot wall'} walled this host within the last 24 hours; the plain fetch was skipped and the stealth browser ran first`
332
+ );
333
+ } else {
334
+ try {
335
+ ({ html, $, finalUrl, warnings: fetchWarnings, status } = await fetchAndParse(url, {
336
+ timeoutMs,
337
+ userAgent: validated.user_agent,
338
+ respectRobots: validated.respect_robots,
339
+ tool: 'scrape',
340
+ stripTags: [], // we handle boilerplate ourselves
341
+ // A real wall reaches a plain fetch as a 403 with the challenge in
342
+ // the body; the verdict below needs that body and the status.
343
+ errorDocuments: true
344
+ }));
345
+ } catch (err) {
346
+ throw new Error(`scrape: fetch failed for ${url}: ${err.message}`);
347
+ }
348
+ warnings.push(...fetchWarnings);
349
+
350
+ // A bot wall, an HTTP error page, an empty shell or an error placeholder
351
+ // arrives with a title and prose of its own; reported as a successful
352
+ // scrape it hid the block for three rounds (producthunt.com, R10 Q1 →
353
+ // R15). The stealth path has named these since 5.6.2; this path never
354
+ // looked, and threw away every non-2xx body before it could (Phase 0,
355
+ // 0.1). Scripts stay in $ for the rawHtml and metadata formats,
356
+ // so the text is measured on a copy without them — the vendor tables
357
+ // count the characters a reader would see, as the browser path does.
358
+ // Nothing below runs on a failed verdict: a screenshot would launch a
359
+ // browser on the wall and branding would fetch its stylesheets.
360
+ title = pageTitle($);
361
+ const $visible = load(html);
362
+ $visible('script, style, noscript, template').remove();
363
+ verdict = documentVerdict(
364
+ { url: finalUrl, title, text: $visible('body').text(), html, status },
365
+ { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
366
+ );
367
+ // Host memory (0.6): remember the vendor that walled this host, keyed by
368
+ // the URL the caller passed as well as the final one when a redirect
369
+ // moved hosts, since a later call looks it up by what it was given.
370
+ const hosts = [finalUrl, url];
371
+ if (verdict.blocked) {
372
+ for (const h of hosts) noteHostBlocked(h, verdict.blocked.vendor);
373
+ } else if (verdict.success) {
374
+ for (const h of hosts) clearHostBlocked(h);
375
+ }
224
376
  }
225
377
 
226
- // A bot wall, an HTTP error page, an empty shell or an error placeholder
227
- // arrives with a title and prose of its own; reported as a successful
228
- // scrape it hid the block for three rounds (producthunt.com, R10 Q1 →
229
- // R15). The stealth path has named these since 5.6.2; this path never
230
- // looked, and threw away every non-2xx body before it could (Phase 0,
231
- // 0.1). Scripts stay in $ for the rawHtml and metadata formats,
232
- // so the text is measured on a copy without them — the vendor tables
233
- // count the characters a reader would see, as the browser path does.
234
- // Nothing below runs on a failed verdict: a screenshot would launch a
235
- // browser on the wall and branding would fetch its stylesheets.
236
- const title = pageTitle($);
237
- const $visible = load(html);
238
- $visible('script, style, noscript, template').remove();
239
- const verdict = documentVerdict(
240
- { url: finalUrl, title, text: $visible('body').text(), html, status },
241
- { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
242
- );
243
- // Host memory (0.6): remember the vendor that walled this host, keyed by
244
- // the URL the caller passed as well as the final one when a redirect
245
- // moved hosts, since a later call looks it up by what it was given.
246
- const hosts = [finalUrl, url];
247
- if (verdict.blocked) {
248
- for (const h of hosts) noteHostBlocked(h, verdict.blocked.vendor);
249
- } else if (verdict.success) {
250
- for (const h of hosts) clearHostBlocked(h);
378
+ // ── Escalation (Phase 3) ─────────────────────────────────────────────
379
+ // Opt-in, and second by construction: the plain fetch above has already
380
+ // run and only a blocked verdict gets here (G1 — never start with
381
+ // stealth). The stage reuses the compliance gate and the browser
382
+ // `stealth_mode` already drives, so it adds no evasion of its own.
383
+ // The vendor the plain fetch hit, or the one this host is remembered for
384
+ // when that fetch was skipped.
385
+ const vendorDetected = remembered ? (remembered.vendor ?? null) : (verdict?.blocked?.vendor ?? null);
386
+ // Set when the stage was asked for and could not run at all; it is the
387
+ // only thing left to report when the plain fetch was skipped as well.
388
+ let escalationError = null;
389
+
390
+ // Only a failure a browser could plausibly change is worth the second
391
+ // stage: a wall or a bare 403/429, or a page that failed with a 2xx (an
392
+ // empty shell renders). A 404 or a 5xx escalates nothing — see
393
+ // aBrowserMightPass.
394
+ if (escalate && (remembered || (!verdict.success && aBrowserMightPass(verdict, status)))) {
395
+ if (!this._escalateScrape) {
396
+ escalationError = 'no stealth stage is wired into this server build';
397
+ warnings.push(`escalate: ${escalationError}`);
398
+ } else {
399
+ try {
400
+ const stealth = await this._escalateScrape({
401
+ url: remembered ? url : finalUrl,
402
+ engine: validated.escalate_engine,
403
+ respectRobots: validated.respect_robots
404
+ });
405
+ escalationRan = true;
406
+ stealthEngine = stealth.engine || validated.escalate_engine;
407
+ if (Array.isArray(stealth.warnings)) warnings.push(...stealth.warnings);
408
+
409
+ // The stealth render replaces the document every format below is
410
+ // built from, so an escalated page goes through the SAME formats
411
+ // loop a plain fetch does — highlights and question included.
412
+ html = stealth.html || '';
413
+ finalUrl = stealth.url || finalUrl;
414
+ status = stealth.status ?? null;
415
+ $ = load(html);
416
+ title = stealth.title || pageTitle($);
417
+ const $rendered = load(html);
418
+ $rendered('script, style, noscript, template').remove();
419
+ // Re-run the verdict on what the browser rendered: a wall that
420
+ // survives the stealth pass must not come back as a success.
421
+ verdict = documentVerdict(
422
+ { url: finalUrl, title, text: stealth.text || $rendered('body').text(), html, status },
423
+ { waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
424
+ );
425
+ // Escalation fires on ANY failed verdict, not only a named vendor —
426
+ // an empty shell and an error placeholder are exactly the cases a
427
+ // browser fixes — so the wording must not call every one a block.
428
+ warnings.push(
429
+ `escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
430
+ `the ${stealthEngine} stealth browser ${verdict.success ? 'returned it' : 'did not get it either'}`
431
+ );
432
+ // The host memory is deliberately left alone here. It records what
433
+ // a PLAIN fetch met, which is what 3.3 reads before deciding to
434
+ // skip one; a stealth success says nothing about that, and only a
435
+ // clean plain fetch clears the entry (0.6).
436
+ } catch (err) {
437
+ // The gate refuses a disallowed URL by throwing, after stamping
438
+ // markPreflightRefusal('ROBOTS_DISALLOWED') on the request context.
439
+ // That flag makes withAuth bill the WHOLE call zero — plain fetch
440
+ // included — which is the intended outcome: we never rendered the
441
+ // page we were refused. Caught here so a refusal is a warning on
442
+ // the verdict we already have rather than a throw out of the tool.
443
+ escalationError = err.message;
444
+ warnings.push(`escalate: the stealth retry did not run — ${escalationError}`);
445
+ }
446
+ }
251
447
  }
252
- if (!verdict.success) {
448
+
449
+ // `escalated` is reported only to a caller who asked to escalate: a call
450
+ // that never asked keeps exactly the result shape it had before.
451
+ const escalationFields = escalate
452
+ ? {
453
+ escalated: escalationRan,
454
+ ...(escalationRan ? { stealth: { engine: stealthEngine, vendor_detected: vendorDetected } } : {})
455
+ }
456
+ : {};
457
+
458
+ if (!verdict || !verdict.success) {
459
+ reportCost();
253
460
  return {
254
461
  success: false,
255
462
  url: finalUrl,
256
- status,
463
+ // A stealth render reports no status when nothing navigated, and a
464
+ // skipped plain fetch reports none at all.
465
+ ...(typeof status === 'number' ? { status } : {}),
257
466
  title,
258
- error: verdict.error,
259
- ...(verdict.blocked ? { blocked: verdict.blocked } : {}),
467
+ error: verdict
468
+ ? verdict.error
469
+ : `The plain fetch was skipped because ${vendorDetected || 'a bot wall'} walled this host within the last 24 hours, and the stealth retry did not run: ${escalationError}`,
470
+ ...(verdict?.blocked ? { blocked: verdict.blocked } : {}),
471
+ ...escalationFields,
260
472
  content: {},
261
- warnings: fetchWarnings.length > 0 ? fetchWarnings : undefined
473
+ warnings: warnings.length > 0 ? warnings : undefined
262
474
  };
263
475
  }
264
476
 
@@ -293,10 +505,22 @@ export class UnifiedScrapeTool {
293
505
  return mainHtml;
294
506
  }
295
507
 
508
+ // The markdown format and the query-scoped formats read the same string,
509
+ // produced once, so a highlight's offset always indexes what `markdown`
510
+ // returned at this call's onlyMainContent.
511
+ let markdown = null;
512
+ function getMarkdown() {
513
+ if (markdown === null) {
514
+ markdown = onlyMainContent ? htmlToMarkdown(getMainHtml()) : htmlToMarkdown($.html('body') || html);
515
+ }
516
+ return markdown;
517
+ }
518
+ let units = null;
519
+ function getUnits() {
520
+ if (units === null) units = segmentUnits(getMarkdown());
521
+ return units;
522
+ }
296
523
  const content = {};
297
- // The gate's warnings (e.g. a respect_robots override) travel with the
298
- // per-format ones, so the caller sees the decision in the response.
299
- const warnings = [...fetchWarnings];
300
524
 
301
525
  // Kept for the rawHtml format, which must survive the strip below.
302
526
  const pristineHtml = html;
@@ -383,13 +607,66 @@ export class UnifiedScrapeTool {
383
607
  continue;
384
608
  }
385
609
 
610
+ // Query-scoped formats (Phase 1): verbatim units of this call's
611
+ // markdown, ranked against the query. mode:"model" lets a model choose
612
+ // among (highlights) or answer from (question) those units; when no
613
+ // LLM route exists the extractive result stands and the model
614
+ // surcharge is dropped below. An empty match is a warning, not an error.
615
+ if (isQueryFormat(fmt)) {
616
+ const modelUnavailable = (label) =>
617
+ warnings.push(`${label}: mode "model" was unavailable (no Ollama, API key or client sampling); the extractive result is returned at the extractive price`);
618
+ try {
619
+ if (fmt.type === 'highlights') {
620
+ let chosen = rankUnits(getUnits(), fmt.query, { maxUnits: fmt.max_highlights });
621
+ if (fmt.mode === 'model' && chosen.length > 0) {
622
+ const candidates = rankUnits(getUnits(), fmt.query, { maxUnits: fmt.max_highlights * 3 });
623
+ if (candidates.length > fmt.max_highlights) {
624
+ try {
625
+ chosen = await this._chooseHighlights(candidates, fmt.query, fmt.max_highlights, warnings);
626
+ modelUsed = true;
627
+ } catch {
628
+ modelUnavailable('highlights');
629
+ }
630
+ } else {
631
+ warnings.push('highlights: no more candidates than max_highlights, so the model had nothing to choose; charged at the extractive price');
632
+ }
633
+ }
634
+ content.highlights = chosen.map(toPublicUnit);
635
+ if (chosen.length === 0) warnings.push(`highlights: no sentence, table row or code block matched "${fmt.query}"`);
636
+ } else {
637
+ const evidence = rankUnits(getUnits(), fmt.question, { maxUnits: QUESTION_EVIDENCE_UNITS });
638
+ const evidenceText = evidence.map((unit) => unit.text).join('\n');
639
+ let text = evidenceText;
640
+ let grounded = true;
641
+ if (fmt.mode === 'model' && evidence.length > 0) {
642
+ try {
643
+ text = await this._answerQuestion(evidence, fmt.question);
644
+ modelUsed = true;
645
+ const check = groundingCheck(text, evidenceText, fmt.question);
646
+ grounded = check.grounded;
647
+ if (!grounded) {
648
+ warnings.push(`question: the model's answer has ${check.unbacked.length} token(s) not found in the evidence or the question (${check.unbacked.join(', ')}); grounded: false`);
649
+ }
650
+ } catch {
651
+ modelUnavailable('question');
652
+ }
653
+ }
654
+ content.answer = { text, grounded, evidence: evidence.map(toPublicUnit) };
655
+ if (evidence.length === 0) warnings.push(`question: no sentence, table row or code block matched "${fmt.question}"`);
656
+ }
657
+ } catch (err) {
658
+ if (fmt.type === 'highlights') content.highlights = [];
659
+ else content.answer = { text: '', grounded: true, evidence: [] };
660
+ warnings.push(`${fmt.type}: ${err.message}`);
661
+ }
662
+ continue;
663
+ }
664
+
386
665
  // String formats
387
666
  switch (fmt) {
388
667
  case 'markdown':
389
668
  try {
390
- content.markdown = onlyMainContent
391
- ? htmlToMarkdown(getMainHtml())
392
- : htmlToMarkdown($.html('body') || html);
669
+ content.markdown = getMarkdown();
393
670
  } catch (err) {
394
671
  content.markdown = '';
395
672
  warnings.push(`markdown: ${err.message}`);
@@ -501,9 +778,22 @@ export class UnifiedScrapeTool {
501
778
  }
502
779
  }
503
780
 
781
+ const queryScoped = formats.some(isQueryFormat);
782
+ if (queryScoped && !formats.includes('markdown')) {
783
+ warnings.push('offsets index the "markdown" format of this call (same onlyMainContent); add "markdown" to formats to quote with a locator');
784
+ }
785
+ if (!queryScoped && typeof content.markdown === 'string' && content.markdown.length > OVERSIZED_MARKDOWN_CHARS) {
786
+ warnings.push(`markdown: ${content.markdown.length} characters; ask for {type:"highlights", query} (1 extra credit, no model) to get only the matching sentences, table rows and code blocks`);
787
+ }
788
+ // What this call actually spent: the model step and the escalation each
789
+ // drop out when they did not run (withAuth clamps to the projection
790
+ // either way, so this can only lower the charge).
791
+ reportCost();
792
+
504
793
  return {
505
794
  success: true,
506
795
  url: finalUrl,
796
+ ...escalationFields,
507
797
  content,
508
798
  warnings: warnings.length > 0 ? warnings : undefined
509
799
  };