champollion 0.3.4 → 0.5.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.
- package/README.md +41 -26
- package/bin/cli.js +53 -5
- package/index.js +63 -2
- package/lib/api-key.js +17 -4
- package/lib/autofix.js +82 -37
- package/lib/bridge/method_bridge.py +15 -3
- package/lib/cards/reader.js +34 -0
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +286 -85
- package/lib/commands/audit.js +10 -3
- package/lib/commands/card.js +583 -226
- package/lib/commands/doctor.js +54 -18
- package/lib/commands/help.js +37 -32
- package/lib/commands/init.js +1709 -101
- package/lib/commands/integrity.js +127 -40
- package/lib/commands/leaderboard.js +187 -67
- package/lib/commands/models.js +9 -2
- package/lib/commands/provenance.js +7 -2
- package/lib/commands/recommend.js +43 -14
- package/lib/commands/register-corpus.js +632 -125
- package/lib/commands/seal-corpus.js +1 -1
- package/lib/commands/status.js +564 -27
- package/lib/commands/submit.js +17 -12
- package/lib/commands/sync.js +31 -7
- package/lib/commands/tm.js +15 -9
- package/lib/commands/verify.js +27 -3
- package/lib/commands/wrap.js +63 -5
- package/lib/commands/xliff.js +135 -64
- package/lib/commercial-eligibility.js +1 -1
- package/lib/config.js +230 -17
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +284 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1136 -345
- package/lib/content.js +94 -7
- package/lib/corpus-registration.mjs +194 -35
- package/lib/cost-label.js +29 -0
- package/lib/cost-report.js +727 -79
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +975 -262
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +1102 -0
- package/lib/file-scope.js +106 -0
- package/lib/flatten.js +80 -3
- package/lib/flutter-locales.js +124 -0
- package/lib/format.js +266 -12
- package/lib/hash.js +146 -21
- package/lib/icu-structure.js +929 -0
- package/lib/integrity.js +223 -75
- package/lib/language-pair.js +157 -0
- package/lib/lint.js +78 -16
- package/lib/local-only-marks.js +106 -0
- package/lib/locale-layout.js +1103 -0
- package/lib/locale-state.js +575 -0
- package/lib/methods/anthropic.js +5 -0
- package/lib/methods/apertium.js +6 -3
- package/lib/methods/api.js +138 -25
- package/lib/methods/base.js +17 -0
- package/lib/methods/coaching-data.js +153 -0
- package/lib/methods/content-separator.js +43 -0
- package/lib/methods/deepl.js +1 -1
- package/lib/methods/direct-llm.js +257 -103
- package/lib/methods/external.js +146 -63
- package/lib/methods/gemini.js +1 -0
- package/lib/methods/google-translate.js +1 -0
- package/lib/methods/http-utils.js +41 -0
- package/lib/methods/libretranslate.js +7 -2
- package/lib/methods/llm-coached.js +71 -129
- package/lib/methods/llm.js +87 -33
- package/lib/methods/local.js +93 -10
- package/lib/methods/microsoft-translator.js +1 -2
- package/lib/methods/openai.js +4 -2
- package/lib/methods/openrouter-client.js +20 -19
- package/lib/methods/openrouter-pricing.js +150 -13
- package/lib/methods/prompt-methods.js +20 -0
- package/lib/methods/provider-pricing.js +42 -1
- package/lib/methods/request-capture.js +104 -0
- package/lib/methods/tilde.js +1 -1
- package/lib/methods/translated.js +1 -2
- package/lib/missing-key.js +93 -0
- package/lib/models.js +102 -55
- package/lib/name-rules.js +32 -0
- package/lib/named-keys.js +172 -0
- package/lib/no-translate.js +4 -3
- package/lib/output.js +160 -19
- package/lib/pairs.js +586 -30
- package/lib/placeholders.js +394 -0
- package/lib/plugins.js +8 -0
- package/lib/plural-gap-redo.js +109 -0
- package/lib/plurals.js +323 -0
- package/lib/po.js +1187 -0
- package/lib/public-catalogue.js +74 -0
- package/lib/recommend.js +527 -32
- package/lib/redo.js +95 -0
- package/lib/refusal-category.js +44 -0
- package/lib/registers.js +255 -11
- package/lib/repair-script.js +20 -13
- package/lib/scripts.js +6 -1
- package/lib/seal.mjs +4 -3
- package/lib/sealed-qualifier.mjs +1 -1
- package/lib/segment.js +24 -8
- package/lib/seo.js +19 -9
- package/lib/serve.js +43 -6
- package/lib/shared-output-seed.js +164 -0
- package/lib/source-contexts.js +39 -0
- package/lib/submit.mjs +57 -5
- package/lib/sync.js +2923 -474
- package/lib/terminology.js +13 -4
- package/lib/tm-evict.js +179 -0
- package/lib/tm-seed.js +5 -2
- package/lib/tm.js +818 -36
- package/lib/translate-pair.js +639 -34
- package/lib/translate.js +78 -5
- package/lib/types.js +22 -3
- package/lib/validate.js +996 -21
- package/lib/verify.js +1332 -104
- package/lib/watch.js +32 -13
- package/lib/xliff.js +44 -3
- package/package.json +1 -1
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/cards-fallback.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/licenses.json +99 -3
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/retired-model-aliases.json +9 -0
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +8 -2
- package/shared/schemas/human-services.schema.json +1 -1
- package/shared/schemas/method-index-record.schema.json +67 -0
- package/shared/schemas/method-registry.schema.json +4 -0
- package/shared/schemas/metric-registry.schema.json +55 -1
- package/shared/schemas/retired-model-aliases.schema.json +32 -0
- package/shared/docent/corpus.json +0 -11739
- package/shared/model-aliases.json +0 -7
- package/shared/schemas/model-aliases.schema.json +0 -27
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request capture — see the exact request a method would send, without
|
|
3
|
+
* sending it (`champollion sync --dry --show-prompt [key]`).
|
|
4
|
+
*
|
|
5
|
+
* WHY: nothing showed whether a gettext `msgctxt`, a `#.` comment or any
|
|
6
|
+
* other per-key instruction actually reaches the model (Round 5, Django
|
|
7
|
+
* persona). Rebuilding "what the prompt probably looks like" beside the real
|
|
8
|
+
* code would be a second prompt builder that drifts; instead the methods'
|
|
9
|
+
* own transports hand their finished request here, at the point where they
|
|
10
|
+
* would call fetch(), and return as if the call failed — no retry, nothing
|
|
11
|
+
* sent, nothing billed.
|
|
12
|
+
*
|
|
13
|
+
* Scoped with AsyncLocalStorage: only code running inside captureRequests()
|
|
14
|
+
* is captured, so a capture never swallows an unrelated call. Inside a
|
|
15
|
+
* capture, fetch() itself is refused (defence in depth: a path that is not
|
|
16
|
+
* hooked fails closed instead of reaching the network).
|
|
17
|
+
*
|
|
18
|
+
* Secrets are redacted before anything is recorded: authorization-type
|
|
19
|
+
* headers and key-bearing URL parameters print as <redacted>.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
23
|
+
|
|
24
|
+
const store = new AsyncLocalStorage();
|
|
25
|
+
|
|
26
|
+
/** The key a preview passes when none is configured (it never leaves the process). */
|
|
27
|
+
export const PREVIEW_KEY = 'preview-no-key';
|
|
28
|
+
|
|
29
|
+
const SECRET_HEADERS = new Set([
|
|
30
|
+
'authorization', 'x-api-key', 'api-key', 'x-goog-api-key', 'ocp-apim-subscription-key',
|
|
31
|
+
'x-champollion-key', 'proxy-authorization', 'cookie',
|
|
32
|
+
]);
|
|
33
|
+
const SECRET_PARAMS = new Set(['key', 'api_key', 'apikey', 'token', 'access_token', 'auth_key']);
|
|
34
|
+
|
|
35
|
+
function redactHeaders(headers) {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
38
|
+
out[k] = SECRET_HEADERS.has(k.toLowerCase()) ? '<redacted>' : v;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function redactUrl(url) {
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(String(url));
|
|
46
|
+
for (const name of [...u.searchParams.keys()]) {
|
|
47
|
+
if (SECRET_PARAMS.has(name.toLowerCase())) u.searchParams.set(name, '<redacted>');
|
|
48
|
+
}
|
|
49
|
+
return u.toString().replace(/%3Credacted%3E/g, '<redacted>');
|
|
50
|
+
} catch {
|
|
51
|
+
return String(url);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True inside captureRequests(): the caller must record, not send. */
|
|
56
|
+
export function isCapturing() {
|
|
57
|
+
return store.getStore() !== undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Record the request a transport is about to send. Returns true when it was
|
|
62
|
+
* captured (the transport must then return without calling fetch), false
|
|
63
|
+
* when no capture is active (send as normal).
|
|
64
|
+
*
|
|
65
|
+
* @param {{ url: string, method?: string, headers?: object, body?: unknown }} request
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
export function captureRequest({ url, method = 'POST', headers = {}, body }) {
|
|
69
|
+
const sink = store.getStore();
|
|
70
|
+
if (!sink) return false;
|
|
71
|
+
let parsed = body;
|
|
72
|
+
if (typeof body === 'string') {
|
|
73
|
+
try { parsed = JSON.parse(body); } catch { parsed = body; }
|
|
74
|
+
}
|
|
75
|
+
sink.push({ url: redactUrl(url), method, headers: redactHeaders(headers), body: parsed });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let fetchGuarded = false;
|
|
80
|
+
/** Inside a capture fetch() is refused; outside, it is the real fetch. Installed once. */
|
|
81
|
+
function guardFetch() {
|
|
82
|
+
if (fetchGuarded || typeof globalThis.fetch !== 'function') return;
|
|
83
|
+
const realFetch = globalThis.fetch;
|
|
84
|
+
globalThis.fetch = function guardedFetch(...args) {
|
|
85
|
+
if (store.getStore()) {
|
|
86
|
+
return Promise.reject(new Error('request preview: nothing is sent while showing a request'));
|
|
87
|
+
}
|
|
88
|
+
return realFetch.apply(this, args);
|
|
89
|
+
};
|
|
90
|
+
fetchGuarded = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run fn with capture on; return the requests its transports would have sent.
|
|
95
|
+
*
|
|
96
|
+
* @param {() => Promise<unknown>} fn
|
|
97
|
+
* @returns {Promise<Array<{ url: string, method: string, headers: object, body: unknown }>>}
|
|
98
|
+
*/
|
|
99
|
+
export async function captureRequests(fn) {
|
|
100
|
+
guardFetch();
|
|
101
|
+
const sink = [];
|
|
102
|
+
await store.run(sink, fn);
|
|
103
|
+
return sink;
|
|
104
|
+
}
|
package/lib/methods/tilde.js
CHANGED
|
@@ -27,11 +27,11 @@ const TILDE_MAX_BATCH = 25;
|
|
|
27
27
|
class TildeMethod extends TranslationMethod {
|
|
28
28
|
constructor(options = {}) {
|
|
29
29
|
super('tilde', options);
|
|
30
|
+
this.translatesRawText = true; // see base.js
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
_resolveApiKey(options = {}) {
|
|
33
34
|
return options.tildeApiKey
|
|
34
|
-
|| getEnvOrFileVar('TILDE_API_KEY')
|
|
35
35
|
|| getEnvOrFileVar('TILDE_API_KEY', options.cwd);
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -28,6 +28,7 @@ const TRANSLATED_MAX_BATCH = 50;
|
|
|
28
28
|
class TranslatedMethod extends TranslationMethod {
|
|
29
29
|
constructor(options = {}) {
|
|
30
30
|
super('translated', options);
|
|
31
|
+
this.translatesRawText = true; // see base.js
|
|
31
32
|
this._client = options.laraClient || null; // injectable for tests
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -40,10 +41,8 @@ class TranslatedMethod extends TranslationMethod {
|
|
|
40
41
|
*/
|
|
41
42
|
_resolveCredentials(options = {}) {
|
|
42
43
|
const id = options.laraAccessKeyId
|
|
43
|
-
|| getEnvOrFileVar('LARA_ACCESS_KEY_ID')
|
|
44
44
|
|| getEnvOrFileVar('LARA_ACCESS_KEY_ID', options.cwd);
|
|
45
45
|
const secret = options.laraAccessKeySecret
|
|
46
|
-
|| getEnvOrFileVar('LARA_ACCESS_KEY_SECRET')
|
|
47
46
|
|| getEnvOrFileVar('LARA_ACCESS_KEY_SECRET', options.cwd);
|
|
48
47
|
return id && secret ? { id, secret } : null;
|
|
49
48
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to tell someone whose method cannot run for want of a key — ONE helper
|
|
3
|
+
* for every place that says it (the sync preflight, real and dry; a method
|
|
4
|
+
* that returned nothing; the content and Docusaurus lanes; `models`).
|
|
5
|
+
*
|
|
6
|
+
* On a CI runner the fix is a repository secret passed to the step, not
|
|
7
|
+
* `export …` or `.env.local`: the Round 8 fix reached one of these places and
|
|
8
|
+
* a real CI run still printed the laptop advice from the others (Round 9,
|
|
9
|
+
* i18next persona). Off CI, the method's own setup help stands as before.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Is this a CI runner? `CI` or `GITHUB_ACTIONS` set to anything but an empty
|
|
14
|
+
* string, "false" or "0" (a developer's shell may export CI=false).
|
|
15
|
+
*
|
|
16
|
+
* @param {object} [env]
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export function onCIRunner(env = process.env) {
|
|
20
|
+
const on = (v) => typeof v === 'string' && v.trim() !== '' && !/^(false|0)$/i.test(v.trim());
|
|
21
|
+
return on(env.CI) || on(env.GITHUB_ACTIONS);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The secret names a readiness reason names, in its first parenthesis:
|
|
26
|
+
* "No OpenRouter API key (OPENROUTER_API_KEY)." → OPENROUTER_API_KEY
|
|
27
|
+
* "No Google Translate API key (GOOGLE_TRANSLATE_API_KEY (or GOOGLE_API_KEY))." → the first (canonical) one
|
|
28
|
+
* "No Lara credentials (LARA_ACCESS_KEY_ID + LARA_ACCESS_KEY_SECRET)." → both (a key pair)
|
|
29
|
+
*
|
|
30
|
+
* @param {Iterable<string>} reasons
|
|
31
|
+
* @returns {string[]} distinct names, in order
|
|
32
|
+
*/
|
|
33
|
+
export function secretNamesIn(reasons) {
|
|
34
|
+
const names = [];
|
|
35
|
+
for (const reason of reasons || []) {
|
|
36
|
+
const m = /\(([A-Z][A-Z0-9]*_[A-Z0-9_]+(?:\s*\+\s*[A-Z][A-Z0-9]*_[A-Z0-9_]+)*)/.exec(String(reason));
|
|
37
|
+
if (!m) continue;
|
|
38
|
+
for (const n of m[1].split('+').map(x => x.trim())) if (!names.includes(n)) names.push(n);
|
|
39
|
+
}
|
|
40
|
+
return names;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* "In CI: add a repository secret named X and pass it to the sync step (env: X: ${{ secrets.X }})."
|
|
45
|
+
*
|
|
46
|
+
* @param {string[]} names
|
|
47
|
+
* @param {string} [step] - what the secret is passed to
|
|
48
|
+
* @returns {string|null} null when no name is known
|
|
49
|
+
*/
|
|
50
|
+
export function ciSecretLine(names, step = 'the sync step') {
|
|
51
|
+
if (!names || names.length === 0) return null;
|
|
52
|
+
const plural = names.length > 1;
|
|
53
|
+
return `In CI: add ${plural ? 'repository secrets' : 'a repository secret'} named ${names.join(' and ')} and pass `
|
|
54
|
+
+ `${plural ? 'them' : 'it'} to ${step} (env: ${names.map(n => `${n}: \${{ secrets.${n} }}`).join(', ')}).`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The advice lines for a missing key: on a CI runner, the repository-secret
|
|
59
|
+
* line INSTEAD of the shell advice; elsewhere, the method's own setup help
|
|
60
|
+
* (export …, .env.local). A reason that names no secret (a model server that
|
|
61
|
+
* does not answer) keeps the setup help everywhere — its reason already says
|
|
62
|
+
* what to do on a runner (lib/methods/local.js).
|
|
63
|
+
*
|
|
64
|
+
* @param {object} p
|
|
65
|
+
* @param {Iterable<string>} p.reasons - readiness reasons
|
|
66
|
+
* @param {string[]} [p.setupHelp] - the method's getSetupHelp() lines
|
|
67
|
+
* @param {string} [p.indent]
|
|
68
|
+
* @param {object} [p.env]
|
|
69
|
+
* @param {string} [p.step] - what the secret is passed to (default: the sync step)
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
export function missingKeyAdvice({ reasons, setupHelp = [], indent = ' ', env = process.env, step = 'the sync step' }) {
|
|
73
|
+
const line = ciSecretLine(secretNamesIn(reasons), step);
|
|
74
|
+
if (onCIRunner(env) && line) return [`${indent}${line}`];
|
|
75
|
+
return [...setupHelp];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The setup help for a method that returned nothing: when its key is missing
|
|
80
|
+
* (its readiness check says so), the missing-key advice above; when the key
|
|
81
|
+
* is there, the method's own HTTP-failure help.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} method - a method instance (lib/methods/base.js)
|
|
84
|
+
* @param {{ apiKey?: string|null, cwd?: string }} [context]
|
|
85
|
+
* @returns {Promise<string[]>}
|
|
86
|
+
*/
|
|
87
|
+
export async function methodSetupAdvice(method, context = {}) {
|
|
88
|
+
const help = method.getSetupHelp();
|
|
89
|
+
let readiness = { ready: true };
|
|
90
|
+
try { readiness = await method.checkReadiness({ apiKey: context.apiKey ?? null, cwd: context.cwd }); } catch { /* the help stands */ }
|
|
91
|
+
if (readiness && readiness.ready === false) return missingKeyAdvice({ reasons: [readiness.reason], setupHelp: help });
|
|
92
|
+
return help;
|
|
93
|
+
}
|
package/lib/models.js
CHANGED
|
@@ -24,77 +24,109 @@ import fs from 'node:fs';
|
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
26
|
import { getEnvOrFileVar } from './api-key.js';
|
|
27
|
+
// A cycle (config.js imports this module) that is safe: the default is read
|
|
28
|
+
// only inside requireExactModelId, at call time, never while modules load.
|
|
29
|
+
import { DEFAULT_OPENROUTER_MODEL } from './config.js';
|
|
27
30
|
|
|
28
31
|
// Per-process cache: provider name → model ID array (or null if fetch failed)
|
|
29
32
|
const _modelCache = new Map();
|
|
30
33
|
|
|
31
|
-
// Lazy-loaded
|
|
32
|
-
let
|
|
34
|
+
// Lazy-loaded retired-name table (loaded once from shared/retired-model-aliases.json)
|
|
35
|
+
let _retiredCache = null;
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
|
-
*
|
|
38
|
+
* Where to look up the models a name could mean — said in every refusal.
|
|
39
|
+
* `champollion models` lists a direct provider's own names; OpenRouter's
|
|
40
|
+
* catalogue is its public model list.
|
|
41
|
+
*/
|
|
42
|
+
const MODEL_LIST_HINT = 'List models: https://openrouter.ai/models (OpenRouter slugs), '
|
|
43
|
+
+ 'or champollion models --method <gemini|openai|anthropic> (a direct provider\'s own names).';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load the RETIRED short model names from shared/retired-model-aliases.json.
|
|
36
47
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* so
|
|
48
|
+
* Founder ruling 2026-10-05: "slugs should be specific, NOT ALIASES — for all
|
|
49
|
+
* models, all slugs, no aliasing." These names no longer resolve to anything.
|
|
50
|
+
* The table exists only so a refusal can say which exact slug the old name
|
|
51
|
+
* used to stand for — it is never used to map a name to a model.
|
|
40
52
|
*
|
|
41
|
-
*
|
|
53
|
+
* Prefers the package-bundled copy (cli/shared/, shipped via sync:shared),
|
|
54
|
+
* then the monorepo-root SSOT for in-repo dev. A missing or unreadable table
|
|
55
|
+
* leaves the refusal of floating ids intact and only loses the "used to stand
|
|
56
|
+
* for" hint for a retired name — which then fails as an unknown model at the
|
|
57
|
+
* provider, still never resolved.
|
|
58
|
+
*
|
|
59
|
+
* @returns {Object<string, string>} Retired short name → the slug it stood for
|
|
42
60
|
*/
|
|
43
|
-
function
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
]
|
|
56
|
-
|
|
57
|
-
for (const p of aliasPaths) {
|
|
58
|
-
try { raw = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
|
|
61
|
+
function _loadRetiredAliases() {
|
|
62
|
+
if (_retiredCache) return _retiredCache;
|
|
63
|
+
_retiredCache = Object.create(null);
|
|
64
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
65
|
+
const candidates = [
|
|
66
|
+
path.resolve(__dirname, '..', 'shared', 'retired-model-aliases.json'),
|
|
67
|
+
path.resolve(__dirname, '..', '..', 'shared', 'retired-model-aliases.json'),
|
|
68
|
+
];
|
|
69
|
+
for (const p of candidates) {
|
|
70
|
+
let raw;
|
|
71
|
+
try { raw = fs.readFileSync(p, 'utf-8'); } catch { continue; }
|
|
72
|
+
const retired = JSON.parse(raw).retired || {};
|
|
73
|
+
for (const [key, value] of Object.entries(retired)) {
|
|
74
|
+
if (typeof value === 'string') _retiredCache[key] = value;
|
|
59
75
|
}
|
|
60
|
-
|
|
61
|
-
// Strip metadata keys (e.g., _comment) — only keep actual aliases
|
|
62
|
-
_aliasCache = {};
|
|
63
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
64
|
-
if (!key.startsWith('_') && typeof value === 'string') {
|
|
65
|
-
_aliasCache[key] = value;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
} catch {
|
|
69
|
-
_aliasCache = {};
|
|
76
|
+
break;
|
|
70
77
|
}
|
|
78
|
+
return _retiredCache;
|
|
79
|
+
}
|
|
71
80
|
|
|
72
|
-
|
|
81
|
+
/**
|
|
82
|
+
* True for a floating id — one that names whatever model a provider points it
|
|
83
|
+
* at today, so a run could not say which model translated: OpenRouter's
|
|
84
|
+
* "~vendor/…" router ids and any "…-latest" / "…:latest" name.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} id
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
function isFloatingModelId(id) {
|
|
90
|
+
return typeof id === 'string' && (id.trim().startsWith('~') || /[-:]latest$/i.test(id.trim()));
|
|
73
91
|
}
|
|
74
92
|
|
|
75
93
|
/**
|
|
76
|
-
*
|
|
94
|
+
* Check that a configured model is an EXACT model slug, and return it unchanged.
|
|
77
95
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
96
|
+
* Founder ruling 2026-10-05: every model is named by its exact provider slug
|
|
97
|
+
* ("google/gemini-3.5-flash", "anthropic/claude-sonnet-4.6", or a direct
|
|
98
|
+
* provider's own exact name, "gpt-5.5"). Nothing resolves a short name:
|
|
99
|
+
* - a retired alias ("gemini-flash", "gpt", …) is REFUSED, naming the exact
|
|
100
|
+
* slug it used to stand for;
|
|
101
|
+
* - a floating id ("~google/gemini-flash-latest", "…-latest") is REFUSED.
|
|
102
|
+
* Anything else passes through as written — the provider judges whether the
|
|
103
|
+
* model exists (direct providers also check it against their model list).
|
|
82
104
|
*
|
|
83
|
-
* @param {string}
|
|
84
|
-
* @
|
|
105
|
+
* @param {string|null|undefined} id - The model as written
|
|
106
|
+
* @param {{ from?: string|null }} [where] - Where it was set ("from --model")
|
|
107
|
+
* @returns {string|null|undefined} The same id
|
|
108
|
+
* @throws {Error} code CHAMPOLLION_MODEL_ID
|
|
85
109
|
*/
|
|
86
|
-
function
|
|
87
|
-
if (!
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
110
|
+
function requireExactModelId(id, { from = null } = {}) {
|
|
111
|
+
if (!id || typeof id !== 'string') return id;
|
|
112
|
+
const at = from ? ` (${from})` : '';
|
|
113
|
+
const retired = _loadRetiredAliases();
|
|
114
|
+
if (Object.prototype.hasOwnProperty.call(retired, id)) {
|
|
115
|
+
const err = new Error(
|
|
116
|
+
`"${id}"${at} is not a model id — Champollion takes exact model slugs only, no aliases. `
|
|
117
|
+
+ `Did you mean ${retired[id]} (what "${id}" used to stand for)? ${MODEL_LIST_HINT}`);
|
|
118
|
+
err.code = 'CHAMPOLLION_MODEL_ID';
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
if (isFloatingModelId(id)) {
|
|
122
|
+
const err = new Error(
|
|
123
|
+
`"${id}"${at} is a floating model id — it names whatever model the provider points it at today, `
|
|
124
|
+
+ 'so a run could not say which model translated. Champollion takes exact model slugs only: '
|
|
125
|
+
+ `name the model itself (e.g. "${DEFAULT_OPENROUTER_MODEL}", the CLI default). ${MODEL_LIST_HINT}`);
|
|
126
|
+
err.code = 'CHAMPOLLION_MODEL_ID';
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
return id;
|
|
98
130
|
}
|
|
99
131
|
|
|
100
132
|
/**
|
|
@@ -183,7 +215,10 @@ async function fetchAvailableModels(provider, apiKey) {
|
|
|
183
215
|
if (cached !== undefined) return cached;
|
|
184
216
|
|
|
185
217
|
try {
|
|
186
|
-
|
|
218
|
+
// Floating ids ("gemini-flash-latest", "chatgpt-4o-latest") are listed by
|
|
219
|
+
// the providers but refused everywhere (requireExactModelId) — never offer
|
|
220
|
+
// one, so neither `champollion models` nor init's picker suggests it.
|
|
221
|
+
const models = (await config.fetch(apiKey))?.filter(id => !isFloatingModelId(id)) ?? null;
|
|
187
222
|
if (models && models.length > 0) {
|
|
188
223
|
_modelCache.set(provider, models);
|
|
189
224
|
return models;
|
|
@@ -219,6 +254,16 @@ function getProviderLabel(provider) {
|
|
|
219
254
|
return PROVIDERS[provider]?.label || provider;
|
|
220
255
|
}
|
|
221
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The environment variable a provider's key is read from (null: unknown).
|
|
259
|
+
*
|
|
260
|
+
* @param {string} provider - Provider name
|
|
261
|
+
* @returns {string|null}
|
|
262
|
+
*/
|
|
263
|
+
function getProviderEnvVar(provider) {
|
|
264
|
+
return PROVIDERS[provider]?.envVar || null;
|
|
265
|
+
}
|
|
266
|
+
|
|
222
267
|
/**
|
|
223
268
|
* Check if a provider has model listing support.
|
|
224
269
|
*
|
|
@@ -248,9 +293,11 @@ function clearModelCache() {
|
|
|
248
293
|
|
|
249
294
|
export {
|
|
250
295
|
fetchAvailableModels,
|
|
251
|
-
|
|
296
|
+
requireExactModelId,
|
|
297
|
+
isFloatingModelId,
|
|
252
298
|
resolveProviderApiKey,
|
|
253
299
|
getProviderLabel,
|
|
300
|
+
getProviderEnvVar,
|
|
254
301
|
isListableProvider,
|
|
255
302
|
getListableProviders,
|
|
256
303
|
clearModelCache,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* name-rules.js — the ONE wording every translation prompt uses for names.
|
|
3
|
+
*
|
|
4
|
+
* The old rule ("proper nouns, product names, and technical terms … role
|
|
5
|
+
* descriptions … should stay in English") let models keep short descriptive
|
|
6
|
+
* labels in English: on the 2026-08-28 dogfood run "Translation CLI" and
|
|
7
|
+
* "Evaluation harness" came back verbatim in Arabic and Thai and failed
|
|
8
|
+
* verification. Names stay; descriptions get translated; a project can
|
|
9
|
+
* declare its real names (config.protectedTerms) so the model never has to
|
|
10
|
+
* guess.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Prompt rule lines about names, each starting with "- ".
|
|
15
|
+
*
|
|
16
|
+
* @param {string[]} [protectedTerms] - config.protectedTerms
|
|
17
|
+
* @returns {string} Newline-joined rule lines (no trailing newline)
|
|
18
|
+
*/
|
|
19
|
+
export function nameRules(protectedTerms = []) {
|
|
20
|
+
const terms = (protectedTerms || []).filter(t => typeof t === 'string' && t.trim());
|
|
21
|
+
const lines = [
|
|
22
|
+
'- Proper names (people, companies, products, places) stay exactly as written.',
|
|
23
|
+
];
|
|
24
|
+
if (terms.length > 0) {
|
|
25
|
+
lines.push(`- Keep these exactly as written wherever they appear: ${terms.map(t => JSON.stringify(t)).join(', ')}.`);
|
|
26
|
+
}
|
|
27
|
+
lines.push(
|
|
28
|
+
'- Descriptive labels and phrases are NOT names, even when short or capitalized (e.g. "Translation CLI", "Evaluation harness", "Getting Started") — translate them.',
|
|
29
|
+
'- Widely used technical acronyms (API, URL, JSON, CLI) may stay as written inside a translated phrase.',
|
|
30
|
+
);
|
|
31
|
+
return lines.join('\n');
|
|
32
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* named-keys.js — keys named for a redo (`--redo keys:` / `--force-keys`)
|
|
3
|
+
* must exist. ONE rule for every sync path (key-value files, Docusaurus UI
|
|
4
|
+
* strings), so the two cannot drift apart.
|
|
5
|
+
*
|
|
6
|
+
* A name that matched nothing used to do nothing in silence ("fully synced
|
|
7
|
+
* … 0 keys", exit 0 — in CI exactly a success), so a typo or a gettext msgid
|
|
8
|
+
* that only exists WITH a context looked like a finished repair (Round 7,
|
|
9
|
+
* Django persona). Now:
|
|
10
|
+
* - no name matches: the run stops before anything is translated or sent
|
|
11
|
+
* (exit 1), naming the closest keys that exist;
|
|
12
|
+
* - some names match: those are redone, then the run fails (exit 1)
|
|
13
|
+
* naming the rest — said again at the very end, where CI looks.
|
|
14
|
+
*
|
|
15
|
+
* Each lane says what its key space is (`known`) — the key-value lane adds
|
|
16
|
+
* the plural forms a target gets and the namespace prefix; the Docusaurus
|
|
17
|
+
* lane collects every source JSON file's message ids.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { fromTypedContext } from './locale-layout.js';
|
|
21
|
+
import { splitKeyList } from './redo.js';
|
|
22
|
+
import { output } from './output.js';
|
|
23
|
+
import { estimateCost } from './pairs.js';
|
|
24
|
+
import { costLabel } from './cost-label.js';
|
|
25
|
+
import { editDistance } from './edit-distance.js';
|
|
26
|
+
|
|
27
|
+
/** A key as a person reads and types it: the gettext context separator as ␄. */
|
|
28
|
+
export const shownKey = (k) => String(k).replace(/\u0004/g, '\u2404');
|
|
29
|
+
|
|
30
|
+
/** "`button␄Cancel` (or type button\x04Cancel)" — both spellings, for a key with a context. */
|
|
31
|
+
function bothSpellings(k) {
|
|
32
|
+
const shown = shownKey(k);
|
|
33
|
+
return /\u0004/.test(k) ? `\`${shown}\` (or type \`${String(k).replace(/\u0004/g, '\\x04')}\`)` : `\`${shown}\``;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which of the names match a key in `known`, and — for each that does not —
|
|
38
|
+
* the closest keys that do: every context variant of a gettext msgid
|
|
39
|
+
* (`button␄Cancel`, `status␄Cancel` for "Cancel"), the same key in another
|
|
40
|
+
* case, near spellings.
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]} names - As typed (␄ or \x04 for a gettext context)
|
|
43
|
+
* @param {Set<string>} known - Every key a name may match, in the lane's key space
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @param {(k: string) => string} [opts.bare] - A key without its namespace
|
|
46
|
+
* prefix (key-value layouts with several files); identity otherwise
|
|
47
|
+
* @param {boolean} [opts.bareMatches] - A name with no namespace names that
|
|
48
|
+
* key in every file that has it
|
|
49
|
+
* @returns {{ matched: string[], missed: Array<{ name: string, closest: string[] }> }}
|
|
50
|
+
*/
|
|
51
|
+
export function checkNamedKeys(names, known, { bare = (k) => k, bareMatches = false } = {}) {
|
|
52
|
+
// A name with no namespace of its own (bare(name) === name) names that key
|
|
53
|
+
// in every file that has it.
|
|
54
|
+
const matches = (name) => known.has(name)
|
|
55
|
+
|| (bareMatches && bare(name) === name && [...known].some(k => bare(k) === name));
|
|
56
|
+
const matched = [];
|
|
57
|
+
const missed = [];
|
|
58
|
+
for (const raw of names) {
|
|
59
|
+
const name = fromTypedContext(raw);
|
|
60
|
+
if (matches(name)) { matched.push(raw); continue; }
|
|
61
|
+
const want = bare(name);
|
|
62
|
+
const msgid = want.includes('\u0004') ? want.slice(want.indexOf('\u0004') + 1) : want;
|
|
63
|
+
const lower = want.toLowerCase();
|
|
64
|
+
const max = Math.max(2, Math.floor(want.length / 5));
|
|
65
|
+
const scored = [];
|
|
66
|
+
for (const k of known) {
|
|
67
|
+
const kb = bare(k);
|
|
68
|
+
const kMsgid = kb.includes('\u0004') ? kb.slice(kb.indexOf('\u0004') + 1) : kb;
|
|
69
|
+
let score = null;
|
|
70
|
+
if (kMsgid === msgid) score = 0; // the same msgid, with (another) context
|
|
71
|
+
else if (kb.toLowerCase() === lower || kMsgid.toLowerCase() === msgid.toLowerCase()) score = 1;
|
|
72
|
+
else {
|
|
73
|
+
const d = editDistance(kb.toLowerCase(), lower, max);
|
|
74
|
+
if (d <= max) score = 1 + d;
|
|
75
|
+
}
|
|
76
|
+
if (score !== null) scored.push([score, k]);
|
|
77
|
+
}
|
|
78
|
+
scored.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]));
|
|
79
|
+
missed.push({ name: raw, closest: scored.slice(0, 8).map(([, k]) => k) });
|
|
80
|
+
}
|
|
81
|
+
return { matched, missed };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The error lines for named keys that matched nothing. */
|
|
85
|
+
export function describeUnmatchedKeys(missed, { flag }) {
|
|
86
|
+
const lines = missed.map(({ name, closest }) => `${flag} names "${shownKey(fromTypedContext(name))}", which matches no key in the source files`
|
|
87
|
+
+ (closest.length > 0 ? ` — closest: ${closest.map(bothSpellings).join(', ')}` : ' (and nothing close to it)') + '.');
|
|
88
|
+
if (missed.some(m => m.closest.some(k => k.includes('\u0004')))) {
|
|
89
|
+
lines.push('A gettext entry with a context is named msgctxt␄msgid; type the ␄ as \\x04 if you cannot (both spellings work).');
|
|
90
|
+
}
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** How the run's named keys were given, for its messages. */
|
|
95
|
+
export function namedKeyFlag(cliArgs) {
|
|
96
|
+
return cliArgs.redo !== undefined ? '--redo keys:' : '--force-keys';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The rule, applied before any work. With no name matching it throws (the
|
|
101
|
+
* run exits 1, nothing translated or sent). With some matching, it prints
|
|
102
|
+
* the misses, narrows `cliArgs['force-keys']` and `config.forceKeys` to the
|
|
103
|
+
* names that exist, and returns the misses — the caller reports them again
|
|
104
|
+
* at the end (reportUnmatchedKeys) and the run exits 1.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} p
|
|
107
|
+
* @param {object} p.cliArgs - Parsed CLI args (mutated: force-keys narrowed)
|
|
108
|
+
* @param {object} p.config - Resolved config (mutated: forceKeys narrowed)
|
|
109
|
+
* @param {Set<string>|(() => Set<string>)} p.known - The lane's key space (lazy: built only when keys are named)
|
|
110
|
+
* @param {(k: string) => string} [p.bare]
|
|
111
|
+
* @param {boolean} [p.bareMatches]
|
|
112
|
+
* @returns {Array<{ name: string, closest: string[] }>} The names that matched nothing
|
|
113
|
+
*/
|
|
114
|
+
export function applyNamedKeyRule({ cliArgs, config, known, bare, bareMatches = false }) {
|
|
115
|
+
const named = splitKeyList(cliArgs['force-keys'] || '');
|
|
116
|
+
if (named.length === 0) return [];
|
|
117
|
+
const keys = typeof known === 'function' ? known() : known;
|
|
118
|
+
const { matched, missed } = checkNamedKeys(named, keys, { bare, bareMatches });
|
|
119
|
+
if (missed.length === 0) return [];
|
|
120
|
+
const lines = describeUnmatchedKeys(missed, { flag: namedKeyFlag(cliArgs) });
|
|
121
|
+
if (matched.length === 0) {
|
|
122
|
+
throw new Error(`${lines.join('\n')}\nNothing was translated or sent.`);
|
|
123
|
+
}
|
|
124
|
+
for (const l of lines) output.error(l);
|
|
125
|
+
output.error(`Redoing the ${matched.length} named key(s) that exist; the run then fails (exit 1) for the ${missed.length} that do not.`);
|
|
126
|
+
cliArgs['force-keys'] = matched.map(k => k.replace(/,/g, '\\,')).join(',');
|
|
127
|
+
// --redo all / --force re-queues every key anyway; the narrowed names only
|
|
128
|
+
// decide what a named redo replaces.
|
|
129
|
+
config.forceKeys = cliArgs.force ? config.forceKeys : splitKeyList(cliArgs['force-keys']);
|
|
130
|
+
return missed;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Said again at the end of the run, where a reader (or CI) looks for the verdict. */
|
|
134
|
+
export function reportUnmatchedKeys(missed, cliArgs) {
|
|
135
|
+
if (!missed || missed.length === 0) return;
|
|
136
|
+
for (const l of describeUnmatchedKeys(missed, { flag: namedKeyFlag(cliArgs) })) output.error(l);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The --json summary's `unmatchedKeys`: each name with the closest keys that exist. */
|
|
140
|
+
export function unmatchedKeysSummary(missed) {
|
|
141
|
+
return (missed || []).map(m => ({ name: m.name, closest: m.closest.map(shownKey) }));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Keys NAMED for a redo that the cache answered: the model was not asked, and
|
|
146
|
+
* a redo without --fresh said nothing about it — the persona's `--redo
|
|
147
|
+
* keys:<k>` wrote back the text it already had (Round 7, Django). Said, with
|
|
148
|
+
* the --fresh command and what it costs — by every key lane (key-value files,
|
|
149
|
+
* Docusaurus UI strings), in the same words.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} p
|
|
152
|
+
* @param {string} p.filename - The file as the run names it
|
|
153
|
+
* @param {string[]} p.keys - The named keys the cache served
|
|
154
|
+
* @param {string[]} [p.shown] - The same keys as a person names them (default: keys)
|
|
155
|
+
* @param {object} [p.served] - key → the text the cache served
|
|
156
|
+
* @param {object} [p.onDisk] - key → the text the file held before the run
|
|
157
|
+
* @param {object} p.pairConfig
|
|
158
|
+
* @param {string|null} [p.cwd]
|
|
159
|
+
* @param {boolean} [p.dryRun]
|
|
160
|
+
* @param {string} p.command - The `--redo keys: … --fresh` command that asks the model again
|
|
161
|
+
*/
|
|
162
|
+
export async function reportNamedFromCache({ filename, keys, shown = keys, served = {}, onDisk = {}, pairConfig, cwd = null, dryRun = false, command }) {
|
|
163
|
+
if (keys.length === 0) return;
|
|
164
|
+
const same = keys.filter(k => typeof served[k] === 'string' && served[k] === onDisk[k]);
|
|
165
|
+
let price = 'cost unknown';
|
|
166
|
+
try { price = costLabel(await estimateCost(keys.length, pairConfig, { cwd })); } catch { /* unknown */ }
|
|
167
|
+
const sample = `${shown.slice(0, 3).join(', ')}${shown.length > 3 ? `, +${shown.length - 3} more` : ''}`;
|
|
168
|
+
output.info(`${filename} — ${keys.length} key(s) named for a redo ${dryRun ? 'would be' : 'were'} served from the cache, `
|
|
169
|
+
+ `not asked again (${sample})${same.length === keys.length ? ' — the file already holds that text' : ''}: `
|
|
170
|
+
+ 'a redo re-checks and re-writes what the cache holds, at no cost. To ask the model again: '
|
|
171
|
+
+ `\`${command}\` (sends ${keys.length} key(s) — ${price}).`);
|
|
172
|
+
}
|
package/lib/no-translate.js
CHANGED
|
@@ -73,10 +73,11 @@ function isBareUrl(value) {
|
|
|
73
73
|
* - a pattern with no wildcard is an exact dot-path
|
|
74
74
|
*
|
|
75
75
|
* @param {string} pattern - e.g. '**.url', 'pages.software.*.repo'
|
|
76
|
+
* @param {string} [sep='.'] - Segment separator ('/' for file paths — see file-scope.js)
|
|
76
77
|
* @returns {(keySegments: string[]) => boolean} Segment-array matcher
|
|
77
78
|
*/
|
|
78
|
-
function compilePattern(pattern) {
|
|
79
|
-
const patternSegments = pattern.split(
|
|
79
|
+
function compilePattern(pattern, sep = '.') {
|
|
80
|
+
const patternSegments = pattern.split(sep);
|
|
80
81
|
|
|
81
82
|
// Per-segment regexes, built once. `**` is handled structurally below and
|
|
82
83
|
// never reaches this map.
|
|
@@ -88,7 +89,7 @@ function compilePattern(pattern) {
|
|
|
88
89
|
const source = '^' + seg
|
|
89
90
|
.split('*')
|
|
90
91
|
.map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
91
|
-
.join(
|
|
92
|
+
.join(`[^${sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}]*`) + '$';
|
|
92
93
|
const re = new RegExp(source);
|
|
93
94
|
return (s) => re.test(s);
|
|
94
95
|
});
|