champollion 0.5.1 → 0.5.3

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.
@@ -711,7 +711,7 @@ const COMMAND_HELP = {
711
711
  },
712
712
 
713
713
  models: {
714
- usage: 'champollion models --method <provider>',
714
+ usage: 'champollion models --method <provider> | champollion models check',
715
715
  description: [
716
716
  'Lists available models from a translation provider\'s API.',
717
717
  'Queries the provider\'s live model endpoint and displays all',
@@ -719,6 +719,7 @@ const COMMAND_HELP = {
719
719
  ],
720
720
  options: [
721
721
  ['--method <name>', 'Provider to query: gemini, openai, or anthropic (required)'],
722
+ ['check', 'Check every default model (shared/model-defaults.json) against the providers\' live lists; exit 1 if one is gone. Free (list endpoints are not billed)'],
722
723
  ['--json', 'Machine-readable JSON output (single document)'],
723
724
  ],
724
725
  examples: [
@@ -21,6 +21,8 @@ import { resolveConfig } from '../config.js';
21
21
  import { fetchAvailableModels, resolveProviderApiKey, getProviderLabel, getProviderEnvVar, isListableProvider, getListableProviders } from '../models.js';
22
22
  import { missingKeyAdvice } from '../missing-key.js';
23
23
  import { output } from '../output.js';
24
+ import { checkModelDefaults } from '../model-defaults.js';
25
+ import { fetchModelPricing } from '../methods/openrouter-pricing.js';
24
26
 
25
27
  /**
26
28
  * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
@@ -38,6 +40,8 @@ async function run(args, cwd) {
38
40
  return 0;
39
41
  }
40
42
 
43
+ if (args._[1] === 'check') return runCheck(args, cwd, json);
44
+
41
45
  const method = args.method;
42
46
  if (!method) {
43
47
  if (json) {
@@ -182,3 +186,49 @@ function showHelp() {
182
186
  }
183
187
 
184
188
  export { run };
189
+
190
+ /**
191
+ * `champollion models check` — every default model (shared/model-defaults.json)
192
+ * against the providers' live lists. Read-only and free: list endpoints are
193
+ * not billed. Exit 1 when a pinned default is no longer listed (a run would
194
+ * fail on it); a newer model matching a role's rule is reported, not a
195
+ * failure — moving a default is a reviewed change, never automatic.
196
+ *
197
+ * @param {object} args
198
+ * @param {string} cwd
199
+ * @param {boolean} json
200
+ * @returns {Promise<number>}
201
+ */
202
+ async function runCheck(args, cwd, json) {
203
+ const lists = { openrouter: null, openai: null, gemini: null, anthropic: null };
204
+ try {
205
+ const pricing = await fetchModelPricing();
206
+ if (pricing && pricing.size > 0) lists.openrouter = [...pricing.keys()];
207
+ } catch { /* unchecked */ }
208
+ for (const provider of ['openai', 'gemini', 'anthropic']) {
209
+ const key = resolveProviderApiKey(provider, cwd);
210
+ if (key) lists[provider] = await fetchAvailableModels(provider, key);
211
+ }
212
+ const rows = checkModelDefaults(lists);
213
+ const gone = rows.filter(r => r.status === 'gone');
214
+ if (json) {
215
+ console.log(JSON.stringify({ command: 'models check', ok: gone.length === 0, roles: rows }, null, 2));
216
+ return gone.length > 0 ? 1 : 0;
217
+ }
218
+ output.raw('\n Default models (shared/model-defaults.json) against the providers\' live lists:\n');
219
+ for (const r of rows) {
220
+ const mark = { ok: '[OK] ', newer: '[NEW] ', gone: '[GONE]', unchecked: '[--] ' }[r.status];
221
+ output.raw(` ${mark} ${r.role.padEnd(10)} ${r.model.padEnd(34)} ${r.note}`);
222
+ }
223
+ const unchecked = rows.filter(r => r.status === 'unchecked').map(r => r.provider);
224
+ if (unchecked.length > 0) {
225
+ output.raw(`\n Not checked (no key for ${[...new Set(unchecked)].map(p => getProviderEnvVar(p) || p).join(', ')}): set it to check that provider's list.`);
226
+ }
227
+ output.raw('');
228
+ if (gone.length > 0) {
229
+ output.error(`${gone.length} default model(s) are no longer listed by their provider — a run on them would fail. `
230
+ + 'Update shared/model-defaults.json (in the repo: node cli/scripts/check-model-defaults.mjs --update) and release.');
231
+ return 1;
232
+ }
233
+ return 0;
234
+ }
package/lib/config.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * complex setups with custom registers, models, and batch sizes.
12
12
  */
13
13
 
14
+ import { defaultModel } from './model-defaults.js';
14
15
  import fs from 'node:fs';
15
16
  import path from 'node:path';
16
17
  import { DEFAULT_REGISTERS, getLanguageCard, getRegister, resolveCode } from './registers.js';
@@ -25,7 +26,8 @@ const CONFIG_FILENAMES = ['champollion.config.json'];
25
26
 
26
27
  // Canonical defaults — import these in any module that needs a fallback
27
28
  // instead of hardcoding the string/number inline.
28
- const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3.8-flash';
29
+ // shared/model-defaults.json, role "translate" — never written here (lib/model-defaults.js).
30
+ const DEFAULT_OPENROUTER_MODEL = defaultModel('translate');
29
31
  const DEFAULT_BATCH_SIZE = 80;
30
32
  // Max parallel API calls for JSON key-value translation. 50 is kind to
31
33
  // free/low-tier keys on a zero-config first run (200 would hammer 429s).
@@ -51,6 +51,9 @@
51
51
  * paragraph written by hand — drops its record.
52
52
  */
53
53
 
54
+ import fs from 'node:fs';
55
+ import path from 'node:path';
56
+ import { output } from './output.js';
54
57
  import { tmMethodKey, lookupTM } from './tm.js';
55
58
  import { GATE_VERSION } from './validate.js';
56
59
  import { holdState, shortSourceHash } from './locale-state.js';
@@ -282,3 +285,54 @@ export function previewHeld({ tm, code, pairConfig, fields, body, segMode, holds
282
285
  }
283
286
  return { names, pageHeld: false };
284
287
  }
288
+
289
+ // ── What a run left in the source language ───────────────────────────
290
+
291
+ /** Where refused answers are logged (the per-machine cache folder, never committed). */
292
+ export const REFUSAL_LOG = path.join('.champollion', 'refused.jsonl');
293
+
294
+ /**
295
+ * Append one refused answer to `.champollion/refused.jsonl`: the source, what
296
+ * the model answered, and why the gate refused it. WHY: a refused answer was
297
+ * thrown away, so "why did it refuse that twice?" could only be answered by
298
+ * paying the model again (2026-10-06 — and the answer then showed the gate,
299
+ * not the model, was wrong). Never fails the run.
300
+ *
301
+ * @param {string} cwd
302
+ * @param {object} rec - { pair, method, file, locale, paragraph, reason, source, answer }
303
+ */
304
+ export function logRefusal(cwd, rec) {
305
+ try {
306
+ const file = path.join(cwd, REFUSAL_LOG);
307
+ fs.mkdirSync(path.dirname(file), { recursive: true });
308
+ fs.appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), ...rec })}\n`, 'utf-8');
309
+ } catch { /* a diagnostic: never fails the run */ }
310
+ }
311
+
312
+ /**
313
+ * The run's closing report when anything was left in the source language —
314
+ * last, as an error, never under an [OK] (2026-10-06: a 1,082-page sync ended
315
+ * on "[OK] Created 1082 content file(s)" with seven untranslated blocks named
316
+ * only in warnings mid-stream).
317
+ *
318
+ * @param {Array<{ file: string, locale: string, pair: string, where: string, reason: string }>} items
319
+ */
320
+ export function reportLeftInSource(items) {
321
+ if (!items || items.length === 0) return;
322
+ const pages = new Map();
323
+ for (const it of items) {
324
+ const k = `${it.file}\u0000${it.pair}`;
325
+ if (!pages.has(k)) pages.set(k, { ...it, wheres: [] });
326
+ pages.get(k).wheres.push(`${it.where}: ${it.reason}`);
327
+ }
328
+ output.raw('');
329
+ output.error(`Not finished: ${items.length} part(s) of ${pages.size} page translation(s) are still in the source language — `
330
+ + 'the quality gate refused the model\'s answer, also when asked again with the reason:');
331
+ for (const p of pages.values()) {
332
+ output.raw(` ${p.file} → ${p.locale}: ${p.wheres.slice(0, 3).join('; ')}${p.wheres.length > 3 ? `; +${p.wheres.length - 3} more` : ''}`);
333
+ }
334
+ const first = [...pages.values()][0];
335
+ output.raw(` The rest of each page is written. What the model answered is in ${REFUSAL_LOG}. `
336
+ + `Ask again: \`${contentRedoCommand(first.file, { pair: first.pair })}\`${pages.size > 1 ? ' (one per page)' : ''}, `
337
+ + 'or add a "fallback" method to the pair.');
338
+ }
@@ -89,7 +89,7 @@ import {
89
89
  } from './fallback.js';
