champollion-mcp-server 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,329 @@
1
+ /**
2
+ * Language tools — search and browse Champollion language cards.
3
+ *
4
+ * Card data comes from ONE of three sources, tried in order:
5
+ * 1. CHAMPOLLION_CARDS_DIR — explicit override (same variable the CLI and
6
+ * the Python harness honor). Set-but-unusable is an immediate error:
7
+ * the user pointed somewhere specific, and silently reading a different
8
+ * corpus would be worse than failing.
9
+ * 2. The monorepo checkout's cli/shared/language-cards/ (full corpus).
10
+ * 3. The champollion package's bundled shared/cards-fallback.json — the
11
+ * same bundle the published CLI runs on: 1,157 full cards plus a
12
+ * manifest of every concrete code → name/aliases, so the FULL catalogue
13
+ * is searchable by name offline, with rich fields on the core set and
14
+ * honest absences elsewhere.
15
+ *
16
+ * NOTHING is hardcoded and nothing degrades silently. This file used to
17
+ * carry a hand-written 40-language FALLBACK_INDEX served with only a stderr
18
+ * note — an agent outside the repo was told "No languages found" for ~7,887
19
+ * real languages, indistinguishable from an authoritative answer (and the
20
+ * published install crashed before even reaching it, because the adapter
21
+ * import was a static repo-relative path). When no source resolves, the
22
+ * server now REFUSES TO START, listing every path tried and the fix — the
23
+ * same posture as translate.js's "refusing to start with an empty or
24
+ * hand-guessed method surface". With `champollion` a real dependency, a
25
+ * missing card surface always means a broken install, never a legitimate
26
+ * degraded mode.
27
+ */
28
+
29
+ import { readdir, readFile } from 'node:fs/promises';
30
+ import { existsSync } from 'node:fs';
31
+ import { dirname, join, resolve } from 'node:path';
32
+ import { fileURLToPath, pathToFileURL } from 'node:url';
33
+ import { createRequire } from 'node:module';
34
+
35
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
36
+ const require = createRequire(import.meta.url);
37
+
38
+ // Monorepo layout: mcp-server/src/tools/ → repo root is three up.
39
+ const REPO_CLI = resolve(__dirname, '../../../cli');
40
+
41
+ /**
42
+ * Resolve the ONE card adapter (cli/lib/cards/reader.js), monorepo checkout
43
+ * first so development never resolves a stale registry install, then the
44
+ * `champollion` dependency for published installs. A ninth private reader is
45
+ * how this server came to serve "[object Object]" — the adapter is not
46
+ * optional, so failing to find it is a startup error, not a fallback.
47
+ *
48
+ * @returns {Promise<{reader: object, packageRoot: string, source: string}>}
49
+ */
50
+ async function resolveAdapter() {
51
+ const attempts = [];
52
+
53
+ const repoReader = resolve(REPO_CLI, 'lib/cards/reader.js');
54
+ if (existsSync(repoReader)) {
55
+ const reader = await import(pathToFileURL(repoReader).href);
56
+ return { reader, packageRoot: REPO_CLI, source: 'monorepo checkout' };
57
+ }
58
+ attempts.push(`${repoReader} (monorepo checkout — not present)`);
59
+
60
+ try {
61
+ const entry = require.resolve('champollion');
62
+ const reader = await import('champollion');
63
+ return {
64
+ reader,
65
+ packageRoot: dirname(entry),
66
+ source: 'champollion package',
67
+ };
68
+ } catch (err) {
69
+ attempts.push(`champollion package (${err.code ?? err.message})`);
70
+ }
71
+
72
+ throw new Error(
73
+ 'Cannot resolve the champollion card adapter — refusing to start with no '
74
+ + 'card surface. Tried:\n'
75
+ + attempts.map((a) => ` - ${a}`).join('\n')
76
+ + '\nFix: run inside the Champollion monorepo, or `npm install` in the '
77
+ + 'MCP server directory so the `champollion` dependency is present.',
78
+ );
79
+ }
80
+
81
+ /** One index entry from a full (normalized) card. */
82
+ function entryFromCard(card, { display, isDisputed }) {
83
+ // Speaker estimates: ONE claim answers as itself; several claims answer as
84
+ // the DISAGREEMENT, every value shown. Taking estimates[0] silently elected
85
+ // ELCat's British-Columbia-only count as Plains Cree's total — 'speakers:
86
+ // 10-99' on the flagship card, which is exactly the pick-a-winner move the
87
+ // card boundary forbids.
88
+ const claims = Array.isArray(card.speakerEstimates) ? card.speakerEstimates : [];
89
+ const counts = [...new Set(claims.map((e) => e?.count).filter((c) => c != null && c !== ''))];
90
+ const speakers = card.vitality?.speakerCount
91
+ || (counts.length === 1 ? counts[0]
92
+ : counts.length > 1 ? `${counts.join(' / ')} (sources differ)` : '');
93
+ const fam = card.classification?.family;
94
+ return {
95
+ code: card.code || card.iso639_3 || card.bcp47 || '',
96
+ name: card.name || '',
97
+ endonym: card.nativeName || card.endonym || '',
98
+ // display() yields the agreed value and nothing on a real dispute —
99
+ // but 'disputed' and 'unknown' are different claims, so a genuine
100
+ // disagreement says so instead of reading as ignorance.
101
+ family: display(fam) || (isDisputed?.(fam) ? 'disputed' : ''),
102
+ speakers,
103
+ script: card.script || (Array.isArray(card.scripts) ? card.scripts[0] : '') || '',
104
+ region: card.macroarea || '',
105
+ typology: card.typologicalProfile?.verbSynthesis
106
+ || card.typologicalProfile?.morphologicalSynthesis || '',
107
+ // Documented alternate names so a language is findable by ANY of its
108
+ // names — the same alias set the Atlas searches.
109
+ aliases: [
110
+ ...(Array.isArray(card.alternateNames) ? card.alternateNames : []),
111
+ ...(Array.isArray(card.aliases) ? card.aliases : []),
112
+ ],
113
+ // Keep the full card for detailed lookups
114
+ _raw: card,
115
+ };
116
+ }
117
+
118
+ /** Build the index from a language-cards directory (full corpus). */
119
+ async function indexFromDir(dir, ctx) {
120
+ const { normalizeCard } = ctx;
121
+ const files = await readdir(dir);
122
+ const jsonFiles = files.filter((f) => f.endsWith('.json') && !f.startsWith('.'));
123
+
124
+ const index = [];
125
+ const cards = await Promise.allSettled(
126
+ jsonFiles.map(async (f) => {
127
+ const raw = await readFile(join(dir, f), 'utf-8');
128
+ // THROUGH THE ONE ADAPTER, like every other consumer.
129
+ return normalizeCard(JSON.parse(raw));
130
+ }),
131
+ );
132
+
133
+ for (const result of cards) {
134
+ if (result.status !== 'fulfilled') continue;
135
+ const card = result.value;
136
+ // The cards dir also holds generated reference files (language-tree.json)
137
+ // that are not cards — a card always carries a code and a name.
138
+ if (!(card.code || card.iso639_3 || card.bcp47) || !card.name) continue;
139
+ // A LOCALE IS NOT A LANGUAGE: fra-CA carries French's name and facts;
140
+ // indexing the 8,675 locale cards would return one language a dozen
141
+ // times for one query.
142
+ if (card.locale?.language) continue;
143
+ index.push(entryFromCard(card, ctx));
144
+ }
145
+ return index;
146
+ }
147
+
148
+ /**
149
+ * Build the index from the CLI's bundled cards-fallback.json: full entries
150
+ * for the ~1,157 bundled core cards, lean name/alias entries for every other
151
+ * concrete language in the manifest. Lean entries answer "does Champollion
152
+ * know this language, and by what name" honestly — with empty strings, never
153
+ * invented facts — and the full card remains fetchable through the CLI.
154
+ */
155
+ async function indexFromFallbackFile(file, ctx) {
156
+ const { normalizeCard } = ctx;
157
+ const bundle = JSON.parse(await readFile(file, 'utf-8'));
158
+ const index = [];
159
+ const seen = new Set();
160
+
161
+ for (const raw of Object.values(bundle.cards ?? {})) {
162
+ const card = normalizeCard(raw);
163
+ if (!(card.code || card.iso639_3 || card.bcp47) || !card.name) continue;
164
+ // The bundle deliberately carries locale-variant cards (fra-CA,
165
+ // cmn-Hant) for the CLI's resolution needs; a language index excludes
166
+ // them by locale block AND by dashed code — some variant cards predate
167
+ // the locale block.
168
+ if (card.locale?.language) continue;
169
+ const entry = entryFromCard(card, ctx);
170
+ if (entry.code.includes('-')) continue;
171
+ index.push(entry);
172
+ seen.add(entry.code);
173
+ }
174
+ const parents = new Set(Object.keys(bundle.parents ?? {}));
175
+ for (const [code, m] of Object.entries(bundle.manifest ?? {})) {
176
+ if (seen.has(code) || parents.has(code)) continue;
177
+ // Dashed codes are locale projections (fra-CA) — not languages. (This
178
+ // also skips the x-* constructed-script variants; their base cards are
179
+ // in the bundled core set.)
180
+ if (code.includes('-')) continue;
181
+ if (!m?.n) continue;
182
+ index.push({
183
+ code,
184
+ name: m.n,
185
+ endonym: '',
186
+ family: '',
187
+ speakers: '',
188
+ script: '',
189
+ region: '',
190
+ typology: '',
191
+ aliases: Array.isArray(m.a) ? m.a : [],
192
+ });
193
+ }
194
+ return index;
195
+ }
196
+
197
+ /**
198
+ * Load the language index. Resolution ladder in the module docstring; every
199
+ * miss is recorded and a total miss THROWS with the full list — startup is
200
+ * the right place to fail, because index.js awaits this before the MCP
201
+ * handshake and a server that starts without languages would answer
202
+ * "No languages found" as if it were a fact about the world.
203
+ *
204
+ * @param {object} [opts] Test injection: `cardsDir` (strict, same semantics
205
+ * as CHAMPOLLION_CARDS_DIR), `fallbackFile`, `repoDir` (override the
206
+ * monorepo candidate).
207
+ * @returns {Promise<object[]>} Language index array
208
+ */
209
+ export async function loadLanguageIndex(opts = {}) {
210
+ const { reader, packageRoot, source } = await resolveAdapter();
211
+ const { normalizeCard, display, isDisputed } = reader;
212
+ const adapterCtx = { normalizeCard, display, isDisputed };
213
+ const attempts = [];
214
+
215
+ // Tier 1 — explicit override: obey it or fail, never fall past it.
216
+ const explicit = opts.cardsDir ?? process.env.CHAMPOLLION_CARDS_DIR;
217
+ if (explicit) {
218
+ const label = opts.cardsDir ? 'cardsDir option' : 'CHAMPOLLION_CARDS_DIR';
219
+ let index;
220
+ try {
221
+ index = await indexFromDir(explicit, adapterCtx);
222
+ } catch (err) {
223
+ throw new Error(
224
+ `${label} points at ${explicit}, which is not a readable card `
225
+ + `directory (${err.code ?? err.message}). The override names a `
226
+ + 'specific corpus; silently reading a different one would be worse '
227
+ + 'than failing.',
228
+ );
229
+ }
230
+ if (index.length === 0) {
231
+ throw new Error(
232
+ `${label} points at ${explicit}, which contains no language cards — `
233
+ + 'a broken corpus, not a missing one.',
234
+ );
235
+ }
236
+ process.stderr.write(`Loaded ${index.length} language cards from ${explicit} (${label})\n`);
237
+ return index;
238
+ }
239
+
240
+ // Tier 2 — the monorepo's full corpus.
241
+ const repoDir = opts.repoDir ?? resolve(REPO_CLI, 'shared/language-cards');
242
+ if (existsSync(repoDir)) {
243
+ const index = await indexFromDir(repoDir, adapterCtx);
244
+ if (index.length === 0) {
245
+ throw new Error(
246
+ `${repoDir} exists but contains no language cards — a broken corpus `
247
+ + '(a half-applied cutover?), not a missing one. Rebuild the cards or '
248
+ + 'set CHAMPOLLION_CARDS_DIR.',
249
+ );
250
+ }
251
+ process.stderr.write(`Loaded ${index.length} language cards from ${repoDir}\n`);
252
+ return index;
253
+ }
254
+ attempts.push(`${repoDir} (monorepo corpus — not present)`);
255
+
256
+ // Tier 3 — the champollion package's bundled fallback.
257
+ const fallbackFile = opts.fallbackFile
258
+ ?? process.env.CHAMPOLLION_CARDS_FALLBACK
259
+ ?? join(packageRoot, 'shared', 'cards-fallback.json');
260
+ if (existsSync(fallbackFile)) {
261
+ const index = await indexFromFallbackFile(fallbackFile, adapterCtx);
262
+ if (index.length === 0) {
263
+ throw new Error(
264
+ `${fallbackFile} parsed but yielded no language entries — a broken `
265
+ + 'bundle, not a missing one.',
266
+ );
267
+ }
268
+ process.stderr.write(
269
+ `Loaded ${index.length} languages from the bundled fallback ${fallbackFile}\n`,
270
+ );
271
+ return index;
272
+ }
273
+ attempts.push(`${fallbackFile} (bundled cards-fallback.json — not present)`);
274
+
275
+ throw new Error(
276
+ 'No language-card source resolved — refusing to start with an empty or '
277
+ + `hand-guessed language surface (adapter came from the ${source}). Tried:\n`
278
+ + attempts.map((a) => ` - ${a}`).join('\n')
279
+ + '\nFix: set CHAMPOLLION_CARDS_DIR to a language-cards directory, run '
280
+ + 'inside the Champollion monorepo, or reinstall so the champollion '
281
+ + "dependency's bundled cards ship intact.",
282
+ );
283
+ }
284
+
285
+ /**
286
+ * Search the language index for matches against a query string.
287
+ *
288
+ * Matches against: code, name, endonym, family, region.
289
+ * Case-insensitive substring match, ranked: exact code match first, then
290
+ * exact name/endonym, then name-prefix, then everything else in file order.
291
+ *
292
+ * @param {object[]} index Language index from loadLanguageIndex()
293
+ * @param {string} query Search term
294
+ * @param {number} limit Max results
295
+ * @returns {object[]} Matching languages
296
+ */
297
+ export function searchLanguages(index, query, limit = 10) {
298
+ const q = query.toLowerCase();
299
+ const tiers = [[], [], [], []];
300
+
301
+ for (const lang of index) {
302
+ const aliases = Array.isArray(lang.aliases) ? lang.aliases : [];
303
+ const searchable = [
304
+ lang.code, lang.name, lang.endonym, lang.family,
305
+ lang.region, lang.typology, ...aliases,
306
+ ].filter(Boolean).join(' ').toLowerCase();
307
+
308
+ if (!searchable.includes(q)) continue;
309
+
310
+ if (lang.code && lang.code.toLowerCase() === q) {
311
+ tiers[0].push(lang);
312
+ } else if (
313
+ (lang.name && lang.name.toLowerCase() === q) ||
314
+ (lang.endonym && lang.endonym.toLowerCase() === q) ||
315
+ aliases.some((a) => a.toLowerCase() === q)
316
+ ) {
317
+ tiers[1].push(lang);
318
+ } else if (lang.name && lang.name.toLowerCase().startsWith(q)) {
319
+ tiers[2].push(lang);
320
+ } else {
321
+ tiers[3].push(lang);
322
+ }
323
+
324
+ // Enough candidates to fill the limit from the best tiers alone.
325
+ if (tiers[0].length >= limit) break;
326
+ }
327
+
328
+ return tiers.flat().slice(0, limit);
329
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Queue tools — fetch, filter, and inspect the Champollion sweep queue.
3
+ *
4
+ * Data source (default): the live queue served from Postgres via the queue_top
5
+ * RPC — a ranked list of (corpus, model, condition) items, coverage-filtered
6
+ * against VERIFIED runs, so nothing stale or already-done is shown. Items are
7
+ * paged from the RPC; metadata comes from the small queue-preview.json. If the
8
+ * DB is unreachable, fetchQueue falls back to the full static queue.json blob,
9
+ * so the tools never break. Set CHAMPOLLION_QUEUE_SOURCE=blob to force the blob.
10
+ * An in-memory CACHE_TTL_MS cache avoids re-fetching on every tool call.
11
+ */
12
+
13
+ const QUEUE_URL = 'https://champollion.dev/queue.json';
14
+ // DB-as-queue (B1): the live queue is served from Postgres (the queue_top RPC),
15
+ // coverage-filtered against VERIFIED runs, so items are never stale. Metadata
16
+ // (open_items, models, priority_model, cost_basis, how_to_run) comes from the
17
+ // small queue-preview.json companion. The full static blob remains the FALLBACK
18
+ // so this tool never breaks if the DB is unreachable.
19
+ const PREVIEW_URL = 'https://champollion.dev/queue-preview.json';
20
+ const SUPABASE_URL = process.env.MT_EVAL_SUPABASE_URL
21
+ || 'https://sjdomynysdljkbemupqa.supabase.co';
22
+ const SUPABASE_ANON_KEY = process.env.MT_EVAL_SUPABASE_ANON_KEY
23
+ || 'sb_publishable_bV6CFNFnzxhQI0wlBx2J0A_5Vm5gFBp';
24
+ const QUEUE_TOP_PAGE = 500; // matches the RPC's hard page cap
25
+ // 'db' (default) serves live from queue_top with a blob fallback; 'blob' forces
26
+ // the legacy static file (used by the existing fetch tests).
27
+ const QUEUE_SOURCE = process.env.CHAMPOLLION_QUEUE_SOURCE || 'db';
28
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
29
+
30
+ // Served item fields (what queue.json publishes / consumers rely on). queue_top
31
+ // rows also carry rank_mode/map_value/diagnostics/generation_id/generated_at —
32
+ // projected away so DB-sourced items match the blob shape exactly.
33
+ const SERVED_FIELDS = [
34
+ 'priority', 'id', 'language_pair', 'source_language', 'target_language',
35
+ 'corpus_id', 'corpus_license', 'entry_count', 'contamination', 'domain',
36
+ 'source_length', 'model', 'condition', 'est_cost_usd', 'est_basis',
37
+ 'run_command',
38
+ ];
39
+
40
+ let _cache = null;
41
+ let _cacheTime = 0;
42
+
43
+ /**
44
+ * Fetch the queue, returning the cached version if still fresh.
45
+ *
46
+ * The static host can serve an HTML holding page with HTTP 200 (site gated,
47
+ * maintenance, CDN error page) — `resp.json()` would then surface a raw
48
+ * `SyntaxError: Unexpected token '<'` to every queue-backed tool (list_queue,
49
+ * get_queue_item, estimate_cost, get_project_info, and run_benchmark's item
50
+ * lookup all relay err.message verbatim). The body is therefore read as text
51
+ * and parsed here, so a non-JSON response becomes ONE clean, user-facing
52
+ * error at this shared choke point. Failures are never cached — the next
53
+ * call re-fetches.
54
+ *
55
+ * @param {object} [opts]
56
+ * @param {typeof fetch} [opts.fetchImpl] Injectable fetch (for tests).
57
+ * @returns {Promise<{ metadata: object, items: object[] }>}
58
+ */
59
+ export async function fetchQueue({ fetchImpl = fetch, source = QUEUE_SOURCE } = {}) {
60
+ const now = Date.now();
61
+ if (_cache && (now - _cacheTime) < CACHE_TTL_MS) {
62
+ return _cache;
63
+ }
64
+
65
+ let data;
66
+ if (source === 'blob') {
67
+ data = await fetchQueueFromBlob(fetchImpl);
68
+ } else {
69
+ // Live DB path with a graceful fallback: a DB/preview failure must never
70
+ // take the tool down when the static blob is still being served.
71
+ try {
72
+ data = await fetchQueueFromDb(fetchImpl);
73
+ } catch {
74
+ data = await fetchQueueFromBlob(fetchImpl);
75
+ }
76
+ }
77
+
78
+ _cache = data;
79
+ _cacheTime = now;
80
+ return data;
81
+ }
82
+
83
+ /** Serve the live queue from Postgres: items from the queue_top RPC (paged,
84
+ * coverage-filtered against verified runs), metadata from queue-preview.json.
85
+ * Throws on any failure so fetchQueue can fall back to the static blob. */
86
+ async function fetchQueueFromDb(fetchImpl) {
87
+ // Metadata + preview from the companion file (a few hundred KB — ~292 KB
88
+ // as of 2026-08; it grows with the queue, so never assume it is tiny).
89
+ const pResp = await fetchImpl(PREVIEW_URL, {
90
+ headers: { 'Accept': 'application/json' },
91
+ signal: AbortSignal.timeout(30_000),
92
+ });
93
+ if (!pResp.ok) throw new Error(`preview HTTP ${pResp.status}`);
94
+ const preview = JSON.parse(await pResp.text());
95
+ const metadata = preview?.metadata;
96
+ if (metadata === null || typeof metadata !== 'object') {
97
+ throw new Error('queue-preview.json missing metadata');
98
+ }
99
+ const rankMode = metadata.rank_mode || 'map';
100
+
101
+ // Page through the RPC until a short page signals the end.
102
+ const items = [];
103
+ for (let offset = 0; ; offset += QUEUE_TOP_PAGE) {
104
+ const resp = await fetchImpl(`${SUPABASE_URL}/rest/v1/rpc/queue_top`, {
105
+ method: 'POST',
106
+ headers: {
107
+ 'apikey': SUPABASE_ANON_KEY,
108
+ 'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
109
+ 'Content-Type': 'application/json',
110
+ 'Accept': 'application/json',
111
+ },
112
+ body: JSON.stringify({
113
+ p_rank_mode: rankMode, p_limit: QUEUE_TOP_PAGE, p_offset: offset,
114
+ }),
115
+ signal: AbortSignal.timeout(30_000),
116
+ });
117
+ if (!resp.ok) throw new Error(`queue_top HTTP ${resp.status}`);
118
+ const page = JSON.parse(await resp.text());
119
+ if (!Array.isArray(page)) throw new Error('queue_top did not return an array');
120
+ for (const row of page) {
121
+ const item = {};
122
+ for (const f of SERVED_FIELDS) if (row[f] !== undefined) item[f] = row[f];
123
+ // The restricted-corpus `transmission` stamp is a SERVED extra on
124
+ // queue.json but not a queue_items COLUMN — the ranker writes it into
125
+ // the diagnostics JSONB. Projecting columns alone dropped it from every
126
+ // DB-served item, so agents pulling work through MCP lost the no-train
127
+ // channel requirement the blob discloses. Lift it back.
128
+ const stamp = row.diagnostics?.transmission;
129
+ if (stamp && typeof stamp === 'object' && stamp.policy) item.transmission = stamp;
130
+ items.push(item);
131
+ }
132
+ if (page.length < QUEUE_TOP_PAGE) break;
133
+ }
134
+
135
+ // open_items reflects the LIVE served count, not the last generation's stat.
136
+ return { metadata: { ...metadata, open_items: items.length }, items };
137
+ }
138
+
139
+ /** The legacy path: the full static queue.json blob. Also the DB-path fallback. */
140
+ async function fetchQueueFromBlob(fetchImpl) {
141
+ const resp = await fetchImpl(QUEUE_URL, {
142
+ headers: { 'Accept': 'application/json' },
143
+ signal: AbortSignal.timeout(30_000),
144
+ });
145
+ if (!resp.ok) {
146
+ throw new Error(`Queue fetch failed: HTTP ${resp.status}`);
147
+ }
148
+
149
+ const body = await resp.text();
150
+ let data;
151
+ try {
152
+ data = JSON.parse(body);
153
+ } catch {
154
+ const contentType = resp.headers?.get?.('content-type') || 'unknown content-type';
155
+ throw new Error(
156
+ 'the Champollion queue endpoint is not serving JSON — the site may be '
157
+ + `gated or down (got ${contentType} from ${QUEUE_URL}). `
158
+ + 'Try again later, or check https://champollion.dev.',
159
+ );
160
+ }
161
+ // Parsed, but not a queue: a gate/deploy step could serve a JSON error
162
+ // object with HTTP 200. Fail cleanly here rather than crashing downstream
163
+ // in filterQueue with a raw TypeError.
164
+ if (data === null || typeof data !== 'object'
165
+ || !Array.isArray(data.items)
166
+ || data.metadata === null || typeof data.metadata !== 'object') {
167
+ throw new Error(
168
+ 'the Champollion queue endpoint returned JSON without the expected '
169
+ + '{ metadata, items } queue shape — the site may be gated or '
170
+ + 'mid-deploy. Try again later, or check https://champollion.dev.',
171
+ );
172
+ }
173
+
174
+ return data;
175
+ }
176
+
177
+ /** Clear the in-memory queue cache. For test isolation. */
178
+ export function resetQueueCache() {
179
+ _cache = null;
180
+ _cacheTime = 0;
181
+ }
182
+
183
+ /**
184
+ * Filter queue items by language, model, budget, and condition.
185
+ *
186
+ * Core selection follows the same rules as queue_runner.py select_items:
187
+ * - Items are walked in queue order (the ranking IS the priority)
188
+ * - Budget mode: only items whose est_cost fits entirely within
189
+ * the remaining budget are selected
190
+ * - Items without cost estimates are skipped in budget mode
191
+ * - Coached items are included only when condition='coached'
192
+ *
193
+ * This function adds language/model/source_language filters on top
194
+ * of the core selection logic (those are MCP-only features).
195
+ *
196
+ * SSOT: Canonical implementation is arena/mt_eval_harness/queue_runner.py.
197
+ * Both are tested against shared/queue-selection-vectors.json — if you
198
+ * change core selection behavior here, update the vectors and run both
199
+ * test suites (npm test + pytest).
200
+ *
201
+ * @param {object[]} items All queue items (already sorted by priority)
202
+ * @param {object} filters Filter options
203
+ * @returns {object[]} Filtered items
204
+ */
205
+ export function filterQueue(items, {
206
+ budget = null,
207
+ language = null,
208
+ source_language = null,
209
+ model = null,
210
+ condition = null,
211
+ limit = 20,
212
+ } = {}) {
213
+ const selected = [];
214
+ let spend = 0;
215
+
216
+ // Normalize filter strings for case-insensitive matching
217
+ const langLower = language?.toLowerCase();
218
+ const modelLower = model?.toLowerCase();
219
+ const srcLower = source_language?.toLowerCase();
220
+
221
+ for (const item of items) {
222
+ if (selected.length >= limit) break;
223
+
224
+ // --- Condition filter ---
225
+ // Skip coached items unless explicitly requested (mirrors queue_runner.py)
226
+ if (condition) {
227
+ if (item.condition !== condition) continue;
228
+ } else if (item.condition === 'coached') {
229
+ continue;
230
+ }
231
+
232
+ // --- Language filter ---
233
+ // Matches against target_language name or the target side of the pair code
234
+ if (langLower) {
235
+ const targetCode = item.language_pair?.split('>')[1] || '';
236
+ const targetName = (item.target_language || '').toLowerCase();
237
+ if (!targetName.includes(langLower) && !targetCode.includes(langLower)) {
238
+ continue;
239
+ }
240
+ }
241
+
242
+ // --- Source language filter ---
243
+ if (srcLower) {
244
+ const sourceCode = item.language_pair?.split('>')[0] || '';
245
+ if (sourceCode.toLowerCase() !== srcLower) continue;
246
+ }
247
+
248
+ // --- Model filter ---
249
+ if (modelLower) {
250
+ const itemModel = (item.model || '').toLowerCase();
251
+ if (!itemModel.includes(modelLower)) continue;
252
+ }
253
+
254
+ // --- Budget filter ---
255
+ if (budget != null) {
256
+ const est = item.est_cost_usd;
257
+ if (est == null) continue; // unknown cost — skip in budget mode
258
+ if (spend + est > budget) continue; // would exceed budget
259
+ spend += est;
260
+ }
261
+
262
+ selected.push(item);
263
+ }
264
+
265
+ return selected;
266
+ }
267
+
268
+ /**
269
+ * Look up a single queue item by ID or priority rank.
270
+ *
271
+ * @param {object[]} items All queue items
272
+ * @param {{ id?: string, priority?: number }} lookup
273
+ * @returns {object|null} The matching item, or null
274
+ */
275
+ export function getQueueItem(items, { id, priority }) {
276
+ if (id) {
277
+ return items.find(it => it.id === id) || null;
278
+ }
279
+ if (priority != null) {
280
+ return items.find(it => it.priority === priority) || null;
281
+ }
282
+ return null;
283
+ }
284
+
285
+ /**
286
+ * Estimate cost for a set of queue items matching the given filters.
287
+ *
288
+ * Returns aggregate statistics without executing anything.
289
+ *
290
+ * @param {object[]} items All queue items
291
+ * @param {object} filters Same filters as filterQueue
292
+ * @returns {{ count, totalCost, cheapest, mostExpensive, languages }}
293
+ */
294
+ export function estimateCost(items, filters) {
295
+ // Use a high limit to get all matching items for cost estimation
296
+ // Hard cap so a broad filter can't blow up the response; callers see
297
+ // `capped` and must not read `count` as the queue-wide total.
298
+ const ESTIMATE_CAP = 500;
299
+ const matched = filterQueue(items, { ...filters, limit: ESTIMATE_CAP });
300
+ const costs = matched
301
+ .map(it => it.est_cost_usd)
302
+ .filter(c => c != null);
303
+ const languages = [...new Set(matched.map(it => it.target_language))];
304
+
305
+ return {
306
+ count: matched.length,
307
+ capped: matched.length >= ESTIMATE_CAP,
308
+ totalCost: costs.reduce((s, c) => s + c, 0),
309
+ cheapest: costs.length ? Math.min(...costs) : 0,
310
+ mostExpensive: costs.length ? Math.max(...costs) : 0,
311
+ languages,
312
+ };
313
+ }