crawlforge-mcp-server 5.3.0 → 5.4.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,304 @@
1
+ /**
2
+ * embeddedState.js — find the JSON state a page already ships in its own HTML.
3
+ *
4
+ * Modern SPAs serialize the data their UI renders into the document: Next.js
5
+ * pages carry __NEXT_DATA__ or an RSC flight stream, Nuxt carries __NUXT__,
6
+ * Apollo/Redux apps carry __APOLLO_STATE__ / __INITIAL_STATE__ /
7
+ * __PRELOADED_STATE__. One fetch returns the exact values the site itself uses
8
+ * — no LLM in the extraction path, so no fabricated prices.
9
+ *
10
+ * Pure: HTML in, named payloads out. No fetching, no cheerio — every source
11
+ * lives inside a <script> tag, so one regex pass over script tags is enough
12
+ * and avoids a second full parse of a multi-megabyte document.
13
+ */
14
+
15
+ /**
16
+ * Global variable assignments we look for, in the order they are reported.
17
+ * Each is matched with an optional window./self./globalThis. prefix.
18
+ * `name` is the path-safe key the caller addresses with a JSON path — the
19
+ * raw variable name has no dots, but keeping the two separate means a caller
20
+ * never has to guess how "self.__next_f" would be spelled in a path.
21
+ */
22
+ const STATE_VARIABLES = [
23
+ { name: 'nuxt', variable: '__NUXT__' },
24
+ { name: 'apollo_state', variable: '__APOLLO_STATE__' },
25
+ { name: 'initial_state', variable: '__INITIAL_STATE__' },
26
+ { name: 'preloaded_state', variable: '__PRELOADED_STATE__' }
27
+ ];
28
+
29
+ // Script bodies cannot contain a literal "</script", so a non-greedy match is
30
+ // exact here — the same assumption every HTML parser makes.
31
+ const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
32
+
33
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
34
+
35
+ // self.__next_f.push([1,"<chunk>"]) — Next.js App Router RSC flight chunks.
36
+ const NEXT_F_PUSH_RE = /self\.__next_f\.push\(\s*\[\s*1\s*,\s*/g;
37
+
38
+ /**
39
+ * Read an HTML attribute out of a raw tag's attribute string.
40
+ * @param {string} attrs
41
+ * @param {string} name
42
+ * @returns {string|null}
43
+ */
44
+ function attr(attrs, name) {
45
+ const match = attrs.match(
46
+ new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i')
47
+ );
48
+ if (!match) return null;
49
+ return match[1] ?? match[2] ?? match[3] ?? null;
50
+ }
51
+
52
+ /**
53
+ * Scan a double-quoted string literal starting at `start` and return its
54
+ * parsed value plus the index just past its closing quote.
55
+ * @param {string} text
56
+ * @param {number} start index of the opening quote
57
+ * @returns {{ value: string, end: number }|null}
58
+ */
59
+ function readStringLiteral(text, start) {
60
+ if (text[start] !== '"') return null;
61
+ let i = start + 1;
62
+ while (i < text.length) {
63
+ const ch = text[i];
64
+ if (ch === '\\') { i += 2; continue; }
65
+ if (ch === '"') break;
66
+ i++;
67
+ }
68
+ if (i >= text.length) return null;
69
+ try {
70
+ return { value: JSON.parse(text.slice(start, i + 1)), end: i + 1 };
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Scan a balanced {...} / [...] literal starting at `start`, respecting string
78
+ * literals and escapes so braces inside strings don't end it early.
79
+ * @param {string} text
80
+ * @param {number} start
81
+ * @returns {string|null} the raw literal, or null if it never closes
82
+ */
83
+ function readBracketedLiteral(text, start) {
84
+ const open = text[start];
85
+ const close = open === '{' ? '}' : open === '[' ? ']' : null;
86
+ if (!close) return null;
87
+ let depth = 0;
88
+ let i = start;
89
+ while (i < text.length) {
90
+ const ch = text[i];
91
+ if (ch === '"' || ch === "'") {
92
+ const quote = ch;
93
+ i++;
94
+ while (i < text.length && text[i] !== quote) {
95
+ if (text[i] === '\\') i++;
96
+ i++;
97
+ }
98
+ } else if (ch === open) {
99
+ depth++;
100
+ } else if (ch === close) {
101
+ depth--;
102
+ if (depth === 0) return text.slice(start, i + 1);
103
+ }
104
+ i++;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Split a concatenated RSC flight stream into its rows.
111
+ *
112
+ * The stream is a sequence of `<hexId>:<payload>\n` rows. Three payload shapes
113
+ * matter:
114
+ * - `T<hexByteLength>,` — a length-prefixed text blob. The length is in
115
+ * UTF-8 BYTES and INCLUDES the row's terminating newline, so the cursor
116
+ * advances exactly that many bytes and no further. (Advancing one extra
117
+ * character for a newline silently eats the first hex digit of the next
118
+ * row id, turning row "14" into row "4" and overwriting an unrelated row —
119
+ * verified against the live Healthgrades capture, where it produced seven
120
+ * colliding ids.)
121
+ * - `I[...]` / `HL[...]` — module and hint references. Not JSON; kept as the
122
+ * raw string so the caller can still see which components a page loads.
123
+ * - anything else — JSON, parsed.
124
+ *
125
+ * @param {string} stream
126
+ * @returns {Record<string, unknown>} row id -> value
127
+ */
128
+ function parseFlightRows(stream) {
129
+ const rows = {};
130
+ let cursor = 0;
131
+
132
+ while (cursor < stream.length) {
133
+ const newline = stream.indexOf('\n', cursor);
134
+ const lineEnd = newline === -1 ? stream.length : newline;
135
+ const header = stream.slice(cursor, lineEnd).match(/^([0-9a-f]+):/i);
136
+ if (!header) {
137
+ // Not a row start: a chunk boundary landed mid-row, or the stream was
138
+ // truncated. Resync on the next line rather than giving up.
139
+ cursor = lineEnd + 1;
140
+ continue;
141
+ }
142
+
143
+ const id = header[1];
144
+ const payloadStart = cursor + header[0].length;
145
+ const payload = stream.slice(payloadStart, lineEnd);
146
+
147
+ const textRow = payload.match(/^T([0-9a-f]+),/i);
148
+ if (textRow) {
149
+ const blobStart = payloadStart + textRow[0].length;
150
+ const text = Buffer.from(stream.slice(blobStart), 'utf8')
151
+ .subarray(0, parseInt(textRow[1], 16))
152
+ .toString('utf8');
153
+ rows[id] = text;
154
+ cursor = blobStart + text.length;
155
+ continue;
156
+ }
157
+
158
+ try {
159
+ rows[id] = JSON.parse(payload);
160
+ } catch {
161
+ rows[id] = payload;
162
+ }
163
+ cursor = lineEnd + 1;
164
+ }
165
+
166
+ return rows;
167
+ }
168
+
169
+ /**
170
+ * Collect every `self.__next_f.push([1,"…"])` chunk in document order and
171
+ * concatenate them into the flight stream they encode.
172
+ * @param {string} html
173
+ * @returns {{ chunks: number, stream: string }}
174
+ */
175
+ function readFlightStream(html) {
176
+ const parts = [];
177
+ NEXT_F_PUSH_RE.lastIndex = 0;
178
+ let match;
179
+ while ((match = NEXT_F_PUSH_RE.exec(html)) !== null) {
180
+ const literal = readStringLiteral(html, NEXT_F_PUSH_RE.lastIndex);
181
+ if (!literal) continue;
182
+ parts.push(literal.value);
183
+ NEXT_F_PUSH_RE.lastIndex = literal.end;
184
+ }
185
+ return { chunks: parts.length, stream: parts.join('') };
186
+ }
187
+
188
+ const serializedBytes = (value) => Buffer.byteLength(JSON.stringify(value) ?? '');
189
+
190
+ /**
191
+ * Extract every embedded state payload a page carries.
192
+ *
193
+ * @param {string} rawHtml raw HTML — NOT a script-stripped document
194
+ * @returns {{ data: Record<string, unknown>, found: Array<{name: string, variable: string, bytes: number, note?: string}>, warnings: string[] }}
195
+ */
196
+ export function extractEmbeddedState(rawHtml) {
197
+ const data = {};
198
+ const found = [];
199
+ const warnings = [];
200
+ const jsonScripts = [];
201
+
202
+ // Commented-out markup is not state the page renders from, and an opening
203
+ // <script> tag inside a comment would otherwise match through to the first
204
+ // real </script> — swallowing the genuine tag that follows it.
205
+ const html = rawHtml.replace(HTML_COMMENT_RE, '');
206
+
207
+ SCRIPT_RE.lastIndex = 0;
208
+ let script;
209
+ while ((script = SCRIPT_RE.exec(html)) !== null) {
210
+ const [, attrs, body] = script;
211
+ const type = (attr(attrs, 'type') || '').toLowerCase();
212
+ if (type !== 'application/json') continue;
213
+
214
+ const id = attr(attrs, 'id');
215
+ let parsed;
216
+ try {
217
+ parsed = JSON.parse(body);
218
+ } catch {
219
+ warnings.push(
220
+ `A <script type="application/json"${id ? ` id="${id}"` : ''}> block is not valid JSON; skipped.`
221
+ );
222
+ continue;
223
+ }
224
+
225
+ // __NEXT_DATA__ is a JSON script too. Reported under its own name so a
226
+ // caller addressing "next_data" gets it, and not duplicated into
227
+ // json_scripts — on a Next.js page that would double a multi-hundred-KB
228
+ // payload.
229
+ if (id === '__NEXT_DATA__') {
230
+ data.next_data = parsed;
231
+ found.push({
232
+ name: 'next_data',
233
+ variable: '__NEXT_DATA__',
234
+ bytes: serializedBytes(parsed)
235
+ });
236
+ } else {
237
+ jsonScripts.push({ id: id || null, data: parsed });
238
+ }
239
+ }
240
+
241
+ const flight = readFlightStream(html);
242
+ if (flight.chunks > 0) {
243
+ const rows = parseFlightRows(flight.stream);
244
+ data.next_f = rows;
245
+ found.push({
246
+ name: 'next_f',
247
+ variable: 'self.__next_f',
248
+ bytes: serializedBytes(rows),
249
+ note: `${flight.chunks} RSC flight chunks concatenated into ${Object.keys(rows).length} rows, keyed by row id`
250
+ });
251
+ }
252
+
253
+ for (const { name, variable } of STATE_VARIABLES) {
254
+ const assignment = html.match(
255
+ new RegExp(`(?:window|self|globalThis)?\\.?\\b${variable}\\s*=\\s*`)
256
+ );
257
+ if (!assignment) continue;
258
+
259
+ const valueStart = assignment.index + assignment[0].length;
260
+ const literal = readBracketedLiteral(html, valueStart);
261
+ let parsed;
262
+ if (literal !== null) {
263
+ try {
264
+ parsed = JSON.parse(literal);
265
+ } catch {
266
+ parsed = undefined;
267
+ }
268
+ }
269
+
270
+ if (parsed === undefined) {
271
+ // Nuxt 2 wraps its payload in an IIFE, and Nuxt 3 emits a bare JS object
272
+ // literal with unquoted keys. Neither is JSON and neither is worth
273
+ // eval()ing — say so instead of reporting a source we did not read.
274
+ warnings.push(
275
+ `${variable} is present but its value is not a JSON literal (a JS object literal or function-wrapped payload); not parsed.`
276
+ );
277
+ continue;
278
+ }
279
+
280
+ // Nuxt 3 emits `window.__NUXT__={}` and then fills it from a second,
281
+ // non-JSON statement. Reporting the empty object without saying so reads
282
+ // like "this page has no Nuxt state", which is the opposite of true.
283
+ if (Object.keys(parsed).length === 0) {
284
+ warnings.push(
285
+ `${variable} is present but assigned an empty ${Array.isArray(parsed) ? 'array' : 'object'}; the page fills it in from a later statement this tool does not evaluate.`
286
+ );
287
+ }
288
+
289
+ data[name] = parsed;
290
+ found.push({ name, variable, bytes: serializedBytes(parsed) });
291
+ }
292
+
293
+ if (jsonScripts.length > 0) {
294
+ data.json_scripts = jsonScripts;
295
+ found.push({
296
+ name: 'json_scripts',
297
+ variable: 'script[type="application/json"]',
298
+ bytes: serializedBytes(jsonScripts),
299
+ note: `${jsonScripts.length} block(s), each { id, data }`
300
+ });
301
+ }
302
+
303
+ return { data, found, warnings };
304
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * JSON-LD parsing and schema.org type filtering.
3
+ *
4
+ * Pure: no fetching. Callers pass a loaded cheerio document (parse) or already
5
+ * parsed blocks (filter).
6
+ */
7
+
8
+ /**
9
+ * schema.org descendants that a filter on the parent type must also match.
10
+ *
11
+ * Real pages publish the specific subtype and almost never the parent, so an
12
+ * exact-string @type match returns nothing on exactly the pages callers ask
13
+ * about: ticketmaster.com/discover/concerts emits MusicEvent (never Event),
14
+ * apple.com/shop/buy-mac/macbook-air emits AggregateOffer and BreadcrumbList
15
+ * (never Offer or ItemList). Each list is the transitive descendant set of the
16
+ * key in schema.org v30. Types outside this table are matched exactly.
17
+ */
18
+ export const JSON_LD_SUBTYPES = {
19
+ ItemList: ['BreadcrumbList', 'HowToSection', 'HowToStep', 'OfferCatalog'],
20
+ Product: [
21
+ 'DietarySupplement', 'Drug', 'IndividualProduct', 'ProductCollection',
22
+ 'ProductGroup', 'ProductModel', 'SomeProducts', 'Vehicle', 'BusOrCoach',
23
+ 'Car', 'Motorcycle', 'MotorizedBicycle'
24
+ ],
25
+ Offer: ['AggregateOffer', 'OfferForLease', 'OfferForPurchase'],
26
+ Event: [
27
+ 'BusinessEvent', 'ChildrensEvent', 'ComedyEvent', 'CourseInstance',
28
+ 'DanceEvent', 'DeliveryEvent', 'EducationEvent', 'EventSeries',
29
+ 'ExhibitionEvent', 'Festival', 'FoodEvent', 'Hackathon', 'LiteraryEvent',
30
+ 'MusicEvent', 'PublicationEvent', 'BroadcastEvent', 'OnDemandEvent',
31
+ 'SaleEvent', 'ScreeningEvent', 'SocialEvent', 'SportsEvent', 'TheaterEvent',
32
+ 'VisualArtsEvent'
33
+ ],
34
+ JobPosting: [],
35
+ RealEstateListing: []
36
+ };
37
+
38
+ // Lowercased filter name → set of lowercased @type values it accepts.
39
+ const MATCH_SETS = new Map(
40
+ Object.entries(JSON_LD_SUBTYPES).map(([parent, subtypes]) => [
41
+ parent.toLowerCase(),
42
+ new Set([parent, ...subtypes].map((t) => t.toLowerCase()))
43
+ ])
44
+ );
45
+
46
+ /**
47
+ * Some publishers write @type as a full IRI ("https://schema.org/Product").
48
+ * @param {unknown} value
49
+ * @returns {string|null}
50
+ */
51
+ function normalizeType(value) {
52
+ if (typeof value !== 'string') return null;
53
+ return value.replace(/^https?:\/\/schema\.org\//, '').trim().toLowerCase() || null;
54
+ }
55
+
56
+ /**
57
+ * Parse all JSON-LD blocks from the document. A malformed block is skipped so
58
+ * one bad block does not lose the good ones.
59
+ * @param {import('cheerio').CheerioAPI} $
60
+ * @returns {Array}
61
+ */
62
+ export function parseJsonLd($) {
63
+ const results = [];
64
+ $('script[type="application/ld+json"]').each((_, el) => {
65
+ try {
66
+ const raw = $(el).html();
67
+ if (raw) results.push(JSON.parse(raw));
68
+ } catch {
69
+ // Skip invalid blocks
70
+ }
71
+ });
72
+ return results;
73
+ }
74
+
75
+ /**
76
+ * Collect the JSON-LD nodes matching the requested schema.org types.
77
+ *
78
+ * Nodes are found at any depth, so @graph wrappers, top-level arrays and types
79
+ * nested inside another node (an Offer inside an Event) are all reachable.
80
+ * @type itself may be a string or an array of strings.
81
+ *
82
+ * @param {Array} blocks - parsed JSON-LD blocks, as returned by parseJsonLd
83
+ * @param {string[]} types - schema.org type names to keep
84
+ * @returns {{ items: Array, counts: Record<string, number> }} matching nodes in
85
+ * document order, and how many matched per requested type
86
+ */
87
+ export function filterJsonLdByType(blocks, types) {
88
+ const wanted = types.map((requested) => ({
89
+ requested,
90
+ match: MATCH_SETS.get(requested.toLowerCase()) || new Set([requested.toLowerCase()])
91
+ }));
92
+ const counts = Object.fromEntries(types.map((t) => [t, 0]));
93
+ const items = [];
94
+
95
+ const visit = (node) => {
96
+ if (Array.isArray(node)) {
97
+ node.forEach(visit);
98
+ return;
99
+ }
100
+ if (!node || typeof node !== 'object') return;
101
+
102
+ const raw = node['@type'];
103
+ const nodeTypes = (Array.isArray(raw) ? raw : [raw]).map(normalizeType).filter(Boolean);
104
+ if (nodeTypes.length) {
105
+ let matched = false;
106
+ for (const { requested, match } of wanted) {
107
+ if (nodeTypes.some((t) => match.has(t))) {
108
+ counts[requested] += 1;
109
+ matched = true;
110
+ }
111
+ }
112
+ if (matched) items.push(node);
113
+ }
114
+
115
+ // Descend even into a matched node: its children may match another
116
+ // requested type (Ticketmaster nests each Offer inside its MusicEvent).
117
+ for (const value of Object.values(node)) visit(value);
118
+ };
119
+
120
+ blocks.forEach(visit);
121
+ return { items, counts };
122
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * jsonPath.js — the minimal subtree selector for extract_embedded_state.
3
+ *
4
+ * Deliberately not a JSONPath engine: dotted keys and array indexes only, no
5
+ * wildcards, filters, slices or recursive descent. That is enough to turn a
6
+ * multi-megabyte state blob into the branch a caller actually wants, and a
7
+ * caller who needs more can select a branch and filter it themselves.
8
+ *
9
+ * Syntax: `next_data.props.pageProps`, `json_scripts.0.data`, `next_f[1a]`,
10
+ * `a.b[0].c`. A key containing a literal "." cannot be addressed.
11
+ */
12
+
13
+ /**
14
+ * Split a path into its segments.
15
+ * @param {string} path
16
+ * @returns {string[]}
17
+ */
18
+ export function parseJsonPath(path) {
19
+ const segments = [];
20
+ for (const part of path.split('.')) {
21
+ // "a[0][1]" -> "a", "0", "1"
22
+ const [head, ...brackets] = part.split('[');
23
+ if (head !== '') segments.push(head);
24
+ for (const bracket of brackets) {
25
+ const key = bracket.endsWith(']') ? bracket.slice(0, -1) : bracket;
26
+ segments.push(key.replace(/^['"]|['"]$/g, ''));
27
+ }
28
+ }
29
+ return segments.filter((segment) => segment !== '');
30
+ }
31
+
32
+ /**
33
+ * Describe what a caller could have asked for at a dead end, so a typo comes
34
+ * back as a fixable message rather than an empty result.
35
+ * @param {unknown} value
36
+ * @returns {string}
37
+ */
38
+ function describeOptions(value) {
39
+ if (Array.isArray(value)) return `array of length ${value.length}`;
40
+ if (value !== null && typeof value === 'object') {
41
+ const keys = Object.keys(value);
42
+ const shown = keys.slice(0, 25).join(', ');
43
+ return `available keys: ${shown}${keys.length > 25 ? `, … (${keys.length} total)` : ''}`;
44
+ }
45
+ return `a ${value === null ? 'null' : typeof value} value, which has no keys`;
46
+ }
47
+
48
+ /**
49
+ * Resolve a path against a parsed object.
50
+ *
51
+ * @param {unknown} root
52
+ * @param {string} path
53
+ * @returns {unknown}
54
+ * @throws {Error} when the path does not resolve — including the keys that
55
+ * were available at the point it stopped
56
+ */
57
+ export function selectJsonPath(root, path) {
58
+ const segments = parseJsonPath(path);
59
+ if (segments.length === 0) {
60
+ throw new Error(`Path "${path}" is empty`);
61
+ }
62
+
63
+ let current = root;
64
+ const walked = [];
65
+
66
+ for (const segment of segments) {
67
+ const container = current !== null && typeof current === 'object';
68
+ const key = Array.isArray(current) ? Number(segment) : segment;
69
+ if (!container || !(key in current)) {
70
+ const at = walked.length === 0 ? 'the result' : `"${walked.join('.')}"`;
71
+ throw new Error(
72
+ `Path "${path}" not found: ${at} has no "${segment}" (${describeOptions(current)})`
73
+ );
74
+ }
75
+ current = current[key];
76
+ walked.push(segment);
77
+ }
78
+
79
+ return current;
80
+ }
@@ -55,6 +55,32 @@ const PREFERRED_MODELS = [
55
55
  'qwen2.5:3b'
56
56
  ];
57
57
 
58
+ /**
59
+ * Models measured fit to JUDGE claims — relevance to a topic, same-meaning
60
+ * grouping, and contradiction — as opposed to extracting fields. Measured
61
+ * 2026-08-28 by replaying a live deep_research run's own 136 claims through
62
+ * each installed model, three runs each:
63
+ *
64
+ * gemma3:12b 0 false contradictions on 27 real pairs, 3/3 planted caught,
65
+ * 7-9 cross-source groups (the 4B model: 1-2 false, 0-1/3
66
+ * caught, 1 group)
67
+ * gemma3:4b the extraction winner, but it scored "Playwright vs Selenium"
68
+ * marketing 0.9 relevant to an anti-bot topic and put it in the
69
+ * research summary
70
+ * gemma4:31b judged as cleanly as gemma3:12b but only with thinking turned
71
+ * off — under the default it spends the whole token budget on
72
+ * hidden reasoning and returns empty content — and it grouped so
73
+ * strictly that consensus vanished. Not ranked.
74
+ * gpt-oss:20b empty content at these token budgets for the same reason,
75
+ * and `think: false` makes it emit nothing at all. Not ranked.
76
+ *
77
+ * Membership here is what turns conflict detection on: a model that invents
78
+ * disagreement between sources that agree is worse than one that reports none,
79
+ * so a model absent from this list is never asked. When none is installed the
80
+ * judgement role falls through to the extraction ranking above.
81
+ */
82
+ export const JUDGEMENT_MODELS = ['gemma3:12b'];
83
+
58
84
  /** Used only when Ollama cannot be reached, so the error names a real model. */
59
85
  export const FALLBACK_OLLAMA_MODEL = 'llama3.2';
60
86
 
@@ -102,9 +128,11 @@ export async function installedOllamaModels() {
102
128
  * instead would break anyone who has not pulled it, so the best *installed*
103
129
  * model is chosen, and an explicit OLLAMA_DEFAULT_MODEL always wins.
104
130
  *
131
+ * @param {'default'|'judgement'} [role] 'judgement' tries JUDGEMENT_MODELS
132
+ * first and falls through to the extraction ranking when none is installed.
105
133
  * @returns {Promise<string>}
106
134
  */
107
- export async function selectOllamaModel() {
135
+ export async function selectOllamaModel(role = 'default') {
108
136
  const explicit = process.env.OLLAMA_DEFAULT_MODEL;
109
137
  if (explicit) return explicit;
110
138
 
@@ -112,10 +140,16 @@ export async function selectOllamaModel() {
112
140
  if (installed.length === 0) return FALLBACK_OLLAMA_MODEL;
113
141
 
114
142
  const byBase = new Map(installed.map((name) => [baseName(name), name]));
115
- for (const preferred of PREFERRED_MODELS) {
143
+ const ranking = role === 'judgement' ? [...JUDGEMENT_MODELS, ...PREFERRED_MODELS] : PREFERRED_MODELS;
144
+ for (const preferred of ranking) {
116
145
  const match = byBase.get(baseName(preferred));
117
146
  if (match) return match;
118
147
  }
119
148
  // Nothing recognised — use whatever is there rather than failing.
120
149
  return installed[0];
121
150
  }
151
+
152
+ /** Whether a model name is one measured fit to judge contradictions. */
153
+ export function isJudgementModel(name) {
154
+ return typeof name === 'string' && JUDGEMENT_MODELS.some((m) => baseName(m) === baseName(name));
155
+ }