champollion-mcp-server 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -335,6 +335,7 @@ export function forgeFlagArg(flag) {
335
335
  */
336
336
  export const FORGE_FLAGS_NEVER_TOOLS = {
337
337
  '--show-text': 'prints a withheld (local-only / sealed) corpus\'s sentences — for a person at the terminal, never an agent',
338
+ '--holders-agreed-by': 'the data\'s holders\' own answer (a local-only model served off this machine) — an agent never gives it',
338
339
  '--json': 'every tool adds it',
339
340
  '--workspace': 'every tool takes `workspace`',
340
341
  '--help': 'not a step',
@@ -922,10 +923,11 @@ export function statusNextHint(r) {
922
923
  const terminal = tools.filter((t) => t.startsWith('terminal:')).map((t) => t.replace(/^terminal: nmt-forge /, ''));
923
924
  if (a.state === 'serving') {
924
925
  return 'the chosen model is served and answering (result.advice.ready names its URL) — forge has nothing '
925
- + 'left to run. What is left is the app\'s: the api method config from DEPLOY.md (in the model '
926
- + 'directory), then `npx champollion sync` and `npx champollion verify` in the app, in a terminal '
927
- + '(or the translate tool, method api, endpoint <that URL>/translate). A speaker reviews the strings '
928
- + 'before anyone relies on them.';
926
+ + 'left to run. What is left is the app\'s: `npx champollion connect <model dir> --app <the app folder>` '
927
+ + '(ask the person which app; for a model trained on local-only sentences it needs --holders-agreed-by, '
928
+ + 'the holders\' own answer), then `npx champollion sync` and `npx champollion verify` in the app, in a '
929
+ + 'terminal (or the translate tool, method api, endpoint <that URL>/translate). A speaker reviews the '
930
+ + 'strings before anyone relies on them.';
929
931
  }
930
932
  if (a.state === 'no-dev-set' && /get_training_guardrails/.test(cmd)) {
931
933
  // forge names the guardrails ONCE, here, before the split (Round 13):
@@ -647,11 +647,18 @@ export function buildCorpusArgv(params, {
647
647
  }
648
648
  const localModel = method === 'local-model'
649
649
  || (method != null && methodEntries?.[method]?.kind === 'local-model');
650
- if (attest_local_transport === true && localModel) {
651
- throw new Error('attest_local_transport does not apply to method "local-model": the harness runs that '
652
- + 'model in this process, so nothing leaves the machine and no attestation is needed (or recorded). '
650
+ // the dictionary floor: word-for-word lookup in the file given as `model`
651
+ const lexicon = method === 'lexicon'
652
+ || (method != null && methodEntries?.[method]?.kind === 'lexicon');
653
+ if (attest_local_transport === true && (localModel || lexicon)) {
654
+ throw new Error(`attest_local_transport does not apply to method "${method}": the harness runs it `
655
+ + 'in this process, so nothing leaves the machine and no attestation is needed (or recorded). '
653
656
  + 'Drop attest_local_transport.');
654
657
  }
658
+ if (lexicon && !model) {
659
+ throw new Error('method "lexicon" needs model: the dictionary file to look words up in (a .tsv, '
660
+ + 'source<TAB>target per line) — the harness refuses a run without one (-m)');
661
+ }
655
662
  if (model != null) {
656
663
  // A method's model is a name in ITS world (a Hugging Face id, a local
657
664
  // model directory, a plugin's own model name); an LLM provider's is a
@@ -669,9 +676,9 @@ export function buildCorpusArgv(params, {
669
676
  // An OPUS-MT pair model names its own pair (opus-mt-en-fi writes Finnish):
670
677
  // the harness refuses one whose pair visibly differs from the run's unless
671
678
  // the user asks for it (a related-language baseline).
672
- if (allow_model_pair_mismatch === true && !localModel) {
673
- throw new Error('allow_model_pair_mismatch applies to method "local-model" only (an OPUS-MT pair model '
674
- + 'run against another pair) — drop it.');
679
+ if (allow_model_pair_mismatch === true && !localModel && !lexicon) {
680
+ throw new Error('allow_model_pair_mismatch applies to methods "local-model" (an OPUS-MT pair model run '
681
+ + 'against another pair) and "lexicon" (a dictionary whose sidecar names another pair) only — drop it.');
675
682
  }
676
683
  if (base_url != null) {
677
684
  if (!['local', 'openai'].includes(provider)) throw new Error('base_url applies only to provider "local" or "openai"');
@@ -691,7 +691,7 @@ export function formatLanguage(r) {
691
691
  p.publishedAt && `published ${String(p.publishedAt).slice(0, 10)}`,
692
692
  p.sourceCount != null && `${p.sourceCount} cited sources`,
693
693
  p.coverage && p.coverage.total && `${p.coverage.present}/${p.coverage.total} card components present`,
694
- ].filter(Boolean).join(' · ') || 'see the card'}. Card spec: https://champollion.dev/docs/network/specifications/language-cards`);
694
+ ].filter(Boolean).join(' · ') || 'see the card'}. Card spec: https://champollion.dev/docs/reference/language-card-spec`);
695
695
  out.push(`Next: language_overview { "code": "${s.code}" } — benchmarks, results, methods, constraints and the next steps for building a model.`);
696
696
  return out.join('\n');
697
697
  }
@@ -318,28 +318,35 @@ export function uncitedReason(lang, it = 'it', { brief = false } = {}) {
318
318
  }
319
319
 
320
320
  /**
321
- * The card's alternate names (ISO 639-3's list), as attributed facts —
322
- * capped, with the overflow counted. Codes and the language's own name are
323
- * not alternate names.
321
+ * The card's alternate names (ISO 639-3's name index, SIL langtags), as
322
+ * attributed facts — capped, with the overflow counted. A spelling several
323
+ * registries record is one fact citing every one of them (Montagnais: ISO
324
+ * 639-3 and SIL langtags), and the names more registries record come first:
325
+ * with dozens of names on some cards and four shown, the corroborated ones
326
+ * are the ones a reader should see. Codes and the language's own name are not
327
+ * alternate names.
324
328
  *
325
329
  * @returns {{facts: Array<{text: string, sources: string[]}>, more: number}}
326
330
  */
327
331
  function alternateNamesOf(card, ctx) {
328
- const facts = [];
329
- const seen = new Set([String(card?.name ?? '').toLowerCase()]);
330
- let total = 0;
332
+ const byKey = new Map([[String(card?.name ?? '').toLowerCase(), null]]);
331
333
  for (const c of claimsFor(card, 'alternateNames', ['alternateNames'], ctx)) {
332
334
  for (const n of (Array.isArray(c.value) ? c.value : [c.value])) {
333
335
  // A bare lowercase 2–3 letter string is a code, not a name ("Ata" is a name).
334
336
  if (typeof n !== 'string' || !n.trim() || /^[a-z]{2,3}$/.test(n.trim())) continue;
335
337
  const key = n.trim().toLowerCase();
336
- if (seen.has(key)) continue;
337
- seen.add(key);
338
- total += 1;
339
- if (facts.length < ALT_NAME_CAP) facts.push({ text: n.trim(), sources: c.sources });
338
+ if (!byKey.has(key)) byKey.set(key, { text: n.trim(), sources: [] });
339
+ const fact = byKey.get(key);
340
+ if (!fact) continue; // the card's own name
341
+ for (const s of c.sources) if (!fact.sources.includes(s)) fact.sources.push(s);
340
342
  }
341
343
  }
342
- return { facts, more: total - facts.length };
344
+ const all = [...byKey.values()].filter(Boolean)
345
+ // stable: equally attested names keep the card's (alphabetical) order
346
+ .map((f, i) => ({ f, i }))
347
+ .sort((a, b) => b.f.sources.length - a.f.sources.length || a.i - b.i)
348
+ .map(({ f }) => f);
349
+ return { facts: all.slice(0, ALT_NAME_CAP), more: all.length - Math.min(all.length, ALT_NAME_CAP) };
343
350
  }
344
351
 
345
352
  /**
@@ -368,11 +375,15 @@ export function renderAttributed(facts, { unstamped = 'source not stated on this
368
375
  * Every name a card records for a language, each with its source: the
369
376
  * registries' names (the `name` envelope — normalizeCard keeps only the
370
377
  * displayed one, so the claims are taken from the raw card BEFORE it is
371
- * normalized: `nameClaims`) and every endonym in the `endonym` envelope (a
378
+ * normalized: `nameClaims`), every endonym in the `endonym` envelope (a
372
379
  * community's own name is often not the one displayed: the Cree
373
- * macrolanguage's card shows ᓀᐦᐃᔭᐍᐏᐣ and also records nēhiyawēwin).
380
+ * macrolanguage's card shows ᓀᐦᐃᔭᐍᐏᐣ and also records nēhiyawēwin), and
381
+ * every alternate name (ISO 639-3's name index, SIL langtags) — one entry per
382
+ * spelling, citing every registry that records it, the shape the bundle's
383
+ * name-only entries carry (manifestSearchNames), so a match on one is said
384
+ * and cited the same way whichever index found it.
374
385
  *
375
- * @returns {Array<{text: string, field: 'name'|'endonym', source: string|null}>}
386
+ * @returns {Array<{text: string, field: 'name'|'endonym'|'alternate', source: string|null}>}
376
387
  */
377
388
  function recordedNames(card, ctx, nameClaims = []) {
378
389
  const out = [];
@@ -387,6 +398,17 @@ function recordedNames(card, ctx, nameClaims = []) {
387
398
  for (const c of nameClaims) add(c?.value, 'name', c?.source);
388
399
  for (const c of ctx.attributions?.(card?.endonym) ?? []) add(c?.value, 'endonym', c?.source);
389
400
  if (card?.nativeName) add(card.nativeName, 'endonym', null);
401
+ const alternate = new Map();
402
+ for (const c of claimsFor(card, 'alternateNames', ['alternateNames'], ctx)) {
403
+ for (const n of (Array.isArray(c.value) ? c.value : [c.value])) {
404
+ // A bare lowercase 2–3 letter string is a code, not a name.
405
+ if (typeof n !== 'string' || !n.trim() || /^[a-z]{2,3}$/.test(n.trim())) continue;
406
+ const sources = alternate.get(n.trim()) ?? [];
407
+ for (const s of c.sources) if (!sources.includes(s)) sources.push(s);
408
+ alternate.set(n.trim(), sources);
409
+ }
410
+ }
411
+ for (const [text, sources] of alternate) add(text, 'alternate', sources.join(', ') || null);
390
412
  return out;
391
413
  }
392
414
 
@@ -516,10 +538,7 @@ function entryFromCard(card, ctx, { nameClaims = [] } = {}) {
516
538
  : counts.length > 1 ? `${counts.join(' / ')} (sources differ)` : '');
517
539
  const fam = card.classification?.family;
518
540
  const code = card.code || card.iso639_3 || card.bcp47 || '';
519
- const aliases = [
520
- ...(Array.isArray(card.alternateNames) ? card.alternateNames : []),
521
- ...(Array.isArray(card.aliases) ? card.aliases : []),
522
- ];
541
+ const aliases = Array.isArray(card.aliases) ? card.aliases : [];
523
542
  const names = recordedNames(card, ctx, nameClaims);
524
543
  return {
525
544
  code,
@@ -534,13 +553,13 @@ function entryFromCard(card, ctx, { nameClaims = [] } = {}) {
534
553
  region: card.macroarea || '',
535
554
  typology: card.typologicalProfile?.verbSynthesis
536
555
  || card.typologicalProfile?.morphologicalSynthesis || '',
537
- // Documented alternate names so a language is findable by ANY of its
538
- // names — the same alias set the Atlas searches.
556
+ // Other CODES for the language. Its alternate NAMES are in `names`, each
557
+ // with its source — in `aliases` a match on one could not be cited.
539
558
  aliases,
540
559
  isolate: card.isIsolate === true,
541
- // Every registry name and endonym, with its source: searchable, and a
542
- // result found through one of them says which (Round 5 school persona:
543
- // nêhiyawêwin found nothing).
560
+ // Every registry name, endonym and alternate name, with its source:
561
+ // searchable, and a result found through one of them says which (Round 5
562
+ // school persona: nêhiyawêwin found nothing).
544
563
  names,
545
564
  // Those names read in the working script of the language's registered
546
565
  // converter (Round 8: nêhiyawêwin found cre but never crk, whose card has
@@ -1147,8 +1166,11 @@ function matchTerms(index, qTokens, limit) {
1147
1166
  used.add(at);
1148
1167
  }
1149
1168
  if (used.size === words.length) {
1169
+ // A name whose every word an earlier name already matched explains
1170
+ // nothing new ("Cree, Plains" after "Plains Cree"), so it is not listed.
1171
+ const adds = [...used].some((i) => !whole.has(i));
1150
1172
  for (const i of used) { whole.add(i); covered.add(i); }
1151
- if (!matched.some((m) => normalizeForMatch(m.text) === normalizeForMatch(text))) {
1173
+ if (adds && !matched.some((m) => normalizeForMatch(m.text) === normalizeForMatch(text))) {
1152
1174
  matched.push({ text, field, ...(source ? { source } : {}), ...(derived ? { derived } : {}) });
1153
1175
  }
1154
1176
  if (field === 'name') ownName = 0;
@@ -323,8 +323,8 @@ const COMMAND_ORDER = [
323
323
  // user cannot reach. The public page below is the same map, symptom first.
324
324
  const TAXONOMY_POINTER =
325
325
  'The failure map (what you see → the likely cause → the forge guard or lever '
326
- + 'that fixes it): https://champollion.dev/docs/network/getting-started/'
327
- + 'diagnosing-training. Every forge refusal also carries its own what/why/fix. '
326
+ + 'that fixes it): https://champollion.dev/docs/agents/training'
327
+ + '. Every forge refusal also carries its own what/why/fix. '
328
328
  + 'The forge_* MCP tools drive each guard; get_training_guardrails (this tool) '
329
329
  + 'is the rulebook behind them.';
330
330
 
@@ -399,9 +399,9 @@ export function formatTrainingGuardrails(answer) {
399
399
  + 'https://champollion.dev/docs/network/context/mt-training-concepts');
400
400
  parts.push(
401
401
  '- Step-by-step, agent-forward walkthrough: '
402
- + 'https://champollion.dev/docs/network/tutorials/train-your-own-model');
402
+ + 'https://champollion.dev/docs/your-first-translator');
403
403
  parts.push(
404
404
  '- The guardrails on one page: '
405
- + 'https://champollion.dev/docs/network/getting-started/training-honestly');
405
+ + 'https://champollion.dev/docs/agents/training');
406
406
  return parts.join('\n').trim();
407
407
  }
@@ -984,12 +984,12 @@ export async function translateTexts(args, deps = {}) {
984
984
  method,
985
985
  // The OpenRouter default slug only makes sense for the OpenRouter lane;
986
986
  // every other engine (direct APIs, a local server) applies its own
987
- // default when model is unset — "google/gemini-3.8-flash" sent to Ollama
987
+ // default when model is unset — the OpenRouter default sent to Ollama
988
988
  // or to Anthropic's API is a guaranteed failure. A method that came from
989
989
  // the project brings the model sync would pair it with.
990
990
  model: model
991
991
  || (methodSource === 'project' ? projectWouldBe?.model : null)
992
- || (method === 'llm' ? (champollion.DEFAULT_OPENROUTER_MODEL ?? 'google/gemini-3.8-flash') : undefined),
992
+ || (method === 'llm' ? champollion.DEFAULT_OPENROUTER_MODEL : undefined),
993
993
  batchSize: champollion.DEFAULT_BATCH_SIZE ?? 20,
994
994
  maxRetries: 2,
995
995
  ...reg,