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.
package/src/rails.js ADDED
@@ -0,0 +1,821 @@
1
+ /**
2
+ * The rails profile: six tools a small agent can drive a language project
3
+ * with, end to end, without holding the whole toolset in its context.
4
+ *
5
+ * start_project champollion new — a project with a place for everything
6
+ * project_status champollion next --json — THE next step, from the files
7
+ * run_next_step run the step `project_status` named, if its mode allows
8
+ * add_data champollion data add — bring in a spreadsheet / table
9
+ * try_translation the served model, on a few of the person's sentences —
10
+ * never relaying what a local-only model writes
11
+ * explain the rails, privacy, quality, the step modes, the improve rounds, in words
12
+ *
13
+ * Every tool drives the `champollion` CLI as a separate, bounded process —
14
+ * the same commands a person types, one implementation, never a copy. The
15
+ * decisions stay with the people who hold the data: run_next_step refuses a
16
+ * human_only step, runs an ask_user step only with the person's answers, a
17
+ * confirm step only with `confirmed: true`, and never a command it was not
18
+ * just handed by project_status (the step id must match).
19
+ *
20
+ * Selected with `champollion-mcp --profile rails` (or
21
+ * CHAMPOLLION_MCP_PROFILE=rails); instructions in instructions-rails.md.
22
+ */
23
+
24
+ import { spawn } from 'node:child_process';
25
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
26
+ import { homedir } from 'node:os';
27
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { z } from 'zod';
30
+
31
+ /** Bound for any one command (founder rule: every step bounded). */
32
+ export const COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
33
+ const OUTPUT_TAIL_LINES = 40;
34
+ /** A step that failed this many times in a row runs again only on the person's say-so. */
35
+ export const MAX_FAILED_TRIES = 2;
36
+
37
+ /** The champollion CLI this server drives: CHAMPOLLION_CLI_BIN, else the dependency. */
38
+ export function resolveCliBin(env = process.env) {
39
+ if (env.CHAMPOLLION_CLI_BIN) return env.CHAMPOLLION_CLI_BIN;
40
+ const entry = fileURLToPath(import.meta.resolve('champollion'));
41
+ return join(dirname(entry), 'bin', 'cli.js');
42
+ }
43
+
44
+ function findOnPath(tool, env = process.env) {
45
+ const override = env[`${tool.toUpperCase().replace(/-/g, '_')}_BIN`];
46
+ if (override) return override;
47
+ const dirs = (env.PATH || '').split(delimiter).filter(Boolean);
48
+ dirs.push(env.UV_TOOL_BIN_DIR || join(homedir(), '.local', 'bin'));
49
+ for (const d of dirs) {
50
+ const p = join(d, tool);
51
+ try {
52
+ if (statSync(p).isFile()) return p;
53
+ } catch { /* next */ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Split a rails command into argv lists, one per `&&` part. Only plain words,
60
+ * "double" and 'single' quotes and `&&`: anything else a shell would act on
61
+ * (| ; > < $( ` &) is refused — a rails command never needs it.
62
+ */
63
+ export function tokenize(command) {
64
+ const parts = [[]];
65
+ let cur = null;
66
+ for (let i = 0; i < command.length; i++) {
67
+ const ch = command[i];
68
+ if (ch === '"' || ch === "'") {
69
+ const q = ch;
70
+ cur = cur ?? '';
71
+ for (i++; i < command.length && command[i] !== q; i++) {
72
+ if (q === '"' && command[i] === '\\' && i + 1 < command.length) cur += command[++i];
73
+ else cur += command[i];
74
+ }
75
+ if (i >= command.length) throw new Error('unbalanced quote in the step\'s command');
76
+ } else if (ch === '#' && cur === null) {
77
+ // a comment, as in a shell: forge's advice explains itself after `#`
78
+ break;
79
+ } else if (/\s/.test(ch)) {
80
+ if (cur !== null) { parts[parts.length - 1].push(cur); cur = null; }
81
+ } else if (ch === '&' && command[i + 1] === '&') {
82
+ if (cur !== null) { parts[parts.length - 1].push(cur); cur = null; }
83
+ parts.push([]);
84
+ i++;
85
+ } else if ('|;<>`$&'.includes(ch)) {
86
+ throw new Error(`the step's command uses "${ch}", which a rails command never needs — refused`);
87
+ } else {
88
+ cur = (cur ?? '') + ch;
89
+ }
90
+ }
91
+ if (cur !== null) parts[parts.length - 1].push(cur);
92
+ if (parts.some((p) => p.length === 0)) throw new Error('empty command part');
93
+ return parts;
94
+ }
95
+
96
+ /** argv → the program to spawn, or a refusal. Only the rails' own tools. */
97
+ export function launcherFor(argv, env = process.env) {
98
+ const [tool, ...rest] = argv;
99
+ if (tool === 'champollion') return { cmd: process.execPath, args: [resolveCliBin(env), ...rest] };
100
+ if (tool === 'nmt-forge') {
101
+ const bin = findOnPath('nmt-forge', env);
102
+ if (!bin) return { error: 'nmt-forge is not installed — run the install step first' };
103
+ return { cmd: bin, args: rest };
104
+ }
105
+ if (tool === 'uv') {
106
+ // only installing the training tools, never anything else
107
+ const ok = rest[0] === 'tool' && rest[1] === 'install'
108
+ && rest.slice(2).every((a) => /^(--python|--reinstall|--upgrade|3\.\d+|nmt-forge(\[[a-z,]+\])?)$/.test(a))
109
+ && rest.some((a) => a.startsWith('nmt-forge'));
110
+ if (!ok) return { error: 'only `uv tool install … nmt-forge` is a rails command' };
111
+ const bin = findOnPath('uv', env);
112
+ if (!bin) {
113
+ return { error: 'uv is not installed. Ask the person to install it from '
114
+ + 'https://docs.astral.sh/uv/ (one command there), then ask again — the agent '
115
+ + 'does not download or run installers itself.' };
116
+ }
117
+ return { cmd: bin, args: rest };
118
+ }
119
+ return { error: `"${tool}" is not a rails command` };
120
+ }
121
+
122
+ /** Run one program, bounded; resolves {code, out, timedOut}. */
123
+ export function runBounded(cmd, args, { cwd, env = process.env, timeoutMs = COMMAND_TIMEOUT_MS } = {}) {
124
+ return new Promise((resolveRun) => {
125
+ let out = '';
126
+ let timedOut = false;
127
+ const child = spawn(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
128
+ const add = (b) => { out += b.toString('utf-8'); if (out.length > 200_000) out = out.slice(-100_000); };
129
+ child.stdout.on('data', add);
130
+ child.stderr.on('data', add);
131
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); setTimeout(() => child.kill('SIGKILL'), 5000); }, timeoutMs);
132
+ child.on('error', (e) => { clearTimeout(timer); resolveRun({ code: null, out: `${out}\n${e.message}`, timedOut }); });
133
+ child.on('close', (code) => { clearTimeout(timer); resolveRun({ code, out, timedOut }); });
134
+ });
135
+ }
136
+
137
+ function tail(text) {
138
+ return text.trim().split('\n').slice(-OUTPUT_TAIL_LINES).join('\n');
139
+ }
140
+
141
+ /** Projects (folders holding champollion.project.json) under `root`, a few levels down. */
142
+ function projectsUnder(root, depth = 3) {
143
+ const found = [];
144
+ const walk = (dir, d) => {
145
+ let entries = [];
146
+ try {
147
+ entries = readdirSync(dir, { withFileTypes: true });
148
+ } catch {
149
+ return;
150
+ }
151
+ if (entries.some((e) => e.isFile() && e.name === 'champollion.project.json')) found.push(dir);
152
+ if (d >= depth) return;
153
+ for (const e of entries) {
154
+ if (e.isDirectory() && !e.name.startsWith('.') && !['node_modules', 'data', 'models'].includes(e.name)) {
155
+ walk(join(dir, e.name), d + 1);
156
+ }
157
+ }
158
+ };
159
+ walk(root, 0);
160
+ return found.slice(0, 10);
161
+ }
162
+
163
+ /** Files named `name` under `root`, a few levels down (not inside a project's own data). */
164
+ function filesNamed(root, name, depth = 3) {
165
+ const found = [];
166
+ const walk = (dir, d) => {
167
+ let entries = [];
168
+ try {
169
+ entries = readdirSync(dir, { withFileTypes: true });
170
+ } catch {
171
+ return;
172
+ }
173
+ for (const e of entries) {
174
+ if (e.isFile() && e.name === name) found.push(join(dir, e.name));
175
+ else if (e.isDirectory() && d < depth && !e.name.startsWith('.')
176
+ && !['node_modules', 'data', 'models', 'export'].includes(e.name)) walk(join(dir, e.name), d + 1);
177
+ }
178
+ };
179
+ walk(root, 0);
180
+ return found;
181
+ }
182
+
183
+ /**
184
+ * The data file add_data was given: as written when absolute, else from the
185
+ * project folder, else from where the agent started (the server's folder —
186
+ * where a person says "the files are next to where you start"). Not found:
187
+ * said with where it looked and the files of that name nearby, as paths to
188
+ * pass (acceptance runs, 2026-10-11: three small models were told only "no
189
+ * such file in the project folder" and asked the family to move files).
190
+ */
191
+ function dataFileAt(given, projectDir) {
192
+ if (isAbsolute(given)) {
193
+ return existsSync(given) ? { file: given } : { refused: `${given}: no such file` };
194
+ }
195
+ for (const base of [projectDir, process.cwd()]) {
196
+ const p = resolve(base, given);
197
+ if (existsSync(p) && statSync(p).isFile()) return { file: p, from: base === projectDir ? null : base };
198
+ }
199
+ const name = basename(given);
200
+ const near = [...new Set([...filesNamed(process.cwd(), name), ...filesNamed(dirname(projectDir), name)])]
201
+ .slice(0, 5).map((p) => relative(projectDir, p) || p);
202
+ return { refused: `${given}: no such file in the project folder (${projectDir}) or where you started `
203
+ + `(${process.cwd()})`
204
+ + (near.length ? `. Files named ${name} nearby, as paths from the project folder: ${near.join(', ')}`
205
+ : '. Ask the person where the file is — any path works, absolute or relative') };
206
+ }
207
+
208
+ /**
209
+ * What `data add` says a file also holds ("it also holds 6 example
210
+ * sentence(s) … — as pairs to learn from: champollion data add <file>"), as
211
+ * the add_data calls that bring it in. The CLI names a command; an agent with
212
+ * only these tools gets the call (2026-10-11 ladder: of three models given a
213
+ * Toolbox dictionary, one brought its example sentences in as pairs, one
214
+ * left them out, one only mentioned them).
215
+ */
216
+ export function alsoCalls(out, projectDir) {
217
+ const calls = [];
218
+ for (const line of String(out || '').split('\n')) {
219
+ const m = /^\s*it also holds (.+?) — (.+?): champollion data add (.+?)(?: --into (\w+))?\s*$/.exec(line);
220
+ if (!m) continue;
221
+ calls.push({ holds: m[1], as: m[2], tool: 'add_data',
222
+ arguments: { project_dir: projectDir, path: m[3], ...(m[4] ? { into: m[4] } : {}) } });
223
+ }
224
+ return calls;
225
+ }
226
+
227
+ /**
228
+ * The project folder a tool was given, or why it is not one — said plainly.
229
+ * A missing folder used to reach the CLI as a spawn cwd and come back as
230
+ * "spawn …/node ENOENT", which small models read as "install Node"
231
+ * (acceptance runs, 2026-10-11: one mistyped the path, one told the person
232
+ * to `brew install node`).
233
+ */
234
+ function projectAt(projectDir, { needProjectFile = true } = {}) {
235
+ const cwd = resolve(projectDir);
236
+ let isProject = false;
237
+ try {
238
+ isProject = statSync(join(cwd, 'champollion.project.json')).isFile();
239
+ } catch { /* not one */ }
240
+ if (isProject || (!needProjectFile && existsSync(cwd))) return { cwd };
241
+ // look near what was meant (the nearest folder of the given path that
242
+ // exists — a typo is usually in the last part) and where the server runs
243
+ let near = cwd;
244
+ while (!existsSync(near) && dirname(near) !== near) near = dirname(near);
245
+ const wide = near === dirname(near) || near === homedir(); // never scan / or ~
246
+ const here = [...new Set([...(wide ? [] : projectsUnder(near)), ...projectsUnder(process.cwd())])]
247
+ .slice(0, 10);
248
+ const what = existsSync(cwd) ? `${cwd} is a folder, but not a project (no champollion.project.json)`
249
+ : `there is no folder ${cwd}`;
250
+ return { refused: `${what}. `
251
+ + (here.length ? `The projects here: ${here.join(', ')} — pass one of them as project_dir.`
252
+ : 'No project was found here — start one with start_project.') };
253
+ }
254
+
255
+ async function cliJson(args, cwd, env) {
256
+ const r = await runBounded(process.execPath, [resolveCliBin(env), ...args, '--json'], { cwd, env, timeoutMs: 120_000 });
257
+ try {
258
+ return { code: r.code, doc: JSON.parse(r.out.slice(r.out.indexOf('{'))) };
259
+ } catch {
260
+ return { code: r.code, error: tail(r.out) || `champollion ${args[0]} gave no answer (exit ${r.code})` };
261
+ }
262
+ }
263
+
264
+ /** The compact step an agent needs (no file lists, no internals). */
265
+ export function compact(next) {
266
+ if (!next) return null;
267
+ const out = { project: next.project };
268
+ if (next.stage) out.stage = `${next.stage.n}/${next.stage.of} ${next.stage.title}`;
269
+ if (next.step) {
270
+ const s = next.step;
271
+ out.step = { id: s.id, mode: s.mode, say: s.say, why: s.why };
272
+ if (s.mode === 'human_only' && s.agent) {
273
+ // A person's step an agent does with its own tools (bring in the files
274
+ // the person named): shown as that, by its own mode, naming the tool.
275
+ // Small models acted on "human_only" whatever a hint beside it said and
276
+ // handed the step back (acceptance runs, 2026-10-11).
277
+ out.step.mode = 'use_tool';
278
+ out.step.tool = s.agent_tool || 'add_data';
279
+ out.step.say = s.agent;
280
+ out.step.for_the_person = s.say;
281
+ }
282
+ // The exact call that takes the step. The command is what the rails run,
283
+ // not a tool: a weak model turned `champollion data mark` into a tool
284
+ // "data_mark" (Llama 3.1 8B, ladder round 5), and one ended its turn at
285
+ // "WAIT for it to finish" with training running (GPT-4o mini, round 3).
286
+ const take = { project_dir: next.project, step_id: s.id };
287
+ if (s.mode === 'wait') {
288
+ out.step.call = { tool: 'project_status', arguments: { project_dir: next.project, wait_seconds: 120 } };
289
+ } else if (s.mode === 'auto') {
290
+ out.step.call = { tool: 'run_next_step', arguments: take };
291
+ } else if (s.mode === 'confirm') {
292
+ out.step.call = { tool: 'run_next_step', arguments: take,
293
+ once_they_say_yes: 'ask the person first; add "confirmed": true only after they say yes' };
294
+ } else if (s.mode === 'ask_user' && s.answers) {
295
+ // the slots, named — the person's words go in them, never these
296
+ const slots = Object.fromEntries(Object.entries(s.answers).map(([k, r]) =>
297
+ [k, `<${r.enum ? r.enum.join(' or ') : (r.say || k)}>`]));
298
+ out.step.call = { tool: 'run_next_step', arguments: { ...take, answers: slots },
299
+ fill_with: 'the person\'s own answers to step.question — never yours' };
300
+ } else if (out.step.mode === 'use_tool') {
301
+ out.step.call = { tool: out.step.tool,
302
+ arguments: { project_dir: next.project, path: '<each file the person named, where it is>' } };
303
+ }
304
+ if (s.question) out.step.question = s.question;
305
+ if (s.answers) out.step.answers = s.answers;
306
+ if (s.command) out.step.command = s.command;
307
+ } else if (!next.error) {
308
+ out.step = null;
309
+ out.say = 'Every step on the rails is done.';
310
+ // what the people can do now (try the served model on its page; an app)
311
+ if (next.finished) out.finished = next.finished;
312
+ }
313
+ // what is true right now, from forge's record — what the agent says when it
314
+ // tells the people what has been done, and never more (wave 1, 2026-10-12)
315
+ if (next.now?.length) out.true_now = next.now;
316
+ // files waiting for the agreement of the people whose words they hold —
317
+ // asked when the people have it, never holding up the rest (wave 1)
318
+ if (next.waiting?.length) {
319
+ out.waiting_for_agreement = next.waiting.map((w) => ({
320
+ file: w.file, question: w.question, say: w.say,
321
+ call: { tool: 'run_next_step', arguments: { project_dir: next.project, step_id: w.id,
322
+ answers: { agreed: '<yes — only once the person says everyone agreed>',
323
+ ...(w.answers?.training ? { training: '<allowed or not-allowed — the holders\' answer>' } : {}) } },
324
+ fill_with: 'the person\'s own answer — never yours' },
325
+ }));
326
+ }
327
+ // A served model stays in use while the improve rounds run: its try page
328
+ // and the way into an app or site (champollion connect) travel with every
329
+ // later step, not only with a finished journey. Since the improve loop
330
+ // followed serving, the journey never finishes, and an agent on these tools
331
+ // never heard of connect (packaged walk, 2026-10-18).
332
+ if (next.step && next.forge?.state === 'serving' && next.forge.why) {
333
+ out.model_in_use = { say: next.forge.why,
334
+ ...(next.forge.command ? { for_the_person: next.forge.command } : {}) };
335
+ }
336
+ if (next.forge?.blockers?.length) out.blockers = next.forge.blockers;
337
+ if (next.forge?.warnings?.length) out.warnings = next.forge.warnings;
338
+ if (next.error) out.error = next.error;
339
+ return out;
340
+ }
341
+
342
+ const text = (obj) => ({ content: [{ type: 'text', text: typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2) }] });
343
+ const refusal = (msg, extra = {}) => ({ ...text({ refused: msg, ...extra }), isError: true });
344
+
345
+ /** Fill an ask_user step's answer slots, validated against its schema. */
346
+ export function fillAnswers(argvParts, schema, answers) {
347
+ const filled = {};
348
+ for (const [key, rule] of Object.entries(schema || {})) {
349
+ const v = answers?.[key];
350
+ if (v === undefined || v === null || String(v).trim() === '') {
351
+ return { error: `missing the answer "${key}" — ask the person who holds the data; never answer for them` };
352
+ }
353
+ // a slot's placeholder passed back as the answer is no one's answer
354
+ // (the step's call names its slots as <…>; a weak model may copy them)
355
+ if (/^\s*<[^>]*>\s*$/.test(String(v))) {
356
+ return { error: `"${key}" is still the placeholder ${String(v).trim()} — ask the person and pass their words` };
357
+ }
358
+ if (rule.enum && !rule.enum.includes(v)) return { error: `"${key}" must be one of ${rule.enum.join(', ')}` };
359
+ filled[key] = String(v);
360
+ }
361
+ const parts = argvParts.map((argv) => argv.map((a) => a.replace(/\{([a-z_]+)\}/g, (m, k) => (k in filled ? filled[k] : m))));
362
+ const left = parts.flat().find((a) => /\{[a-z_]+\}/.test(a));
363
+ if (left) return { error: `the command still has an unfilled slot (${left})` };
364
+ return { parts };
365
+ }
366
+
367
+ const TRY_MAX_LINES = 10;
368
+ const TRY_TIMEOUT_MS = 120_000;
369
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
370
+
371
+ /**
372
+ * The model this project serves: the newest running, healthy `serve` job
373
+ * (`nmt-forge jobs --json`, read from the project's files), on this machine
374
+ * only, with its /health. Resolves {base, health} or {error}.
375
+ */
376
+ export async function servedModel(cwd, env = process.env) {
377
+ const l = launcherFor(['nmt-forge', 'jobs', '--json'], env);
378
+ if (l.error) return { error: l.error };
379
+ const r = await runBounded(l.cmd, l.args, { cwd, env, timeoutMs: 60_000 });
380
+ let jobs;
381
+ try {
382
+ jobs = JSON.parse(r.out.slice(r.out.indexOf('[')));
383
+ } catch {
384
+ return { error: tail(r.out) || `nmt-forge jobs gave no answer (exit ${r.code})` };
385
+ }
386
+ const serving = (Array.isArray(jobs) ? jobs : [])
387
+ .filter((j) => j.kind === 'serve' && j.state === 'running' && j.healthy && j.health_url);
388
+ if (!serving.length) {
389
+ return { error: 'no model is being served for this project — project_status names the step that starts it' };
390
+ }
391
+ const url = new URL(serving[serving.length - 1].health_url);
392
+ if (!LOOPBACK_HOSTS.has(url.hostname)) {
393
+ return { error: `the served model is at ${url.host}, not on this computer — try it there` };
394
+ }
395
+ const base = url.href.replace(/\/health\/?$/, '');
396
+ try {
397
+ const h = await fetch(url, { signal: AbortSignal.timeout(10_000) });
398
+ if (!h.ok) return { error: `the model at ${base} answered /health with ${h.status}` };
399
+ return { base, health: await h.json() };
400
+ } catch (e) {
401
+ return { error: `the model at ${base} did not answer (${e.message})` };
402
+ }
403
+ }
404
+
405
+ const EXPLAIN = {
406
+ rails: 'A project on the rails is one folder with a place for everything: data/parallel (pairs to learn from), data/test (sentences a fluent speaker checked — never learned from), data/lexicon (a word list: the rails measure what looking words up scores — the floor a model must beat), data/grammar, consent/. champollion next names THE next step from the files; the steps are: gather data → record its terms → protect a test set → install the training tools → build and prove a model. Every step is bounded; nothing runs forever.',
407
+ privacy: 'Beside each data file, <file>.champollion.json records what its holders decided: local-only (only a model on this computer may see it), whether a model may train on it, who holds it. Every tool obeys it, and it only ever gets stricter. You are an outside AI service: never open the user\'s data files — use add_data, which reports counts and columns, never sentences.',
408
+ quality: 'The rails promise you will KNOW how good a model is on the people\'s own test sentences — not that it is good. A small model trained on a few thousand pairs is usually a phrasebook for phrases like the ones it saw, not a general translator. Pass on every caveat the tools print, word for word. A fluent speaker decides what is fit to use.',
409
+ modes: 'auto: you may run it (run_next_step). confirm: ask the person first, then run it with confirmed: true (installs, training); on "not now", run_next_step with declined: true — nothing runs, and it is not asked again this session. ask_user: ask the person who holds the data the question, in their words, then run it with their answers — never answer for them. wait: something is running; call project_status with wait_seconds. use_tool: you do it now with step.tool (bring in the files the person named with add_data), as step.say says — do not hand it back. human_only: a person does this (gather sentences, choose a model) — tell them what step.say says.',
410
+ improve: 'Once a model is in use, it gets better in rounds. Each round: forge makes a wishlist sheet (improve/round-N/wishlist.md) of what would help most — words the model never saw, the kinds of sentence it gets wrong on the dev set, and open prompts; the people write 20 or more sentences they really want translated, using it as much or as little as they like; the model drafts them onto a sheet (improve/round-N/review.csv); the speaker marks each draft Y (right), N (wrong — and writes how it is said) or ? (unsure) — about one row in ten comes back from an earlier sheet on purpose (the sheet says so; their agreement with their own earlier marks shows how far the marks can be trusted); their confirmed drafts and fixes become new training sentences (the people say who holds them, as for any file). While the dev (practice) set is small, rows have no draft (one in two until it holds 50 sentences, then one in four): the speaker writes them, and they join the dev set, never training — a bigger dev set lets a round\'s gain show. Everything is screened and the model trains again. Forge then compares the new model with the one in use on the same dev sentences and offers it for use only if it is measurably better (the people say yes; a verdict alone changes nothing), and says how big a difference that dev set can see — otherwise the model in use stays, and the next round begins. Under 50 dev sentences no comparison can decide: the round reads "too few to tell", the next sheet mixes both models\' drafts blind, and the speaker\'s marks decide (forge offers the newer model if they clearly prefer it). When two rounds in a row that the dev set could judge show no gain (counted from the last model put to use), the rails offer a stronger model (a big download, longer training): ask the people — yes, or not now. The speaker\'s share of drafts marked right is the measure that matters. Never open the sheets: they hold the family\'s sentences, their word lists and the speaker\'s words.',
411
+ off_rails: 'Every step is an ordinary command (champollion, nmt-forge) the person can run in a terminal too. Stepping off the rails is fine: change a file, run an expert command, then call project_status — the next step is always read from the files, never remembered.',
412
+ };
413
+
414
+ /**
415
+ * Register the rails tools on an McpServer.
416
+ * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
417
+ */
418
+ /** The originals a project's data came from (each sidecar's derived_from), anywhere under data/. */
419
+ export function originalsIn(projectDir) {
420
+ const names = new Set();
421
+ const walk = (dir, depth) => {
422
+ let entries = [];
423
+ try {
424
+ entries = readdirSync(dir, { withFileTypes: true });
425
+ } catch {
426
+ return;
427
+ }
428
+ for (const e of entries) {
429
+ const p = join(dir, e.name);
430
+ if (e.isDirectory() && depth < 3) walk(p, depth + 1);
431
+ else if (e.isFile() && e.name.endsWith('.champollion.json')) {
432
+ try {
433
+ const from = JSON.parse(readFileSync(p, 'utf-8')).derived_from;
434
+ if (typeof from === 'string') names.add(basename(from));
435
+ } catch { /* not a sidecar we can read: says nothing */ }
436
+ }
437
+ }
438
+ };
439
+ walk(join(projectDir, 'data'), 0);
440
+ return names;
441
+ }
442
+
443
+ /**
444
+ * The files add_data could not bring in this session and that are still not
445
+ * in the project, said on every status until they are (or the person
446
+ * decides): a refused file was left behind without a word — GPT-5 nano's
447
+ * guessed sheet name, the family's largest file (model ladder, 2026-10-11).
448
+ */
449
+ export function notAddedFor(memory, projectDir) {
450
+ const tried = memory.get(projectDir);
451
+ if (!tried?.size) return null;
452
+ const inProject = originalsIn(projectDir);
453
+ const left = [...tried.values()].filter((t) => !inProject.has(t.name));
454
+ if (!left.length) return null;
455
+ return {
456
+ files: left.map((t) => ({ file: t.file, refused: t.refused })),
457
+ say: 'add_data could not bring these in yet, and they are not in the project: bring each in (its '
458
+ + 'refusal says how), or tell the person which is left out and why.',
459
+ };
460
+ }
461
+
462
+ /**
463
+ * Why `declined: true` does not fit this step, or null. A "not now" that is one
464
+ * of the step's own answers (the stronger-model offer: try_it yes | not-now) goes
465
+ * in `answers`: forge records it, and the offer comes back only after more
466
+ * rounds without a gain — `declined` would last this session only and ask again
467
+ * in the next one. `declined` is for the steps whose yes has no such answer: a
468
+ * confirm step, or a question the person cannot answer yet.
469
+ */
470
+ export function declineRefusal(step) {
471
+ if (step.mode !== 'confirm' && step.mode !== 'ask_user') {
472
+ return `this step does not wait on the person's yes (${step.mode}) — there is nothing to decline`;
473
+ }
474
+ const own = Object.entries(step.answers || {}).find(([, r]) => (r?.enum || []).includes('not-now'));
475
+ if (own) {
476
+ return `"not now" is one of this step's own answers — pass it in answers (${own[0]}: "not-now"): it is `
477
+ + 'recorded, and this comes back only after more rounds; declined would ask again next session';
478
+ }
479
+ return null;
480
+ }
481
+
482
+ export function registerRailsTools(server, { env = process.env } = {}) {
483
+ const projectDir = z.string().describe('The project folder (the one with champollion.project.json).');
484
+ // step → {count, why, output} of its consecutive failures (this server's life)
485
+ const failures = new Map();
486
+ // step → {at}: the person said not now (this server's life: one session) —
487
+ // not asked again this session; a later yes takes it (stress test 7)
488
+ const declines = new Map();
489
+ const withDeclined = (obj, cwd) => {
490
+ const d = obj?.step && declines.get(`${cwd}::${obj.step.id}`);
491
+ if (!d) return obj;
492
+ return { ...obj, step: { ...obj.step, declined_this_session: d.at,
493
+ say: `They said not now (${d.at.slice(11, 16)} UTC) to this: ${obj.step.say} Do not ask again this session — `
494
+ + 'nothing that needs their yes runs. If they bring it up themselves and say yes, take it with confirmed: '
495
+ + 'true (or their answers).' } };
496
+ };
497
+ // project → (file name → {name, file, refused}): what add_data could not bring in
498
+ const notAdded = new Map();
499
+ const withNotAdded = (obj, cwd) => {
500
+ const n = notAddedFor(notAdded, cwd);
501
+ return n ? { ...obj, files_not_added: n } : obj;
502
+ };
503
+
504
+ server.tool(
505
+ 'start_project',
506
+ 'Start a machine-translation project on the rails: a folder with a place for everything, the '
507
+ + 'decisions file, and the first step. Languages are codes or names ("English", "Plains Cree"); a '
508
+ + 'private-use code (qaa) needs its name. The first call makes nothing: it gives you the direction to '
509
+ + 'say back to the person; call again with confirmed: true once they agree.',
510
+ {
511
+ folder: z.string().describe('New folder for the project (relative to parent_dir).'),
512
+ from: z.string().describe('The language people will put IN (what they have): code or name.'),
513
+ to: z.string().describe('The language that comes OUT (what they want): code or name.'),
514
+ target_name: z.string().optional().describe('The "to" language\'s name as its people say it (required when to is qaa–qtz).'),
515
+ source_name: z.string().optional().describe('The "from" language\'s name as its people say it (required when from is qaa–qtz).'),
516
+ purpose: z.string().optional().describe('What the translations are for, in the person\'s words.'),
517
+ parent_dir: z.string().optional().describe('Where to create the folder (default: this server\'s working directory).'),
518
+ confirmed: z.boolean().optional().describe('true only after the person agreed to the direction the first call gave you to say back.'),
519
+ },
520
+ async ({ folder, from, to, target_name, source_name, purpose, parent_dir, confirmed }) => {
521
+ const cwd = resolve(parent_dir || process.cwd());
522
+ // a parent folder that is not there used to come back as "spawn …/node
523
+ // ENOENT" (a small model had invented /home/your_username)
524
+ if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
525
+ return refusal(`there is no folder ${cwd} to start the project in — leave parent_dir out to start `
526
+ + `it where you are (${process.cwd()}), or pass a folder that exists`);
527
+ }
528
+ const given = { folder, from, to, ...(target_name ? { target_name } : {}),
529
+ ...(source_name ? { source_name } : {}), ...(purpose ? { purpose } : {}), ...(parent_dir ? { parent_dir } : {}) };
530
+ const args = ['new', folder, '--from', from, '--to', to,
531
+ ...(target_name ? ['--target-name', target_name] : []), ...(source_name ? ['--source-name', source_name] : []),
532
+ ...(purpose ? ['--purpose', purpose] : []), ...(confirmed === true ? [] : ['--dry'])];
533
+ const r = await cliJson(args, cwd, env);
534
+ if (r.error || r.code !== 0) {
535
+ const why = r.error || `champollion new exited ${r.code}`;
536
+ // a name no card carries, and no close one: the exact call that uses a
537
+ // private-use code — small models copy a call far more reliably than
538
+ // they compose one (acceptance runs, 2026-10-11: two read "use qaa"
539
+ // and stopped)
540
+ const name = target_name || to;
541
+ const retry = /private-use code/.test(why) && !/^q[a-t][a-z]$/i.test(to)
542
+ ? { if_it_has_no_code: { tool: 'start_project', arguments: {
543
+ ...given, to: 'qaa', target_name: name, ...(confirmed === true ? { confirmed } : {}) } } }
544
+ : {};
545
+ return refusal(why, retry);
546
+ }
547
+ // the direction, said back before anything is made — wave 1 (2026-10-12):
548
+ // a learner who wanted their language INTO English got a project built
549
+ // from English, in all five runs; nobody had said the direction aloud
550
+ if (confirmed !== true) {
551
+ const { source: s, target: t } = r.doc.languages;
552
+ const swapped = { ...given, from: t.code, to: s.code };
553
+ delete swapped.target_name; delete swapped.source_name;
554
+ if (/^q[a-t][a-z]$/i.test(t.code)) swapped.source_name = t.name;
555
+ if (/^q[a-t][a-z]$/i.test(s.code)) swapped.target_name = s.name;
556
+ return text({
557
+ made: false,
558
+ ask_first: `Before anything is made, check the direction with the person, in plain words: "This `
559
+ + `translator takes ${s.name} and gives back ${t.name} — you put ${s.name} in, and ${t.name} comes `
560
+ + 'out. Is that the way round you want?"',
561
+ if_yes: { tool: 'start_project', arguments: { ...given, confirmed: true } },
562
+ if_the_other_way: { tool: 'start_project', arguments: { ...swapped, confirmed: true } },
563
+ languages: r.doc.languages,
564
+ });
565
+ }
566
+ // languages: what each name resolved to — with matchedAs when the person's
567
+ // word is another name the card records ("Blackfoot" → Siksika), to say back.
568
+ return text({ project: r.doc.project, languages: r.doc.languages, next: compact(r.doc.next) });
569
+ },
570
+ );
571
+
572
+ server.tool(
573
+ 'project_status',
574
+ 'THE next step for a project, read from its files: what to tell the person, why, the command, and how '
575
+ + 'to take it (mode). Call it first, and after every step. With wait_seconds, it waits (up to 120 s) for '
576
+ + 'a running step to move on.',
577
+ { project_dir: projectDir, wait_seconds: z.number().int().min(0).max(120).optional() },
578
+ async ({ project_dir, wait_seconds = 0 }) => {
579
+ const at = projectAt(project_dir);
580
+ if (at.refused) return refusal(at.refused);
581
+ const { cwd } = at;
582
+ let r = await cliJson(['next', cwd], cwd, env);
583
+ const deadline = Date.now() + wait_seconds * 1000;
584
+ const first = r.doc?.step?.id;
585
+ while (r.doc?.step?.mode === 'wait' && Date.now() < deadline) {
586
+ await new Promise((ok) => setTimeout(ok, 5000));
587
+ r = await cliJson(['next', cwd], cwd, env);
588
+ if (r.doc?.step?.id !== first) break;
589
+ }
590
+ if (r.error) return refusal(r.error);
591
+ return text(withDeclined(withNotAdded(compact(r.doc), cwd), cwd));
592
+ },
593
+ );
594
+
595
+ server.tool(
596
+ 'run_next_step',
597
+ 'Run the step project_status just named (pass its step_id). auto steps run; confirm steps need '
598
+ + 'confirmed: true after the person said yes; ask_user steps need answers — the person\'s own, never '
599
+ + 'yours; human_only and wait steps are refused with what to do instead.',
600
+ {
601
+ project_dir: projectDir,
602
+ step_id: z.string().describe('The step.id project_status returned.'),
603
+ answers: z.record(z.string()).optional().describe('ask_user steps: the person\'s answers, by the keys in step.answers.'),
604
+ confirmed: z.boolean().optional().describe('confirm steps: true only after the person said yes. Also a step that failed twice: true only when the person says to try it again as it is.'),
605
+ declined: z.boolean().optional().describe('true when the person said not now to a confirm step, or to a question with no "not-now" answer of its own: nothing runs, and it is not asked again this session. Where "not-now" is one of the answers, pass it in answers.'),
606
+ },
607
+ async ({ project_dir, step_id, answers, confirmed, declined }) => {
608
+ const at = projectAt(project_dir);
609
+ if (at.refused) return refusal(at.refused);
610
+ const { cwd } = at;
611
+ const r = await cliJson(['next', cwd], cwd, env);
612
+ if (r.error) return refusal(r.error);
613
+ // a file waiting for its speakers' agreement is answered by its own id,
614
+ // whatever step the rails are on
615
+ const step = (r.doc?.waiting || []).find((w) => w.id === step_id) || r.doc?.step;
616
+ if (!step) return refusal('there is no step to run — every step on the rails is done');
617
+ if (step.id !== step_id) {
618
+ return refusal('the project changed since that step was named — call project_status again',
619
+ { current: compact(r.doc) });
620
+ }
621
+ if (step.mode === 'human_only' && step.agent) {
622
+ return refusal(`this step is done with the ${step.agent_tool || 'add_data'} tool, not run_next_step: `
623
+ + step.agent);
624
+ }
625
+ if (step.mode === 'human_only') return refusal(`a person does this step: ${step.say}`);
626
+ if (step.mode === 'wait') return refusal('something is running — call project_status with wait_seconds');
627
+ const declineKey = `${cwd}::${step.id}`;
628
+ if (declined === true) {
629
+ // the person's "not now": recorded, nothing runs (stress test 7)
630
+ const why = declineRefusal(step);
631
+ if (why) return refusal(why, { answers: step.answers });
632
+ const at = new Date().toISOString();
633
+ declines.set(declineKey, { at });
634
+ return text({ declined: step.id, ran: [],
635
+ say: 'Nothing ran. It is not asked again this session; if they bring it up and say yes, take it then.',
636
+ next: withDeclined(withNotAdded(compact(r.doc), cwd), cwd) });
637
+ }
638
+ if (confirmed === true || answers) declines.delete(declineKey); // their yes
639
+ if (step.mode === 'confirm' && confirmed !== true) {
640
+ return refusal(`ask the person first, then call again with confirmed: true. The step: ${step.say}`);
641
+ }
642
+ if (!step.command) return refusal(`this step has no command to run: ${step.say}`);
643
+ // A step that failed twice the same way is not run a third time on the
644
+ // agent's say-so (a scripted walk retried a refused export forever):
645
+ // the person decides — fix what it says, or ask for another try.
646
+ const failKey = `${cwd}::${step.id}`;
647
+ const failed = failures.get(failKey);
648
+ if (failed && failed.count >= MAX_FAILED_TRIES && confirmed !== true) {
649
+ return refusal(`this step has failed ${failed.count} times (last: ${failed.why}). Tell the person what it `
650
+ + 'said and ask what to do: they can fix it (a corrected command run in a terminal is fine — '
651
+ + 'off the rails — then call project_status), or say to try again as it is (confirmed: true).',
652
+ { last_output: failed.output });
653
+ }
654
+ if (/<[^>]+>/.test(step.command)) {
655
+ return refusal('this step\'s command has slots only the person can fill (written as <…>): '
656
+ + `${step.command}. ${step.say}`);
657
+ }
658
+ let parts;
659
+ try {
660
+ parts = tokenize(step.command);
661
+ } catch (e) {
662
+ return refusal(e.message);
663
+ }
664
+ if (step.mode === 'ask_user') {
665
+ const f = fillAnswers(parts, step.answers, answers);
666
+ if (f.error) return refusal(f.error, { question: step.question, answers: step.answers });
667
+ parts = f.parts;
668
+ }
669
+ const ran = [];
670
+ for (const argv of parts) {
671
+ const l = launcherFor(argv, env);
672
+ if (l.error) return refusal(l.error, { ran });
673
+ const timeoutMs = argv[0] === 'uv' ? 15 * 60 * 1000 : COMMAND_TIMEOUT_MS;
674
+ const res = await runBounded(l.cmd, l.args, { cwd, env, timeoutMs });
675
+ ran.push({ command: argv.join(' '), exit_code: res.code, timed_out: res.timedOut || undefined, output: tail(res.out) });
676
+ if (res.timedOut || res.code !== 0) break;
677
+ }
678
+ const last = ran[ran.length - 1];
679
+ if (last && (last.timed_out || last.exit_code !== 0)) {
680
+ failures.set(failKey, { count: (failed?.count || 0) + 1,
681
+ why: last.timed_out ? 'timed out' : `exit ${last.exit_code}`, output: last.output });
682
+ } else {
683
+ failures.delete(failKey);
684
+ }
685
+ const after = await cliJson(['next', cwd], cwd, env);
686
+ return text({ ran, next: after.error ? { error: after.error }
687
+ : withDeclined(withNotAdded(compact(after.doc), cwd), cwd) });
688
+ },
689
+ );
690
+
691
+ server.tool(
692
+ 'add_data',
693
+ 'Bring a file the person has (a spreadsheet .xlsx/.ods/.csv, a .tsv/.txt, a table in a Word .docx, or a '
694
+ + 'Toolbox or LIFT (FieldWorks) dictionary — its words with into: lexicon, its example sentences as pairs — '
695
+ + 'or an ELAN .eaf file, whose transcription and translation tiers become pairs; or USFM scripture — a .usfm '
696
+ + 'file or folder, paired verse by verse with the other language\'s text given as usfm_source, and held back '
697
+ + 'from training until its holders say so) '
698
+ + 'into the project as sentence pairs. Never open the file yourself — this reports counts and columns, '
699
+ + 'never sentences. If it asks which column is which, ask the person.',
700
+ {
701
+ project_dir: projectDir,
702
+ path: z.string().describe('The file, where it is: absolute, or relative to the project folder or to where you started (e.g. ../family-files/grandpa.xlsx).'),
703
+ into: z.enum(['parallel', 'test', 'lexicon']).optional().describe('parallel (default); test — the checked sentences no model learns from; lexicon — a word list (dictionary).'),
704
+ source_col: z.string().optional().describe('The source column: a letter (A), a number, or its header — for an ELAN file, the tier number the refusal lists.'),
705
+ target_col: z.string().optional().describe('The target column.'),
706
+ sheet: z.string().optional().describe('Which sheet or table, when there are several.'),
707
+ header: z.boolean().optional().describe('true if the first row is a header, false if it is data.'),
708
+ usfm_source: z.string().optional().describe('For USFM scripture only: the other language\'s text (a .usfm file or a folder of them), relative to the project folder.'),
709
+ replace: z.boolean().optional().describe('true to read a file you already added again (another header or columns); its holders\' answers are kept.'),
710
+ },
711
+ async ({ project_dir, path, into, source_col, target_col, sheet, header, usfm_source, replace }) => {
712
+ const at = projectAt(project_dir);
713
+ if (at.refused) return refusal(at.refused);
714
+ const { cwd } = at;
715
+ if (/^\s*<[^>]*>\s*$/.test(path)) {
716
+ return refusal(`${path} is the step's placeholder — pass each file the person named, where it is`);
717
+ }
718
+ const found = dataFileAt(path, cwd);
719
+ const tried = notAdded.get(cwd) || new Map();
720
+ notAdded.set(cwd, tried);
721
+ const name = basename(found.file || path);
722
+ if (found.refused) {
723
+ tried.set(name, { name, file: path, refused: found.refused.split('\n')[0] });
724
+ return refusal(found.refused);
725
+ }
726
+ const args = ['data', 'add', found.from ? found.file : path, ...(into ? ['--into', into] : []),
727
+ ...(source_col ? ['--source-col', source_col] : []), ...(target_col ? ['--target-col', target_col] : []),
728
+ ...(sheet ? ['--sheet', sheet] : []), ...(header === true ? ['--header'] : []), ...(header === false ? ['--no-header'] : []),
729
+ ...(usfm_source ? ['--usfm-source', usfm_source] : []), ...(replace === true ? ['--replace'] : [])];
730
+ const res = await runBounded(process.execPath, [resolveCliBin(env), ...args], { cwd, env, timeoutMs: 120_000 });
731
+ if (res.code !== 0) {
732
+ const why = tail(res.out);
733
+ tried.set(name, { name, file: found.file, refused: why.replace(/^\[ERR\]\s*/, '').split('\n')[0] });
734
+ return refusal(why);
735
+ }
736
+ tried.delete(name);
737
+ // (the CLI ran in the project folder: its paths are from there)
738
+ const also = alsoCalls(res.out, cwd);
739
+ return text({ result: tail(res.out),
740
+ ...(found.from ? { read_from: `${found.file} (found from where you started, not the project folder)` } : {}),
741
+ ...(also.length ? { also } : {}) });
742
+ },
743
+ );
744
+
745
+ server.tool(
746
+ 'try_translation',
747
+ 'Translate a few of the person\'s own sentences with the model this project serves on this computer '
748
+ + '(once the serve step ran). If the model learned from sentences marked local-only, what it writes '
749
+ + 'stays on this machine: you get the address of its own page for the person to open, not the text.',
750
+ {
751
+ project_dir: projectDir,
752
+ text: z.string().max(2000).describe('One sentence per line, up to 10 lines: words the person gave you.'),
753
+ },
754
+ async ({ project_dir, text: input }) => {
755
+ // any folder holding the model's workshop will do; a missing one is said
756
+ const at = projectAt(project_dir, { needProjectFile: false });
757
+ if (at.refused) return refusal(at.refused);
758
+ const { cwd } = at;
759
+ const lines = input.split('\n').map((l) => l.trim()).filter(Boolean);
760
+ if (!lines.length) return refusal('nothing to translate — pass one sentence per line');
761
+ if (lines.length > TRY_MAX_LINES) return refusal(`at most ${TRY_MAX_LINES} lines at a time`);
762
+ const served = await servedModel(cwd, env);
763
+ if (served.error) return refusal(served.error);
764
+ const { base, health } = served;
765
+ // older servers do not say: unknown is treated as local-only
766
+ if (health.output_local_only !== false) {
767
+ return text({
768
+ model: health.model,
769
+ shown_to_person_only: true,
770
+ open: `${base}/`,
771
+ say: `The model is running. It learned from sentences marked local-only, and a small model repeats `
772
+ + `what it learned, so what it writes stays on this computer. Ask the person to open ${base}/ in `
773
+ + 'their browser and try it there — and not to paste its translations back to you.',
774
+ });
775
+ }
776
+ let res;
777
+ try {
778
+ res = await fetch(`${base}/translate`, {
779
+ method: 'POST',
780
+ headers: { 'content-type': 'application/json' },
781
+ body: JSON.stringify({ keys: Object.fromEntries(lines.map((l, i) => [`l${i}`, l])) }),
782
+ signal: AbortSignal.timeout(TRY_TIMEOUT_MS),
783
+ });
784
+ } catch (e) {
785
+ return refusal(`the model at ${base} did not answer (${e.name === 'TimeoutError' ? 'timed out' : e.message})`);
786
+ }
787
+ if (res.status === 401) {
788
+ return refusal('the model\'s server asks for an access token — the person can try it at '
789
+ + `${base}/ instead; tokens are never handed to an agent`);
790
+ }
791
+ let d;
792
+ try {
793
+ d = await res.json();
794
+ } catch {
795
+ return refusal(`the model at ${base} answered ${res.status} with no JSON`);
796
+ }
797
+ if (!d.translations) return refusal(d.error?.message || `the model answered ${res.status}`);
798
+ if (d.meta?.output_local_only) {
799
+ return refusal('the model says what it writes stays on this computer — the person can try it at '
800
+ + `${base}/`);
801
+ }
802
+ return text({
803
+ model: health.model,
804
+ pair: health.pair,
805
+ translations: lines.map((l, i) => ({ source: l, translation: d.translations[`l${i}`] ?? null })),
806
+ ...(d.errors ? { errors: d.errors } : {}),
807
+ say: 'Pass these on as they are. The server checks nothing it returns; only a fluent speaker can say '
808
+ + 'whether a translation is right, and a small model is often confidently wrong.',
809
+ });
810
+ },
811
+ );
812
+
813
+ server.tool(
814
+ 'explain',
815
+ 'Plain-words explanations to pass on: rails (the steps), privacy (what the sidecar means), quality '
816
+ + '(what a score does and does not promise), modes (how to take a step), improve (the rounds after a model '
817
+ + 'is in use), off_rails (stepping off).',
818
+ { topic: z.enum(Object.keys(EXPLAIN)) },
819
+ async ({ topic }) => text(EXPLAIN[topic]),
820
+ );
821
+ }