90
90
  import {
91
91
  readRefusals, contentHolds, nextRefusals, storeRefusals, blockUnit, fieldUnit, describeHeld, describeNewHold,
92
- describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock,
92
+ describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock, logRefusal, reportLeftInSource,
93
93
  } from './content-refusals.js';
94
94
  import { contentRedoCommand } from './verify.js';
95
95
 
@@ -115,6 +115,8 @@ import { contentRedoCommand } from './verify.js';
115
115
  * (null = no cap).
116
116
  */
117
117
  async function runContentSync(options) {
118
+ // Parts this run left in the source language — reported at the end, as an error.
119
+ const leftInSource = [];
118
120
  const {
119
121
  contentDir,
120
122
  sourceLocale,
@@ -700,6 +702,11 @@ async function runContentSync(options) {
700
702
  // field keeps its source text; the page is still written.
701
703
  fieldsLeftInSource = true;
702
704
  for (const h of fm.hollowed) {
705
+ leftInSource.push({ file: relPath, locale: code, pair: pairKey, where: `front matter "${h.field}"`, reason: h.reason });
706
+ logRefusal(cwd, {
707
+ pair: pairKey, method: tmMethodKey(pairConfig), file: relPath, locale: code,
708
+ field: h.field, reason: h.reason, source: fieldsToTranslate[h.field], answer: h.value ?? null,
709
+ });
703
710
  const advice = h.sharedOutput
704
711
  ? ` ${pairConfig.method} answers it only with a sentence it gave for other strings — `
705
712
  + `${pairConfig.fallback ? 'and the fallback did not translate it either' : 'add a "fallback" method to the pair'}, or write it in ${targetRel} by hand (kept).`
@@ -990,6 +997,13 @@ async function runContentSync(options) {
990
997
  // Blocks the quality gate refused — the key-value gate's checks
991
998
  // (length inflation, echo, truncation, script), per block.
992
999
  const refusedByGate = batchOutcome.refused || [];
1000
+ for (const r of refusedByGate) {
1001
+ leftInSource.push({ file: relPath, locale: code, pair: pairKey, where: `paragraph ${r.block}`, reason: r.reason });
1002
+ logRefusal(cwd, {
1003
+ pair: pairKey, method: tmMethodKey(pairConfig), file: relPath, locale: code,
1004
+ paragraph: r.block, reason: r.reason, source: missed[r.i].source, answer: r.answer,
1005
+ });
1006
+ }
993
1007
  if (refusedByGate.length > 0) {
994
1008
  output.warn(
995
1009
  `Content sync body for ${code}: ${refusedByGate.length} block(s) of ${relPath} refused by the quality gate, also when asked again with the reason — `
@@ -1210,9 +1224,11 @@ async function runContentSync(options) {
1210
1224
  const heldBackNote = heldBackSeen > 0 ? `; ${heldBackSeen} block(s)/field(s) refused before would be held back (not sent)` : '';
1211
1225
  output.info(`Would have created ${totalCreated} content file(s)${retranslateNote}${keptNote}, ${skipped} unchanged${heldNote}${heldBackNote}.`);
1212
1226
  } else {
1213
- output.ok(`Created ${totalCreated} content file(s)${retranslateNote}${keptNote}, ${skipped} unchanged${heldNote}.`);
1227
+ const unfinished = leftInSource.length > 0 ? ` — ${leftInSource.length} part(s) left in the source language (below)` : '';
1228
+ (unfinished ? output.warn : output.ok).call(output, `Created ${totalCreated} content file(s)${retranslateNote}${keptNote}, ${skipped} unchanged${heldNote}${unfinished}.`);
1214
1229
  }
1215
1230
  }
1231
+ reportLeftInSource(leftInSource);
1216
1232
  if (heldBack > 0) {
1217
1233
  // Not sent, not billed — but not translated either (exit 2, as for a
1218
1234
  // held-back key). Each page was named above with its own repair.
@@ -74,7 +74,7 @@ import {
74
74
  import { walkFiles } from './locale-layout.js';
75
75
  import {
76
76
  readRefusals, contentHolds, nextRefusals, storeRefusals, blockUnit, fieldUnit, describeHeld, describeNewHold,
77
- describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock,
77
+ describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock, logRefusal, reportLeftInSource,
78
78
  } from './content-refusals.js';
79
79
  import { applyNamedKeyRule, reportUnmatchedKeys, unmatchedKeysSummary, reportNamedFromCache } from './named-keys.js';
80
80
 
@@ -1209,6 +1209,8 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1209
1209
  let contentHeldBack = 0;
1210
1210
  const contentHeldBackItems = [];
1211
1211
  let contentRefused = 0;
1212
+ // Parts this run left in the source language — said last, as an error.
1213
+ const leftInSource = [];
1212
1214
 
1213
1215
  if (contentSources.length === 0) {
1214
1216
  output.info('No docs/ or blog/ directories found — skipping content sync.');
@@ -1443,6 +1445,11 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1443
1445
  // field keeps its source text; the page is still written.
1444
1446
  fieldsLeftInSource = true;
1445
1447
  for (const h of fm.hollowed) {
1448
+ leftInSource.push({ file: label, locale: code, pair: pairKey, where: `front matter "${h.field}"`, reason: h.reason });
1449
+ logRefusal(cwd, {
1450
+ pair: pairKey, method: tmMethodKey(pairConfig), file: label, locale: code,
1451
+ field: h.field, reason: h.reason, source: fieldsToTranslate[h.field], answer: h.value ?? null,
1452
+ });
1446
1453
  output.warn(
1447
1454
  `${label} → ${code}: front matter "${h.field}" kept in the source language — ${h.reason}`
1448
1455
  + `${h.fallbackReason ? `; the fallback (${pairConfig.fallback.method}): ${h.fallbackReason}` : ''}.`
@@ -1666,6 +1673,13 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1666
1673
  'the model gave for other, different source strings (a memorized sentence, not a translation) — refused.'
1667
1674
  );
1668
1675
  }
1676
+ for (const r of outcome.refused || []) {
1677
+ leftInSource.push({ file: `${dirName}/${relPath}`, locale: code, pair: pairKey, where: `paragraph ${r.block}`, reason: r.reason });
1678
+ logRefusal(cwd, {
1679
+ pair: pairKey, method: tmMethodKey(pairConfig), file: `${dirName}/${relPath}`, locale: code,
1680
+ paragraph: r.block, reason: r.reason, source: missed[r.i].source, answer: r.answer,
1681
+ });
1682
+ }
1669
1683
  if ((outcome.refused || []).length > 0) {
1670
1684
  output.warn(
1671
1685
  `Docusaurus body for ${code}: ${outcome.refused.length} block(s) of ${dirName}/${relPath} refused by the quality gate, also when asked again with the reason — `
@@ -1845,7 +1859,8 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1845
1859
  if (totalContent > 0 || totalContentSkipped > 0 || totalContentRetranslated > 0) {
1846
1860
  const action = dryRun ? 'Would create' : 'Created';
1847
1861
  const retranslateNote = totalContentRetranslated > 0 ? ` (${totalContentRetranslated} re-translated)` : '';
1848
- output.ok(`${action} ${totalContent} content file(s)${retranslateNote}, ${totalContentSkipped} unchanged`);
1862
+ const unfinished = leftInSource.length > 0 ? ` — ${leftInSource.length} part(s) left in the source language (below)` : '';
1863
+ (unfinished ? output.warn : output.ok).call(output, `${action} ${totalContent} content file(s)${retranslateNote}, ${totalContentSkipped} unchanged${unfinished}`);
1849
1864
  }
1850
1865
 
1851
1866
  contentTranslated = totalContent;
@@ -1891,6 +1906,10 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1891
1906
  saveTM(cwd, tm);
1892
1907
  output.info(`[TM] Saved ${describeTMChanges(tm)} this sync`);
1893
1908
  }
1909
+ {
1910
+ // Last, so it is the run's closing word: what is still untranslated.
1911
+ reportLeftInSource(leftInSource);
1912
+ }
1894
1913
 
1895
1914
  // Failures are reported, not thrown: a partly failed run still did real
1896
1915
  // work, and the exit code says so (2 = partial, 1 = nothing succeeded).
package/lib/fallback.js CHANGED
@@ -572,7 +572,8 @@ export async function translateFieldsWithFallback({
572
572
  if (typeof value !== 'string') continue;
573
573
  const f = failed.get(field);
574
574
  const fault = contentGateFault(fields[field], value, pairConfig);
575
- const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(f.reason) && value === f.value;
575
+ const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(f.reason)
576
+ && [f.value, fields[field]].some(v => sameAnswer(v, value));
576
577
  if (!fault || insisted) againPassing.push([field, value]);
577
578
  else f.reason = `${f.reason}; asked again with that reason: ${fault}`;
578
579
  }
@@ -698,6 +699,17 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
698
699
  return contentGateFault(restoredSource, restoredOut, pairConfig);
699
700
  }
700
701
 
702
+ /**
703
+ * The same answer, give or take spacing and closing punctuation: a citation
704
+ * kept as written once with its final "." and once without was refused as a
705
+ * changed answer (dogfood 2026-10-06, a Thai reference) — the model had said
706
+ * "keep it" twice.
707
+ */
708
+ function sameAnswer(a, b) {
709
+ const norm = (v) => String(v ?? '').replace(/\s+/g, ' ').trim().replace(/[.,;:!?。、]+$/u, '');
710
+ return norm(a) === norm(b);
711
+ }
712
+
701
713
  /**
702
714
  * Ask the pair's method once more for the blocks the gate refused, telling it
703
715
  * why. WHY: a refusal used to be final for the run, and the block was left in
@@ -708,8 +720,9 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
708
720
  * reason, the model either translates it or returns it unchanged again — and
709
721
  * a keep-as-written refusal (isKeepAsWrittenFault) answered the same way
710
722
  * twice is taken at its word, as the key-value lane takes a name. Every other
711
- * fault must clear the gate outright. One ask, never a loop; under --max-cost
712
- * it is billed like any other call (budget.approve).
723
+ * fault must clear the gate outright. One ask per block, alone (not in the
724
+ * page's batch), never a loop; under --max-cost it is billed like any other
725
+ * call (budget.approve).
713
726
  *
714
727
  * @param {object} p
715
728
  * @param {number[]} p.idx - Indices (into `missed`) the gate refused
@@ -724,7 +737,7 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
724
737
  * @param {object|null} [p.budget]
725
738
  * @returns {Promise<Map<number,string>>} index → accepted (restored) answer
726
739
  */
727
- async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runBatch, firstOut, reasons, budget = null }) {
740
+ async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runBatch, firstOut, reasons, answers = null, budget = null }) {
728
741
  const accepted = new Map();
729
742
  if (idx.length === 0) return accepted;
730
743
  // An endpoint that declares it follows no instructions (a trained NMT
@@ -740,20 +753,33 @@ async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runB
740
753
  if (!verdict.ok) return accepted;
741
754
  }
742
755
  const retryNotes = new Map(idx.map(i => [texts[i], reasons.get(i)]));
743
- let res = null;
744
- try {
745
- res = await runBatch(idx.map(i => texts[i]), { ...pairConfig, retryNotes });
746
- } catch {
747
- return accepted;
748
- }
749
- const fell = new Set(res?.fellBack || []);
750
- idx.forEach((i, j) => {
751
- if (!res || fell.has(j) || typeof res.blocks[j] !== 'string') return;
752
- const restored = restoreBlocks(res.blocks[j], blocks);
753
- const fault = blockFault(texts[i], res.blocks[j], restored, missed[i].source, pairConfig);
754
- const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(reasons.get(i)) && restored === firstOut.get(i);
756
+ // Each block alone: away from the page's other segments, the model has one
757
+ // thing to do (and one reason to read). Refusals are rare, so the extra
758
+ // requests cost next to nothing; at most 4 at a time.
759
+ const answers1 = new Map();
760
+ let next = 0;
761
+ const worker = async () => {
762
+ while (next < idx.length) {
763
+ const i = idx[next++];
764
+ try {
765
+ const res = await runBatch([texts[i]], { ...pairConfig, retryNotes });
766
+ if (res && !(res.fellBack || []).includes(0) && typeof res.blocks[0] === 'string') answers1.set(i, res.blocks[0]);
767
+ } catch { /* no second answer for this block */ }
768
+ }
769
+ };
770
+ await Promise.all(Array.from({ length: Math.min(4, idx.length) }, worker));
771
+ idx.forEach((i) => {
772
+ if (!answers1.has(i)) return;
773
+ const protectedOut = answers1.get(i);
774
+ const restored = restoreBlocks(protectedOut, blocks);
775
+ const fault = blockFault(texts[i], protectedOut, restored, missed[i].source, pairConfig);
776
+ const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(reasons.get(i))
777
+ && [firstOut.get(i), missed[i].source].some(v => sameAnswer(v, restored));
755
778
  if (!fault || insisted) accepted.set(i, restored);
756
- else reasons.set(i, `${reasons.get(i)}; asked again with that reason: ${fault}`);
779
+ else {
780
+ reasons.set(i, `${reasons.get(i)}; asked again with that reason: ${fault}`);
781
+ if (answers) answers.set(i, restored);
782
+ }
757
783
  });
758
784
  return accepted;
759
785
  }
@@ -828,9 +854,13 @@ export async function translateBlocksWithFallback({
828
854
  };
829
855
  // Why the gate refused a block (index → reason), for the lane's warning.
830
856
  const refusedReason = new Map();
857
+ // The last answer the gate refused, per block — kept for the refusal log
858
+ // (.champollion/refused.jsonl), so "why was this refused?" can be read off
859
+ // what the model actually said instead of asked again.
860
+ const refusedAnswer = new Map();
831
861
  /** Blocks left as their source text because the gate refused them. */
832
862
  const refusedList = (fellBack) => fellBack.filter(i => refusedReason.has(i))
833
- .map(i => ({ i, block: (Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1, reason: refusedReason.get(i) }));
863
+ .map(i => ({ i, block: (Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1, reason: refusedReason.get(i), answer: refusedAnswer.get(i) ?? null }));
834
864
 
835
865
  if (!fb) {
836
866
  const { blocks: translatedBlocks, fellBack } = await runBatch(texts, pairConfig);
@@ -841,14 +871,14 @@ export async function translateBlocksWithFallback({
841
871
  for (let i = 0; i < outs.length; i++) {
842
872
  if (fellSet.has(i)) continue;
843
873
  const fault = contentGateFault(missed[i].source, outs[i], pairConfig);
844
- if (fault) refusedReason.set(i, fault);
874
+ if (fault) { refusedReason.set(i, fault); refusedAnswer.set(i, outs[i]); }
845
875
  }
846
876
  const shared = sharedFault(outs.map((out, i) => ({ i, out })).filter(({ i }) => !fellSet.has(i) && !refusedReason.has(i)));
847
877
  for (const i of shared) refusedReason.set(i, 'the same text the model gave for other, different source strings');
848
878
  // Refused: asked once more, with the reason.
849
879
  const again = await retryRefusedBlocks({
850
880
  idx: [...refusedReason.keys()], texts, missed, blocks, pairConfig, runBatch,
851
- firstOut: new Map([...refusedReason.keys()].map(i => [i, outs[i]])), reasons: refusedReason, budget,
881
+ firstOut: new Map([...refusedReason.keys()].map(i => [i, outs[i]])), reasons: refusedReason, answers: refusedAnswer, budget,
852
882
  });
853
883
  const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
854
884
  for (const [i, out] of again) {
@@ -914,6 +944,7 @@ export async function translateBlocksWithFallback({
914
944
  const fault = blockFault(texts[i], primary.blocks[i], restored, missed[i].source, pairConfig);
915
945
  if (fault) {
916
946
  refusedReason.set(i, fault);
947
+ refusedAnswer.set(i, restored);
917
948
  primaryRefused.add(i);
918
949
  failed.push(i);
919
950
  continue;
@@ -934,7 +965,7 @@ export async function translateBlocksWithFallback({
934
965
  if (refusedReason.has(i)) firstOut.set(i, restoreBlocks(primary.blocks[i], blocks));
935
966
  }
936
967
  const again = await retryRefusedBlocks({
937
- idx: [...firstOut.keys()], texts, missed, blocks, pairConfig, runBatch, firstOut, reasons: refusedReason, budget,
968
+ idx: [...firstOut.keys()], texts, missed, blocks, pairConfig, runBatch, firstOut, reasons: refusedReason, answers: refusedAnswer, budget,
938
969
  });
939
970
  const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
940
971
  const recovered = new Set();
@@ -1015,6 +1046,7 @@ export async function translateBlocksWithFallback({
1015
1046
  if (!fbFault) fbGood.push({ i, out: restored });
1016
1047
  else {
1017
1048
  refusedReason.set(i, refusedReason.has(i) ? `${refusedReason.get(i)}; the fallback's answer: ${fbFault}` : `the fallback's answer: ${fbFault}`);
1049
+ refusedAnswer.set(i, restored);
1018
1050
  fallbackRefused.add(i);
1019
1051
  }
1020
1052
  }
@@ -13,6 +13,7 @@
13
13
  * lives in DirectLLMMethod.
14
14
  */
15
15
 
16
+ import { defaultModel } from '../model-defaults.js';
16
17
  import { DirectLLMMethod } from './direct-llm.js';
17
18
  import { fetchAvailableModels } from '../models.js';
18
19
  import { estimateLlmCost } from './provider-pricing.js';
@@ -21,7 +22,7 @@ import { estimateLlmCost } from './provider-pricing.js';
21
22
  // _getDefaultModel() and again as an inline `pairConfig.model || '...'`
22
23
  // fallback in estimateCost() — so the two could drift and price a run
23
24
  // against a model it did not use.
24
- const DEFAULT_MODEL = 'claude-sonnet-4-6';
25
+ const DEFAULT_MODEL = defaultModel('anthropic'); // shared/model-defaults.json
25
26
 
26
27
  class AnthropicMethod extends DirectLLMMethod {
27
28
  constructor(options = {}) {
@@ -12,13 +12,14 @@
12
12
  * lives in DirectLLMMethod.
13
13
  */
14
14
 
15
+ import { defaultModel } from '../model-defaults.js';
15
16
  import { DirectLLMMethod } from './direct-llm.js';
16
17
  import { fetchAvailableModels } from '../models.js';
17
18
  import { estimateLlmCost } from './provider-pricing.js';
18
19
 
19
20
  // The default model, named ONCE — it was previously written twice (here and
20
21
  // as an inline fallback in estimateCost()) and the two could drift.
21
- const DEFAULT_MODEL = 'gemini-3.8-flash';
22
+ const DEFAULT_MODEL = defaultModel('gemini'); // shared/model-defaults.json
22
23
 
23
24
  class GeminiMethod extends DirectLLMMethod {
24
25
  constructor(options = {}) {
@@ -13,15 +13,16 @@
13
13
  * lives in DirectLLMMethod.
14
14
  */
15
15
 
16
+ import { defaultModel } from '../model-defaults.js';
16
17
  import { DirectLLMMethod } from './direct-llm.js';
17
18
  import { fetchAvailableModels } from '../models.js';
18
19
  import { estimateLlmCost } from './provider-pricing.js';
19
20
 
20
21
  // The default model, named ONCE — it was previously written twice (here and
21
22
  // as an inline fallback in estimateCost()) and the two could drift.
22
- // A dated snapshot: plain 'gpt-4o' was an alias OpenAI repoints (founder
23
- // ruling 2026-10-05: exact model ids only, nothing floating).
24
- const DEFAULT_MODEL = 'gpt-5.4-mini-2026-03-17';
23
+ // shared/model-defaults.json: a dated snapshot (plain 'gpt-4o' was an alias
24
+ // OpenAI repoints — founder ruling 2026-10-05: exact model ids only).
25
+ const DEFAULT_MODEL = defaultModel('openai');
25
26
 
26
27
  class OpenAIMethod extends DirectLLMMethod {
27
28
  constructor(options = {}) {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * model-defaults.js — the ONE place a default model comes from.
3
+ *
4
+ * WHY: the toolset's defaults were ten hand-written ids in seven files across
5
+ * five components (CLI, its three direct providers, the harness, forge, the
6
+ * MCP server, the site docent). Nothing kept them in step, so they drifted —
7
+ * the docent still named an undated `claude-haiku-4-5` after every other
8
+ * default had been made exact (founder, 2026-10-07: "we're gonna be getting
9
+ * our wires crossed no matter what doing it that way"). Every default now
10
+ * reads shared/model-defaults.json (bundled here as shared/model-defaults.json).
11
+ *
12
+ * A default is an exact id, fixed in that file — never resolved at run time:
13
+ * a run must say which model translated, and the translation memory is keyed
14
+ * by model, so a default that moved by itself would re-bill every cached
15
+ * string. What IS live is the check: `champollion models check` reads each
16
+ * provider's current list, fails when a pinned id is gone, and names the
17
+ * newest model each role's rule matches today.
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ const FILE = path.join(__dirname, '..', 'shared', 'model-defaults.json');
26
+
27
+ let _data = null;
28
+
29
+ /** The parsed file. Missing or malformed is a broken install: fail loud. */
30
+ export function modelDefaultsData() {
31
+ if (_data) return _data;
32
+ let raw;
33
+ try {
34
+ raw = fs.readFileSync(FILE, 'utf-8');
35
+ } catch (err) {
36
+ throw new Error(`${FILE} is missing (${err.code || err.message}): it names every default model. Reinstall champollion.`);
37
+ }
38
+ const data = JSON.parse(raw);
39
+ if (!data || typeof data.roles !== 'object') throw new Error(`${FILE} has no "roles" — it is not a model-defaults file.`);
40
+ _data = data;
41
+ return data;
42
+ }
43
+
44
+ /**
45
+ * The exact default model id for a role ("translate", "openai", "gemini",
46
+ * "anthropic", "harness", "docent"). Unknown role: a programming error.
47
+ *
48
+ * @param {string} role
49
+ * @returns {string}
50
+ */
51
+ export function defaultModel(role) {
52
+ const r = modelDefaultsData().roles[role];
53
+ if (!r || typeof r.model !== 'string' || !r.model) throw new Error(`model-defaults.json has no "${role}" role.`);
54
+ return r.model;
55
+ }
56
+
57
+ /** Version string → comparable number array ("4-6" / "4.6" → [4, 6]). */
58
+ function versionKey(v) {
59
+ return String(v || '0').split(/[.-]/).map((n) => Number(n) || 0);
60
+ }
61
+
62
+ function compareIds(a, b) {
63
+ const [va, vb] = [versionKey(a.v), versionKey(b.v)];
64
+ for (let i = 0; i < Math.max(va.length, vb.length); i++) {
65
+ if ((va[i] || 0) !== (vb[i] || 0)) return (va[i] || 0) - (vb[i] || 0);
66
+ }
67
+ return String(a.date || '').localeCompare(String(b.date || ''));
68
+ }
69
+
70
+ /**
71
+ * The newest id in a provider's live list that a role's rule matches, or null.
72
+ *
73
+ * @param {object} role - A roles[] entry ({ rule: { match } })
74
+ * @param {string[]} ids - The provider's live model ids
75
+ * @returns {string|null}
76
+ */
77
+ export function ruleCandidate(role, ids) {
78
+ const re = new RegExp(role.rule.match);
79
+ const hits = [];
80
+ for (const id of ids || []) {
81
+ const m = re.exec(id);
82
+ if (m) hits.push({ id, v: m.groups?.v, date: m.groups?.date });
83
+ }
84
+ if (hits.length === 0) return null;
85
+ hits.sort(compareIds);
86
+ return hits[hits.length - 1].id;
87
+ }
88
+
89
+ /**
90
+ * Check every role against live lists.
91
+ *
92
+ * @param {Record<string, string[]|null>} lists - provider → live ids (null = could not be read)
93
+ * @returns {Array<{ role: string, provider: string, model: string, status: 'ok'|'newer'|'gone'|'unchecked', candidate: string|null, note: string }>}
94
+ */
95
+ export function checkModelDefaults(lists) {
96
+ const out = [];
97
+ for (const [name, role] of Object.entries(modelDefaultsData().roles)) {
98
+ const ids = lists[role.provider];
99
+ if (!ids) {
100
+ out.push({ role: name, provider: role.provider, model: role.model, status: 'unchecked', candidate: null, note: `${role.provider}'s model list could not be read` });
101
+ continue;
102
+ }
103
+ const candidate = ruleCandidate(role, ids);
104
+ if (!ids.includes(role.model)) {
105
+ out.push({ role: name, provider: role.provider, model: role.model, status: 'gone', candidate, note: `${role.model} is no longer in ${role.provider}'s list${candidate ? ` — the rule's model today: ${candidate}` : ''}` });
106
+ } else if (candidate && candidate !== role.model) {
107
+ out.push({ role: name, provider: role.provider, model: role.model, status: 'newer', candidate, note: `a newer model matches the rule: ${candidate}` });
108
+ } else {
109
+ out.push({ role: name, provider: role.provider, model: role.model, status: 'ok', candidate, note: 'listed' });
110
+ }
111
+ }
112
+ return out;
113
+ }