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,385 @@
1
+ /**
2
+ * Translate tool — Champollion as the diligent agent's translation engine.
3
+ *
4
+ * Rather than improvising a translation prompt, an agent calls this tool and
5
+ * gets the champollion CLI's full tested pipeline: engine dispatch (LLM via
6
+ * OpenRouter, direct OpenAI/Anthropic/Gemini, DeepL, Google Translate,
7
+ * Microsoft Translator, LibreTranslate), language-card register/formality
8
+ * conditioning, the persistent Translation Memory (identical re-requests are
9
+ * free), and the deterministic five-check quality gate (empty / source-echo /
10
+ * hallucination-loop / length-inflation / script-compliance). Every response
11
+ * says which texts came from cache, which were validated, and what the API
12
+ * call was estimated to cost.
13
+ *
14
+ * Honesty + economy contract:
15
+ * - TM is ON by default: repeated texts cost zero tokens, and the response
16
+ * reports the savings so agents learn to rely on it.
17
+ * - The quality gate is ON by default: a failed check returns the failure
18
+ * reason for that text, never a silently bad translation.
19
+ * - Missing API key / missing champollion install degrade to explicit
20
+ * actionable errors (which env var to set, what to install).
21
+ * - This is production translation, NOT benchmark evidence: nothing here
22
+ * writes to any leaderboard, and translation quality claims still belong
23
+ * to the eval harness.
24
+ *
25
+ * The champollion package is resolved from the monorepo checkout first, then
26
+ * as an installed dependency — same candidate-path pattern as languages.js.
27
+ */
28
+
29
+ import { homedir } from 'node:os';
30
+ import { dirname, resolve } from 'node:path';
31
+ import { pathToFileURL, fileURLToPath } from 'node:url';
32
+ import { existsSync, readFileSync } from 'node:fs';
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
+ /** Candidate paths to the champollion package entry; first hit wins. */
39
+ const CHAMPOLLION_CANDIDATES = [
40
+ resolve(__dirname, '../../../cli/index.js'),
41
+ resolve(__dirname, '../../cli/index.js'),
42
+ ];
43
+
44
+ /** Where the MCP server keeps its persistent Translation Memory
45
+ * (champollion writes <cwd>/.champollion/tm.json under this root). */
46
+ export const MCP_TM_ROOT = resolve(homedir(), '.champollion-mcp');
47
+
48
+ /** Candidate paths to the shared method-registry SSOT — monorepo root first,
49
+ * then the copy bundled inside the champollion npm package (kept in sync by
50
+ * `npm run sync:shared`). The package copy is resolved through Node's own
51
+ * resolver, NOT a guessed node_modules path: the old
52
+ * `../../node_modules/champollion/…` guess missed both npm's hoisting and
53
+ * the @champollion scope directory, so a real installed server crashed at
54
+ * startup — caught by the publish dress rehearsal, not by any in-repo test,
55
+ * because every in-repo path hits the monorepo candidates first. */
56
+ function methodRegistryCandidates() {
57
+ const candidates = [
58
+ resolve(__dirname, '../../../shared/method-registry.json'),
59
+ resolve(__dirname, '../../../cli/shared/method-registry.json'),
60
+ resolve(__dirname, '../../shared/method-registry.json'),
61
+ ];
62
+ try {
63
+ const pkgRoot = dirname(require.resolve('champollion'));
64
+ candidates.push(resolve(pkgRoot, 'shared', 'method-registry.json'));
65
+ } catch { /* no installed champollion package — monorepo candidates only */ }
66
+ return candidates;
67
+ }
68
+ const METHOD_REGISTRY_CANDIDATES = methodRegistryCandidates();
69
+
70
+ /** Load the SSOT and derive the MCP method surface from it. Fail-loud: a
71
+ * server that cannot find the registry must not start with a silently empty
72
+ * (or stale hand-copied) method list — that is exactly the drift this
73
+ * replaces. The previous hand-written mirror had already drifted: it missed
74
+ * 6 of 14 entries and claimed GOOGLE_API_KEY unlocks gemini (the CLI only
75
+ * reads GEMINI_API_KEY). */
76
+ function deriveMethodEnv() {
77
+ let registry = null;
78
+ let from = null;
79
+ for (const p of METHOD_REGISTRY_CANDIDATES) {
80
+ if (!existsSync(p)) continue;
81
+ registry = JSON.parse(readFileSync(p, 'utf-8'));
82
+ from = p;
83
+ break;
84
+ }
85
+ if (!registry || !registry.entries) {
86
+ throw new Error(
87
+ 'mcp-server: shared/method-registry.json not found (looked in: '
88
+ + METHOD_REGISTRY_CANDIDATES.join(', ')
89
+ + '). The method list is derived from that SSOT — refusing to start '
90
+ + 'with an empty or hand-guessed method surface.',
91
+ );
92
+ }
93
+ const env = {};
94
+ const requireAll = {};
95
+ for (const [key, entry] of Object.entries(registry.entries)) {
96
+ const runtimes = entry.runtimes; // absent = every runtime
97
+ if (Array.isArray(runtimes) && !runtimes.includes('cli')) continue;
98
+ const name = entry.cli_name || key;
99
+ // credential_env is the unlocking subset; plain env is credentials for
100
+ // simple entries (extra config vars like *_REGION appear only in env).
101
+ env[name] = entry.credential_env || entry.env || [];
102
+ requireAll[name] = entry.credential_env_all === true;
103
+ }
104
+ return { env, requireAll, from };
105
+ }
106
+
107
+ const _derived = deriveMethodEnv();
108
+
109
+ /** method name -> env var(s) that unlock it. DERIVED from
110
+ * shared/method-registry.json at load — never hand-edit a copy here. */
111
+ export const METHOD_ENV = _derived.env;
112
+
113
+ /** method name -> true when ALL of its METHOD_ENV vars are required
114
+ * (credential_env_all in the SSOT, e.g. translated/Lara's key id+secret). */
115
+ export const METHOD_ENV_ALL = _derived.requireAll;
116
+
117
+ let _champollion;
118
+
119
+ /** Load (and cache) the champollion package; null when not resolvable. */
120
+ export async function loadChampollion() {
121
+ if (_champollion !== undefined) return _champollion;
122
+ for (const p of CHAMPOLLION_CANDIDATES) {
123
+ if (!existsSync(p)) continue;
124
+ try {
125
+ _champollion = await import(pathToFileURL(p).href);
126
+ return _champollion;
127
+ } catch {
128
+ // try the next candidate
129
+ }
130
+ }
131
+ try {
132
+ _champollion = await import('champollion');
133
+ return _champollion;
134
+ } catch {
135
+ _champollion = null;
136
+ return _champollion;
137
+ }
138
+ }
139
+
140
+ /** Test hook. */
141
+ export function _setChampollionForTests(value) {
142
+ _champollion = value;
143
+ }
144
+
145
+ /** Resolve the API key for a method from the environment (env only —
146
+ * the MCP server has no project .env to read). Any-of by default; methods
147
+ * flagged credential_env_all in the SSOT (e.g. translated) need EVERY var. */
148
+ export function resolveMethodKey(method, env = process.env) {
149
+ const names = METHOD_ENV[method] || [];
150
+ if (METHOD_ENV_ALL[method]) {
151
+ const missing = names.filter((name) => !(env[name] || '').trim());
152
+ if (names.length > 0 && missing.length === 0) {
153
+ return { key: (env[names[0]] || '').trim(), envVar: names[0] };
154
+ }
155
+ return { key: null, envVar: missing[0] || names[0] || null };
156
+ }
157
+ for (const name of names) {
158
+ const v = (env[name] || '').trim();
159
+ if (v) return { key: v, envVar: name };
160
+ }
161
+ return { key: null, envVar: names[0] || null };
162
+ }
163
+
164
+ /**
165
+ * Translate texts through the champollion pipeline.
166
+ *
167
+ * @param {object} args
168
+ * @param {string[]} args.texts - Source texts (1–50).
169
+ * @param {string} args.source - Source language code (e.g. 'en').
170
+ * @param {string} args.target - Target language code (e.g. 'crk', 'fr').
171
+ * @param {string} [args.method='llm'] - Engine name. The set is derived from
172
+ * shared/method-registry.json (every CLI-runtime entry, by cli_name): llm |
173
+ * openai | anthropic | gemini | local | google-translate | deepl |
174
+ * microsoft-translator | libretranslate | apertium | tilde | translated.
175
+ * @param {string} [args.model] - Model override for LLM methods.
176
+ * @param {string} [args.register] - Style/register instruction (free text) or
177
+ * a language-card register preset name.
178
+ * @param {boolean} [args.useTm=true] - Consult + populate the persistent TM.
179
+ * @param {boolean} [args.validate=true] - Run the deterministic quality gate.
180
+ * @param {object} [deps] - Test injection: { champollion, env, tmRoot }.
181
+ * @returns {Promise<object>} structured result (see formatTranslateResult).
182
+ */
183
+ export async function translateTexts(args, deps = {}) {
184
+ const champollion = deps.champollion !== undefined
185
+ ? deps.champollion
186
+ : await loadChampollion();
187
+ if (!champollion) {
188
+ return {
189
+ status: 'unavailable',
190
+ note: 'The champollion package is not reachable from this MCP server '
191
+ + 'install. Run from the monorepo, or `npm install champollion` '
192
+ + 'alongside the server. Translation is dispatched through '
193
+ + "champollion's tested pipeline — this tool never improvises its own.",
194
+ };
195
+ }
196
+
197
+ const {
198
+ texts, source, target,
199
+ method = 'llm', model, register,
200
+ useTm = true, validate = true,
201
+ } = args;
202
+ const env = deps.env || process.env;
203
+ const tmRoot = deps.tmRoot || MCP_TM_ROOT;
204
+
205
+ if (!Array.isArray(texts) || texts.length === 0) {
206
+ return { status: 'bad-request', note: 'texts must be a non-empty array of strings.' };
207
+ }
208
+ if (texts.length > 50) {
209
+ return {
210
+ status: 'bad-request',
211
+ note: `texts has ${texts.length} entries (max 50 per call). Batch your `
212
+ + 'calls — the TM makes repeated/overlapping calls cheap, so several '
213
+ + 'smaller calls cost no more than one big one.',
214
+ };
215
+ }
216
+ if (!(method in METHOD_ENV)) {
217
+ return {
218
+ status: 'bad-request',
219
+ note: `Unknown method '${method}'. Available: ${Object.keys(METHOD_ENV).join(', ')}.`,
220
+ };
221
+ }
222
+
223
+ // Language resolution — language-card metadata conditions the prompt.
224
+ const resolveCode = champollion.resolveCode || ((c) => c);
225
+ const srcCode = resolveCode(source) || source;
226
+ const tgtCode = resolveCode(target) || target;
227
+ const card = champollion.getLanguageCard ? champollion.getLanguageCard(tgtCode) : null;
228
+
229
+ const { key: apiKey, envVar } = resolveMethodKey(method, env);
230
+ const needsKey = method !== 'libretranslate' || !env.LIBRETRANSLATE_API_URL;
231
+ if (!apiKey && needsKey) {
232
+ return {
233
+ status: 'needs-key',
234
+ note: `Method '${method}' needs ${envVar} in the MCP server's `
235
+ + 'environment. Set it (or pick a method whose key you have — '
236
+ + `${Object.entries(METHOD_ENV)
237
+ .filter(([m]) => resolveMethodKey(m, env).key)
238
+ .map(([m]) => m).join(', ') || 'none currently unlocked'}).`,
239
+ };
240
+ }
241
+
242
+ // Synthetic key/value frame — the pipeline is keyed, so index the texts.
243
+ const sourceFlat = {};
244
+ texts.forEach((t, i) => { sourceFlat[`t${i}`] = String(t); });
245
+ const allKeys = Object.keys(sourceFlat);
246
+
247
+ const pairConfig = {
248
+ source: srcCode,
249
+ target: tgtCode,
250
+ method,
251
+ model: model
252
+ || (champollion.DEFAULT_OPENROUTER_MODEL ?? 'google/gemini-3.5-flash'),
253
+ batchSize: champollion.DEFAULT_BATCH_SIZE ?? 20,
254
+ maxRetries: 2,
255
+ register: register
256
+ || (champollion.getRegister ? champollion.getRegister(tgtCode) : '')
257
+ || '',
258
+ name: card?.name || tgtCode,
259
+ dir: card?.dir || 'ltr',
260
+ scripts: card?.scripts || null,
261
+ script: null,
262
+ qualityTier: 'standard',
263
+ };
264
+
265
+ // TM entries are keyed on the FULL method key (method|model|register|
266
+ // coaching), not the bare method name: switching model or register must be
267
+ // a cache MISS, never a silent re-serve of old-style translations (see
268
+ // cli/lib/tm.js tmMethodKey). Older champollion versions don't export
269
+ // tmMethodKey — their TMs were keyed on the bare method, so falling back
270
+ // to it stays read/write-compatible with those installs.
271
+ const tmKey = typeof champollion.tmMethodKey === 'function'
272
+ ? champollion.tmMethodKey(pairConfig)
273
+ : method;
274
+
275
+ // Tier 1 — Translation Memory (free).
276
+ let tm = null;
277
+ let hits = {};
278
+ let misses = allKeys;
279
+ if (useTm && champollion.loadTM && champollion.partitionByTM) {
280
+ tm = champollion.loadTM(tmRoot);
281
+ ({ hits, misses } = champollion.partitionByTM(
282
+ tm, sourceFlat, allKeys, tgtCode, tmKey));
283
+ }
284
+
285
+ // Tier 2 — the engine, only for TM misses.
286
+ let fresh = {};
287
+ let apiError = null;
288
+ if (misses.length > 0) {
289
+ try {
290
+ fresh = await champollion.translateBatch(
291
+ misses, sourceFlat, pairConfig, { apiKey }) || {};
292
+ } catch (err) {
293
+ apiError = err.message;
294
+ }
295
+ }
296
+
297
+ // Tier 3 — deterministic quality gate on the fresh translations only
298
+ // (TM entries passed it when they were stored).
299
+ let validated = fresh;
300
+ let failures = [];
301
+ if (validate && champollion.validateTranslations && Object.keys(fresh).length > 0) {
302
+ const res = champollion.validateTranslations(fresh, sourceFlat, pairConfig);
303
+ validated = res.validated ?? res.valid ?? {};
304
+ failures = res.failures ?? [];
305
+ }
306
+
307
+ // Persist the survivors so the next agent call is free.
308
+ if (useTm && tm && champollion.storeTM && champollion.saveTM) {
309
+ for (const [key, translation] of Object.entries(validated)) {
310
+ champollion.storeTM(tm, sourceFlat[key], tgtCode, tmKey, translation);
311
+ }
312
+ if (Object.keys(validated).length > 0) champollion.saveTM(tmRoot, tm);
313
+ }
314
+
315
+ const failureByKey = {};
316
+ for (const f of failures) failureByKey[f.key] = f.reason;
317
+
318
+ const results = allKeys.map((key, i) => {
319
+ const fromTm = key in hits;
320
+ const translation = fromTm ? hits[key] : (validated[key] ?? null);
321
+ return {
322
+ index: i,
323
+ source: sourceFlat[key],
324
+ translation,
325
+ from_tm: fromTm,
326
+ validated: fromTm ? true : (key in validated),
327
+ failure: failureByKey[key] ?? (translation === null && !fromTm
328
+ ? (apiError || 'translation failed') : null),
329
+ };
330
+ });
331
+
332
+ let cost = null;
333
+ try {
334
+ const m = champollion.getMethod
335
+ ? champollion.getMethod(method, pairConfig) : null;
336
+ cost = m?.estimateCost ? m.estimateCost(misses.length, pairConfig) : null;
337
+ } catch { /* cost estimation is best-effort */ }
338
+
339
+ return {
340
+ status: 'ok',
341
+ pair: { source: srcCode, target: tgtCode },
342
+ method,
343
+ model: pairConfig.model,
344
+ register_applied: Boolean(pairConfig.register),
345
+ counts: {
346
+ requested: allKeys.length,
347
+ tm_hits: Object.keys(hits).length,
348
+ translated: Object.keys(validated).length,
349
+ failed: allKeys.length - Object.keys(hits).length - Object.keys(validated).length,
350
+ },
351
+ estimated_api_cost: cost,
352
+ results,
353
+ api_error: apiError,
354
+ };
355
+ }
356
+
357
+ /** Human-readable rendering of a translateTexts() result. */
358
+ export function formatTranslateResult(r) {
359
+ if (r.status !== 'ok') return r.note;
360
+ const out = [];
361
+ const c = r.counts;
362
+ out.push(`Translated ${r.pair.source} → ${r.pair.target} via ${r.method}`
363
+ + (r.model && r.method === 'llm' ? ` (${r.model})` : '')
364
+ + ` — ${c.tm_hits}/${c.requested} free from Translation Memory, `
365
+ + `${c.translated} newly translated, ${c.failed} failed.`);
366
+ if (r.estimated_api_cost?.estimatedCost != null) {
367
+ out.push(`Estimated API cost for the fresh calls: `
368
+ + `${r.estimated_api_cost.estimatedCost} ${r.estimated_api_cost.currency || 'USD'}.`);
369
+ }
370
+ out.push('');
371
+ for (const t of r.results) {
372
+ if (t.translation !== null) {
373
+ out.push(`[${t.index}]${t.from_tm ? ' (TM)' : ''} ${t.translation}`);
374
+ } else {
375
+ out.push(`[${t.index}] FAILED: ${t.failure}`);
376
+ }
377
+ }
378
+ if (r.results.some((t) => t.failure && t.translation === null)) {
379
+ out.push('');
380
+ out.push('Failed texts were rejected by the deterministic quality gate or '
381
+ + 'the engine — retry with a different method/model, or shorten the '
382
+ + 'text. Nothing invalid was returned as if it were good.');
383
+ }
384
+ return out.join('\n');
385
+ }