mcp-context-cost 0.7.0 → 0.9.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 +24 -16
- package/dist/cli.js +27 -12
- package/dist/core/cross-check.d.ts +93 -0
- package/dist/core/cross-check.js +176 -0
- package/dist/sweep/cross-check.d.ts +31 -0
- package/dist/sweep/cross-check.js +342 -0
- package/dist/sweep/docker.d.ts +55 -0
- package/dist/sweep/docker.js +94 -1
- package/dist/sweep/published-stats.d.ts +114 -0
- package/dist/sweep/published-stats.js +383 -0
- package/dist/sweep/regen.js +13 -0
- package/dist/sweep/report.d.ts +9 -0
- package/dist/sweep/report.js +50 -4
- package/dist/sweep/run.js +35 -6
- package/dist/sweep/sweep-all.js +41 -9
- package/package.json +2 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI cross-check runner — measure each server twice in one sitting, once with
|
|
3
|
+
* our client and once with `sd2k/mcp-tokens`, and file the pair under the
|
|
4
|
+
* capture our side took:
|
|
5
|
+
* npx tsx src/sweep/cross-check.ts [--docker] [--only a,b] [--concurrency 3]
|
|
6
|
+
* [--shards N [--shard-index K]]
|
|
7
|
+
* [--default-timeout 60]
|
|
8
|
+
*
|
|
9
|
+
* Writes results/cross-check.json and nothing else — the measurements on disk
|
|
10
|
+
* keep their numbers, hashes and dates (the session-start discipline). Our
|
|
11
|
+
* measurement is taken fresh through `measureServer` — same isolation, same
|
|
12
|
+
* dummy env, same retries as a sweep — because the row is only worth publishing
|
|
13
|
+
* while it compares like with like: the fresh capture's `canonicalSha256` is
|
|
14
|
+
* what the row is filed under, and the CLI's tool names are checked against the
|
|
15
|
+
* capture's, so a server that changed between the two launches records data but
|
|
16
|
+
* prints silence.
|
|
17
|
+
*
|
|
18
|
+
* The CLI is a pinned release binary, fetched once into a host cache and
|
|
19
|
+
* verified against the release's own SHA-256 before it is ever executed; in
|
|
20
|
+
* docker mode it is bind-mounted read-only into the same image, limits and
|
|
21
|
+
* package-cache volumes a sweep uses, so the server it launches runs under the
|
|
22
|
+
* exact isolation every published measurement ran under.
|
|
23
|
+
*/
|
|
24
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
27
|
+
import { homedir, tmpdir } from 'node:os';
|
|
28
|
+
import { join, resolve } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { parse } from 'yaml';
|
|
31
|
+
import { measureServer } from './run.js';
|
|
32
|
+
import { DockerHarnessFault, defaultImageFor, dockerize } from './docker.js';
|
|
33
|
+
import { splitCommand } from './client.js';
|
|
34
|
+
import { selectShard, shardIndexForDate } from './shard.js';
|
|
35
|
+
import { CROSS_CHECK_CLI, CROSS_CHECK_CLI_ARGS, CROSS_CHECK_CLI_VERSION, CROSS_CHECK_METHOD, divergencePct, parseCliReport, parseCrossCheck, toCrossCheckRow, } from '../core/cross-check.js';
|
|
36
|
+
export function loadCrossCheck(root = process.cwd()) {
|
|
37
|
+
const p = join(root, 'results', 'cross-check.json');
|
|
38
|
+
return existsSync(p) ? parseCrossCheck(readFileSync(p, 'utf8')) : null;
|
|
39
|
+
}
|
|
40
|
+
export function writeCrossCheck(run, root = process.cwd()) {
|
|
41
|
+
// Key order sorted so a re-run of the same servers produces no diff noise.
|
|
42
|
+
const servers = {};
|
|
43
|
+
for (const name of Object.keys(run.servers).sort())
|
|
44
|
+
servers[name] = run.servers[name];
|
|
45
|
+
writeFileSync(join(root, 'results', 'cross-check.json'), JSON.stringify({ ...run, servers }, null, 2) + '\n');
|
|
46
|
+
}
|
|
47
|
+
/** Release-asset triple for where the CLI will actually run. */
|
|
48
|
+
export function cliTriple(docker, platform = process.platform, arch = process.arch) {
|
|
49
|
+
if (docker) {
|
|
50
|
+
// The container is Linux whatever the host is; its architecture is the host's.
|
|
51
|
+
return arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu';
|
|
52
|
+
}
|
|
53
|
+
const cpu = arch === 'arm64' ? 'aarch64' : 'x86_64';
|
|
54
|
+
if (platform === 'darwin')
|
|
55
|
+
return `${cpu}-apple-darwin`;
|
|
56
|
+
if (platform === 'linux')
|
|
57
|
+
return `${cpu}-unknown-linux-gnu`;
|
|
58
|
+
throw new Error(`no ${CROSS_CHECK_CLI} release asset for ${platform}/${arch} — run with --docker`);
|
|
59
|
+
}
|
|
60
|
+
function sh(command, args) {
|
|
61
|
+
return new Promise((resolvePromise) => {
|
|
62
|
+
const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
63
|
+
let stderr = '';
|
|
64
|
+
child.stderr.setEncoding('utf8');
|
|
65
|
+
child.stderr.on('data', (chunk) => (stderr = (stderr + chunk).slice(-4000)));
|
|
66
|
+
child.on('error', (err) => resolvePromise({ code: null, stderr: String(err.message) }));
|
|
67
|
+
child.on('exit', (code) => resolvePromise({ code, stderr }));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fetch the pinned CLI release for `triple` into a host cache, verifying the
|
|
72
|
+
* archive against the release's own `.sha256` before anything is extracted or
|
|
73
|
+
* executed. `MCP_TOKENS_BIN` overrides the whole dance — that is how the tests
|
|
74
|
+
* substitute a shim, and how an airgapped machine supplies its own copy.
|
|
75
|
+
*/
|
|
76
|
+
export async function ensureCliBinary(triple) {
|
|
77
|
+
const override = process.env.MCP_TOKENS_BIN;
|
|
78
|
+
if (override)
|
|
79
|
+
return override;
|
|
80
|
+
const cacheDir = join(homedir(), '.cache', 'mcp-context-cost', 'mcp-tokens', `${CROSS_CHECK_CLI_VERSION}-${triple}`);
|
|
81
|
+
const binPath = join(cacheDir, 'mcp-tokens');
|
|
82
|
+
if (existsSync(binPath))
|
|
83
|
+
return binPath;
|
|
84
|
+
const asset = `mcp-tokens-${triple}.tar.xz`;
|
|
85
|
+
const base = `https://github.com/${CROSS_CHECK_CLI}/releases/download/${CROSS_CHECK_CLI_VERSION}`;
|
|
86
|
+
const fetchBytes = async (url) => {
|
|
87
|
+
const res = await fetch(url);
|
|
88
|
+
if (!res.ok)
|
|
89
|
+
throw new Error(`GET ${url}: HTTP ${res.status}`);
|
|
90
|
+
return Buffer.from(await res.arrayBuffer());
|
|
91
|
+
};
|
|
92
|
+
const archive = await fetchBytes(`${base}/${asset}`);
|
|
93
|
+
const sumFile = (await fetchBytes(`${base}/${asset}.sha256`)).toString('utf8');
|
|
94
|
+
const expected = sumFile.trim().split(/\s+/)[0]?.toLowerCase();
|
|
95
|
+
const actual = createHash('sha256').update(archive).digest('hex');
|
|
96
|
+
if (!expected || expected !== actual) {
|
|
97
|
+
throw new Error(`${asset}: SHA-256 mismatch — expected ${expected ?? '(unparseable)'}, got ${actual}; refusing to run it`);
|
|
98
|
+
}
|
|
99
|
+
const work = join(tmpdir(), `mcp-tokens-${process.pid}-${Math.floor(Math.random() * 1e6)}`);
|
|
100
|
+
mkdirSync(work, { recursive: true });
|
|
101
|
+
try {
|
|
102
|
+
const archivePath = join(work, asset);
|
|
103
|
+
writeFileSync(archivePath, archive);
|
|
104
|
+
const tar = await sh('tar', ['-xJf', archivePath, '-C', work]);
|
|
105
|
+
if (tar.code !== 0)
|
|
106
|
+
throw new Error(`tar failed extracting ${asset}: ${tar.stderr.slice(-200)}`);
|
|
107
|
+
const found = findFile(work, 'mcp-tokens');
|
|
108
|
+
if (!found)
|
|
109
|
+
throw new Error(`${asset} did not contain an mcp-tokens binary`);
|
|
110
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
111
|
+
chmodSync(found, 0o755);
|
|
112
|
+
renameSync(found, binPath);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
rmSync(work, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
return binPath;
|
|
118
|
+
}
|
|
119
|
+
function findFile(dir, name) {
|
|
120
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
121
|
+
const p = join(dir, entry.name);
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
const hit = findFile(p, name);
|
|
124
|
+
if (hit)
|
|
125
|
+
return hit;
|
|
126
|
+
}
|
|
127
|
+
else if (entry.name === name) {
|
|
128
|
+
return p;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Run the CLI against one server, host or containerized. In docker mode the
|
|
135
|
+
* binary is bind-mounted read-only and the CLI launches the server inside the
|
|
136
|
+
* container — same image, limits, dummy env and shared package caches as the
|
|
137
|
+
* measurement that just ran, so the npx install it pays is already warm.
|
|
138
|
+
*/
|
|
139
|
+
export function runCli(binPath, entry, opts) {
|
|
140
|
+
// The CLI's own deadline gets the entry's budget — its default is 30s, which
|
|
141
|
+
// published as a failure for a server (postgres-mcp) that legitimately takes
|
|
142
|
+
// longer to start and measures fine on the same budget our client gets. Its
|
|
143
|
+
// --timeout is in seconds, for server startup.
|
|
144
|
+
const cliArgs = [...CROSS_CHECK_CLI_ARGS, '--timeout', String(Math.ceil(opts.timeoutMs / 1000))];
|
|
145
|
+
let command = binPath;
|
|
146
|
+
let argv = [...cliArgs, '--', ...splitCommand(entry.command)];
|
|
147
|
+
let containerName = null;
|
|
148
|
+
if (opts.docker) {
|
|
149
|
+
containerName = `mcp-ctx-xchk-${entry.name}-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
150
|
+
// The slim images carry no OpenSSL shared library and the CLI's linux-gnu
|
|
151
|
+
// build links libssl.so.3 — installed inside the container before launch,
|
|
152
|
+
// the same shape as dockerize's needsGit: it prefixes the containerized
|
|
153
|
+
// invocation only and is never part of any recorded command.
|
|
154
|
+
const sslPrefix = 'ldconfig -p 2>/dev/null | grep -q libssl.so.3 || ' +
|
|
155
|
+
'(apt-get update -qq && apt-get install -y -qq --no-install-recommends libssl3 >/dev/null 2>&1); ';
|
|
156
|
+
const d = dockerize(`${sslPrefix}/opt/mcp-tokens ${cliArgs.join(' ')} -- ${entry.command}`, {
|
|
157
|
+
// Explicit, from the SERVER's command: dockerize's image sniffing reads
|
|
158
|
+
// the front of the command line, which here is the ssl prefix and the
|
|
159
|
+
// CLI, not the `uvx …` it is about to launch — left implicit, every
|
|
160
|
+
// Python server's CLI run would land in the node image, uvx-less.
|
|
161
|
+
image: entry.dockerImage ?? defaultImageFor(entry.command),
|
|
162
|
+
dummyEnv: entry.env ?? [],
|
|
163
|
+
dummyEnvValues: entry.envValues,
|
|
164
|
+
needsGit: entry.needsGit,
|
|
165
|
+
containerName,
|
|
166
|
+
binds: [`${binPath}:/opt/mcp-tokens:ro`],
|
|
167
|
+
});
|
|
168
|
+
command = d.command;
|
|
169
|
+
argv = d.argv;
|
|
170
|
+
}
|
|
171
|
+
return new Promise((resolvePromise) => {
|
|
172
|
+
const child = spawn(command, argv, {
|
|
173
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
174
|
+
env: { PATH: process.env.PATH, HOME: process.env.HOME },
|
|
175
|
+
});
|
|
176
|
+
let stdout = '';
|
|
177
|
+
let stderr = '';
|
|
178
|
+
let timedOut = false;
|
|
179
|
+
child.stdout.setEncoding('utf8');
|
|
180
|
+
child.stdout.on('data', (chunk) => (stdout += chunk));
|
|
181
|
+
child.stderr.setEncoding('utf8');
|
|
182
|
+
child.stderr.on('data', (chunk) => (stderr = (stderr + chunk).slice(-4000)));
|
|
183
|
+
// Grace beyond the CLI's own deadline: startup is what its timeout covers,
|
|
184
|
+
// and the counting that follows deserves to finish rather than be killed
|
|
185
|
+
// at the exact same instant.
|
|
186
|
+
const timer = setTimeout(() => {
|
|
187
|
+
timedOut = true;
|
|
188
|
+
child.kill('SIGKILL');
|
|
189
|
+
if (containerName)
|
|
190
|
+
spawn('docker', ['rm', '-f', containerName], { stdio: 'ignore' }).on('error', () => { });
|
|
191
|
+
}, opts.timeoutMs + (opts.graceMs ?? 30_000));
|
|
192
|
+
child.on('error', (err) => {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
resolvePromise({ code: null, stdout, stderr: String(err.message), timedOut });
|
|
195
|
+
});
|
|
196
|
+
child.on('exit', (code) => {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
resolvePromise({ code, stdout, stderr, timedOut });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function arg(name) {
|
|
203
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
204
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
205
|
+
}
|
|
206
|
+
// Exact path match, for the reason src/sweep/run.ts states: any other file whose
|
|
207
|
+
// name merely ends the same way would otherwise run this block.
|
|
208
|
+
const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
209
|
+
if (isMain) {
|
|
210
|
+
const doc = parse(readFileSync('servers.yaml', 'utf8'));
|
|
211
|
+
const only = arg('only')?.split(',');
|
|
212
|
+
const docker = process.argv.includes('--docker');
|
|
213
|
+
const concurrency = Number(arg('concurrency') ?? 3);
|
|
214
|
+
const defaultTimeout = Number(arg('default-timeout') ?? 60);
|
|
215
|
+
const shards = arg('shards') === undefined ? undefined : Number(arg('shards'));
|
|
216
|
+
const shardIndexArg = arg('shard-index') === undefined ? undefined : Number(arg('shard-index'));
|
|
217
|
+
if (shards !== undefined && only) {
|
|
218
|
+
// Same refusal as sweep-all, same reason: a slice that belongs to no cycle
|
|
219
|
+
// must not be producible by accident.
|
|
220
|
+
console.error('--shards and --only both select servers; pass one or the other');
|
|
221
|
+
process.exit(2);
|
|
222
|
+
}
|
|
223
|
+
if (shardIndexArg !== undefined && shards === undefined) {
|
|
224
|
+
console.error('--shard-index needs --shards');
|
|
225
|
+
process.exit(2);
|
|
226
|
+
}
|
|
227
|
+
let entries = doc.servers.filter((s) => {
|
|
228
|
+
if (only && !only.includes(s.name))
|
|
229
|
+
return false;
|
|
230
|
+
return !s.remote; // a remote server never reaches tools/list without credentials
|
|
231
|
+
});
|
|
232
|
+
if (shards !== undefined) {
|
|
233
|
+
const index = shardIndexArg ?? shardIndexForDate(new Date(), shards);
|
|
234
|
+
entries = selectShard(entries, shards, index);
|
|
235
|
+
console.log(`shard ${index + 1}/${shards}: ${entries.map((e) => e.name).join(', ')}`);
|
|
236
|
+
}
|
|
237
|
+
// A row can only ever print by matching the hash of a published measurement,
|
|
238
|
+
// so a server with no good number on record has no comparison to make — the
|
|
239
|
+
// launches would be spent on a row that cannot become printable until a
|
|
240
|
+
// sweep publishes a capture for it. Skipped after shard selection, so the
|
|
241
|
+
// slice stays the sweep's slice.
|
|
242
|
+
const skipped = [];
|
|
243
|
+
entries = entries.filter((e) => {
|
|
244
|
+
const p = join(process.cwd(), 'results', e.name, 'measurement.json');
|
|
245
|
+
if (!existsSync(p)) {
|
|
246
|
+
skipped.push(e.name);
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const status = JSON.parse(readFileSync(p, 'utf8')).status;
|
|
251
|
+
if (status === 'measured' || status === 'dynamic')
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// An unreadable record is not a good number on record.
|
|
256
|
+
}
|
|
257
|
+
skipped.push(e.name);
|
|
258
|
+
return false;
|
|
259
|
+
});
|
|
260
|
+
if (skipped.length > 0) {
|
|
261
|
+
console.log(`skipping ${skipped.length} with no published number to compare against: ${skipped.join(', ')}`);
|
|
262
|
+
}
|
|
263
|
+
const triple = cliTriple(docker);
|
|
264
|
+
const binPath = await ensureCliBinary(triple);
|
|
265
|
+
// A command that is already its own `docker run` cannot have the CLI
|
|
266
|
+
// containerized around it — that would be docker from inside docker — so for
|
|
267
|
+
// those entries the CLI runs host-side and the server still runs in its own
|
|
268
|
+
// container, the same shape measureServer records for these commands.
|
|
269
|
+
let hostBinPromise = null;
|
|
270
|
+
const hostBin = () => (hostBinPromise ??= ensureCliBinary(cliTriple(false)));
|
|
271
|
+
console.log(`cross-checking ${entries.length} servers against ${CROSS_CHECK_CLI} ${CROSS_CHECK_CLI_VERSION} ` +
|
|
272
|
+
`(${triple}, docker=${docker}, concurrency=${concurrency})`);
|
|
273
|
+
// Merged, not replaced: a run over `--only` or one shard must not delete the
|
|
274
|
+
// rows it did not visit. A row it did visit is overwritten, errors included.
|
|
275
|
+
const prior = loadCrossCheck();
|
|
276
|
+
const servers = { ...(prior?.servers ?? {}) };
|
|
277
|
+
const queue = [...entries];
|
|
278
|
+
async function worker() {
|
|
279
|
+
for (let e = queue.shift(); e; e = queue.shift()) {
|
|
280
|
+
const timeoutMs = (e.timeoutSeconds ?? defaultTimeout) * 1000;
|
|
281
|
+
let m;
|
|
282
|
+
try {
|
|
283
|
+
m = await measureServer(e.name, e.command, {
|
|
284
|
+
timeoutMs,
|
|
285
|
+
docker,
|
|
286
|
+
dockerImage: e.dockerImage,
|
|
287
|
+
dummyEnv: e.env ?? [],
|
|
288
|
+
dummyEnvValues: e.envValues,
|
|
289
|
+
needsGit: e.needsGit,
|
|
290
|
+
persist: false, // the measurements on disk are not this run's to rewrite
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
if (!(err instanceof DockerHarnessFault))
|
|
295
|
+
throw err;
|
|
296
|
+
// A machine fault is not a fact about the server: leave its prior row alone.
|
|
297
|
+
console.log(` ${e.name}: docker harness fault — skipped, prior row untouched`);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (m.status !== 'measured' && m.status !== 'dynamic') {
|
|
301
|
+
servers[e.name] = toCrossCheckRow(m, {});
|
|
302
|
+
console.log(` ${e.name}: our measurement ${m.status} — recorded, nothing to compare`);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const selfDocker = e.command.trimStart().startsWith('docker ');
|
|
306
|
+
const out = await runCli(docker && selfDocker ? await hostBin() : binPath, e, {
|
|
307
|
+
docker: docker && !selfDocker,
|
|
308
|
+
timeoutMs,
|
|
309
|
+
});
|
|
310
|
+
const cli = out.timedOut
|
|
311
|
+
? { problem: `timeout after ${timeoutMs}ms` }
|
|
312
|
+
: out.code !== 0
|
|
313
|
+
? { problem: `exited ${out.code}: ${out.stderr.slice(-200)}` }
|
|
314
|
+
: parseCliReport(out.stdout);
|
|
315
|
+
const row = toCrossCheckRow(m, cli);
|
|
316
|
+
servers[e.name] = row;
|
|
317
|
+
const pct = divergencePct(row);
|
|
318
|
+
console.log(` ${e.name}: ours ${row.ourTokens} (mapped ${row.ourMappedTokens}), cli ${row.cliTokens}` +
|
|
319
|
+
(row.error
|
|
320
|
+
? ` — ${row.error}`
|
|
321
|
+
: !row.toolSetMatches
|
|
322
|
+
? ` — tool sets differ (${row.ourToolCount} vs ${row.cliToolCount} tools), not comparable`
|
|
323
|
+
: row.dynamic
|
|
324
|
+
? ` (${pct !== null && pct >= 0 ? '+' : ''}${pct?.toFixed(1)}% vs mapped) — dynamic listing, recorded but never printed`
|
|
325
|
+
: ` (${pct !== null && pct >= 0 ? '+' : ''}${pct?.toFixed(1)}% vs mapped)`));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
|
|
329
|
+
writeCrossCheck({
|
|
330
|
+
method: CROSS_CHECK_METHOD,
|
|
331
|
+
cli: CROSS_CHECK_CLI,
|
|
332
|
+
cliVersion: CROSS_CHECK_CLI_VERSION,
|
|
333
|
+
cliArgs: [...CROSS_CHECK_CLI_ARGS],
|
|
334
|
+
measuredAt: new Date().toISOString().slice(0, 10),
|
|
335
|
+
isolation: (docker ? 'docker (same images, limits and package caches as a sweep)' : 'host process (no container)') +
|
|
336
|
+
(process.env.MCP_TOKENS_BIN ? '; binary supplied via MCP_TOKENS_BIN' : ''),
|
|
337
|
+
servers,
|
|
338
|
+
});
|
|
339
|
+
const rows = Object.values(servers);
|
|
340
|
+
const clean = rows.filter((r) => !r.error && r.toolSetMatches && !r.dynamic);
|
|
341
|
+
console.log(`done: ${clean.length}/${rows.length} rows comparable; results/cross-check.json written`);
|
|
342
|
+
}
|
package/dist/sweep/docker.d.ts
CHANGED
|
@@ -30,6 +30,13 @@ export interface DockerOptions {
|
|
|
30
30
|
* git already on PATH would actually run.
|
|
31
31
|
*/
|
|
32
32
|
needsGit?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Extra `-v` bind mounts, verbatim (`host:container:ro`). Used to hand a
|
|
35
|
+
* host-verified binary into the container (the cross-check CLI); mounts here
|
|
36
|
+
* should be read-only so the isolation claim — clean FS, no host credentials
|
|
37
|
+
* — survives them.
|
|
38
|
+
*/
|
|
39
|
+
binds?: string[];
|
|
33
40
|
/**
|
|
34
41
|
* Skip the shared npm/uv cache volumes, paying a cold install for a clean one.
|
|
35
42
|
*
|
|
@@ -46,6 +53,54 @@ export interface DockerOptions {
|
|
|
46
53
|
}
|
|
47
54
|
export declare const DEFAULT_NODE_IMAGE = "public.ecr.aws/docker/library/node:22-slim";
|
|
48
55
|
export declare const DEFAULT_PYTHON_IMAGE = "ghcr.io/astral-sh/uv:python3.12-bookworm-slim";
|
|
56
|
+
/** The image a launch command gets when the entry does not name one. */
|
|
57
|
+
export declare function defaultImageFor(commandLine: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* Docker failed, not the server. A measurement that ends this way is a
|
|
60
|
+
* statement about this machine — the daemon, the registry, the network — and
|
|
61
|
+
* must never be recorded as the server's startup-failure: the 2026-08-26
|
|
62
|
+
* re-sweep published exactly that lie about `sequential-thinking` when the
|
|
63
|
+
* runner could not pull the base image, and the harness guard never saw it
|
|
64
|
+
* because it watches for populations of regressions, not single rows.
|
|
65
|
+
*/
|
|
66
|
+
export declare class DockerHarnessFault extends Error {
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether a capture-failure message describes `docker run` failing as docker
|
|
70
|
+
* rather than the contained server failing as itself.
|
|
71
|
+
*
|
|
72
|
+
* Docker reserves exit code 125 for its own failures, but a contained process
|
|
73
|
+
* that exits 125 passes that code through indistinguishably — so the code alone
|
|
74
|
+
* is not enough, and docker's own stderr voice ("Unable to find image …",
|
|
75
|
+
* "docker: Error response from daemon: …", "docker: Cannot connect …") is
|
|
76
|
+
* required alongside it. Only meaningful for a command this code wrapped in
|
|
77
|
+
* `docker run` itself; a config whose command is already `docker run …` owns
|
|
78
|
+
* its exit codes.
|
|
79
|
+
*/
|
|
80
|
+
export declare function isDockerRunFailure(message: string): boolean;
|
|
81
|
+
export interface EnsureImageOptions {
|
|
82
|
+
/** Run one docker invocation — injectable so tests never need a daemon. */
|
|
83
|
+
run?: (args: string[]) => Promise<{
|
|
84
|
+
code: number | null;
|
|
85
|
+
stderr: string;
|
|
86
|
+
}>;
|
|
87
|
+
/** Waits between pull attempts; attempts = delays + 1. */
|
|
88
|
+
delaysMs?: number[];
|
|
89
|
+
sleep?: (ms: number) => Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Make sure a base image is present before any container needs it, retrying the
|
|
93
|
+
* pull. `docker run --pull=missing` pulls lazily, so a transient registry
|
|
94
|
+
* failure lands mid-measurement and gets read as the server refusing to start —
|
|
95
|
+
* pulling up front, with retries, is what keeps a registry hiccup from ever
|
|
96
|
+
* reaching a measurement. Failures throw `DockerHarnessFault`.
|
|
97
|
+
*
|
|
98
|
+
* Results are memoized per image for the life of the process (only on the real
|
|
99
|
+
* docker path — injected runners are for tests), so concurrent sweep workers
|
|
100
|
+
* share one pull, and an image that could not be pulled after retries is not
|
|
101
|
+
* re-attempted by every remaining server in the sweep.
|
|
102
|
+
*/
|
|
103
|
+
export declare function ensureImage(image: string, opts?: EnsureImageOptions): Promise<void>;
|
|
49
104
|
export interface IsolationRecord {
|
|
50
105
|
docker: boolean;
|
|
51
106
|
image?: string;
|
package/dist/sweep/docker.js
CHANGED
|
@@ -2,12 +2,102 @@
|
|
|
2
2
|
// on this machine 2026-08-16: hub pulls stall indefinitely while ECR works).
|
|
3
3
|
export const DEFAULT_NODE_IMAGE = 'public.ecr.aws/docker/library/node:22-slim';
|
|
4
4
|
export const DEFAULT_PYTHON_IMAGE = 'ghcr.io/astral-sh/uv:python3.12-bookworm-slim';
|
|
5
|
+
/** The image a launch command gets when the entry does not name one. */
|
|
6
|
+
export function defaultImageFor(commandLine) {
|
|
7
|
+
return commandLine.trimStart().startsWith('uvx') ? DEFAULT_PYTHON_IMAGE : DEFAULT_NODE_IMAGE;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Docker failed, not the server. A measurement that ends this way is a
|
|
11
|
+
* statement about this machine — the daemon, the registry, the network — and
|
|
12
|
+
* must never be recorded as the server's startup-failure: the 2026-08-26
|
|
13
|
+
* re-sweep published exactly that lie about `sequential-thinking` when the
|
|
14
|
+
* runner could not pull the base image, and the harness guard never saw it
|
|
15
|
+
* because it watches for populations of regressions, not single rows.
|
|
16
|
+
*/
|
|
17
|
+
export class DockerHarnessFault extends Error {
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether a capture-failure message describes `docker run` failing as docker
|
|
21
|
+
* rather than the contained server failing as itself.
|
|
22
|
+
*
|
|
23
|
+
* Docker reserves exit code 125 for its own failures, but a contained process
|
|
24
|
+
* that exits 125 passes that code through indistinguishably — so the code alone
|
|
25
|
+
* is not enough, and docker's own stderr voice ("Unable to find image …",
|
|
26
|
+
* "docker: Error response from daemon: …", "docker: Cannot connect …") is
|
|
27
|
+
* required alongside it. Only meaningful for a command this code wrapped in
|
|
28
|
+
* `docker run` itself; a config whose command is already `docker run …` owns
|
|
29
|
+
* its exit codes.
|
|
30
|
+
*/
|
|
31
|
+
export function isDockerRunFailure(message) {
|
|
32
|
+
return /server exited \(code 125\)/.test(message) && /Unable to find image|docker: /.test(message);
|
|
33
|
+
}
|
|
34
|
+
function runDocker(args) {
|
|
35
|
+
return import('node:child_process').then(({ spawn }) => new Promise((resolve) => {
|
|
36
|
+
const child = spawn('docker', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
37
|
+
let stderr = '';
|
|
38
|
+
child.stderr.setEncoding('utf8');
|
|
39
|
+
child.stderr.on('data', (chunk) => (stderr = (stderr + chunk).slice(-4000)));
|
|
40
|
+
child.on('error', (err) => resolve({ code: null, stderr: String(err.message) }));
|
|
41
|
+
child.on('exit', (code) => resolve({ code, stderr }));
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
async function ensureImageOnce(image, opts) {
|
|
45
|
+
const run = opts.run ?? runDocker;
|
|
46
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
47
|
+
const delays = opts.delaysMs ?? [2_000, 8_000];
|
|
48
|
+
const noDocker = (stderr) => new DockerHarnessFault(`docker is not runnable on this machine: ${stderr.trim() || 'spawn docker failed'}`);
|
|
49
|
+
const inspect = await run(['image', 'inspect', image]);
|
|
50
|
+
if (inspect.code === 0)
|
|
51
|
+
return;
|
|
52
|
+
if (inspect.code === null && /ENOENT/i.test(inspect.stderr))
|
|
53
|
+
throw noDocker(inspect.stderr);
|
|
54
|
+
let lastStderr = '';
|
|
55
|
+
for (let attempt = 1; attempt <= delays.length + 1; attempt++) {
|
|
56
|
+
const pull = await run(['pull', image]);
|
|
57
|
+
if (pull.code === 0)
|
|
58
|
+
return;
|
|
59
|
+
lastStderr = pull.stderr;
|
|
60
|
+
// A missing docker binary cannot appear on a later attempt.
|
|
61
|
+
if (pull.code === null && /ENOENT/i.test(pull.stderr))
|
|
62
|
+
throw noDocker(pull.stderr);
|
|
63
|
+
if (attempt <= delays.length)
|
|
64
|
+
await sleep(delays[attempt - 1]);
|
|
65
|
+
}
|
|
66
|
+
throw new DockerHarnessFault(`could not pull ${image} after ${delays.length + 1} attempts — ` +
|
|
67
|
+
`a statement about this machine and its registry path, not about any server: ${lastStderr.slice(-300).trim()}`);
|
|
68
|
+
}
|
|
69
|
+
const ensured = new Map();
|
|
70
|
+
/**
|
|
71
|
+
* Make sure a base image is present before any container needs it, retrying the
|
|
72
|
+
* pull. `docker run --pull=missing` pulls lazily, so a transient registry
|
|
73
|
+
* failure lands mid-measurement and gets read as the server refusing to start —
|
|
74
|
+
* pulling up front, with retries, is what keeps a registry hiccup from ever
|
|
75
|
+
* reaching a measurement. Failures throw `DockerHarnessFault`.
|
|
76
|
+
*
|
|
77
|
+
* Results are memoized per image for the life of the process (only on the real
|
|
78
|
+
* docker path — injected runners are for tests), so concurrent sweep workers
|
|
79
|
+
* share one pull, and an image that could not be pulled after retries is not
|
|
80
|
+
* re-attempted by every remaining server in the sweep.
|
|
81
|
+
*/
|
|
82
|
+
export function ensureImage(image, opts = {}) {
|
|
83
|
+
if (opts.run)
|
|
84
|
+
return ensureImageOnce(image, opts);
|
|
85
|
+
let p = ensured.get(image);
|
|
86
|
+
if (!p) {
|
|
87
|
+
p = ensureImageOnce(image, opts);
|
|
88
|
+
// Mark handled so a memoized rejection never trips unhandled-rejection
|
|
89
|
+
// before the next caller awaits it.
|
|
90
|
+
p.catch(() => { });
|
|
91
|
+
ensured.set(image, p);
|
|
92
|
+
}
|
|
93
|
+
return p;
|
|
94
|
+
}
|
|
5
95
|
/**
|
|
6
96
|
* Wrap a launch command line in `docker run`. The inner command is passed to
|
|
7
97
|
* `sh -lc` inside the container; quoting is preserved by argv (no host shell).
|
|
8
98
|
*/
|
|
9
99
|
export function dockerize(commandLine, opts = {}) {
|
|
10
|
-
const image = opts.image ?? (commandLine
|
|
100
|
+
const image = opts.image ?? defaultImageFor(commandLine);
|
|
11
101
|
const argv = [
|
|
12
102
|
'run',
|
|
13
103
|
'--rm',
|
|
@@ -36,6 +126,9 @@ export function dockerize(commandLine, opts = {}) {
|
|
|
36
126
|
'UV_CACHE_DIR=/tmp/.uv-cache',
|
|
37
127
|
]),
|
|
38
128
|
];
|
|
129
|
+
for (const bind of opts.binds ?? []) {
|
|
130
|
+
argv.push('-v', bind);
|
|
131
|
+
}
|
|
39
132
|
for (const name of opts.dummyEnv ?? []) {
|
|
40
133
|
argv.push('-e', `${name}=${opts.dummyEnvValues?.[name] ?? 'dummy'}`);
|
|
41
134
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type ServerEntry } from './report.js';
|
|
2
|
+
export interface PublishedStats {
|
|
3
|
+
candidateTotal: number;
|
|
4
|
+
measuredCount: number;
|
|
5
|
+
max: {
|
|
6
|
+
name: string;
|
|
7
|
+
tokens: number;
|
|
8
|
+
};
|
|
9
|
+
second: {
|
|
10
|
+
name: string;
|
|
11
|
+
tokens: number;
|
|
12
|
+
};
|
|
13
|
+
min: {
|
|
14
|
+
name: string;
|
|
15
|
+
tokens: number;
|
|
16
|
+
};
|
|
17
|
+
/** max/min, floored to two significant digits — a span claim must not overstate. */
|
|
18
|
+
spanTimes: number;
|
|
19
|
+
/** The heaviest server's share of the default context window, rounded %. */
|
|
20
|
+
maxContextSharePct: number;
|
|
21
|
+
/** The servers README's sample table names, with their current numbers. */
|
|
22
|
+
sample: Record<string, {
|
|
23
|
+
tokens: number;
|
|
24
|
+
tools: number;
|
|
25
|
+
}>;
|
|
26
|
+
claude: {
|
|
27
|
+
runSize: number;
|
|
28
|
+
/** Rows the leaderboard prints a claude number for: measured AND capture-current. */
|
|
29
|
+
currentCount: number;
|
|
30
|
+
heaviestClaudeName: string | null;
|
|
31
|
+
github: {
|
|
32
|
+
badgeTokens: number;
|
|
33
|
+
mappedTokens: number;
|
|
34
|
+
claudeTokens: number;
|
|
35
|
+
droppedPct: number;
|
|
36
|
+
};
|
|
37
|
+
notion: {
|
|
38
|
+
badgeTokens: number;
|
|
39
|
+
claudeTokens: number;
|
|
40
|
+
};
|
|
41
|
+
/** Field-selection share across the run's rows, as fractions of the payload. */
|
|
42
|
+
shareMin: number;
|
|
43
|
+
shareMax: number;
|
|
44
|
+
/** claudeDelta / o200kFull across the run's rows that carry a number. */
|
|
45
|
+
ratioMin: number;
|
|
46
|
+
ratioMax: number;
|
|
47
|
+
};
|
|
48
|
+
deferralCostlierCount: number;
|
|
49
|
+
verify: {
|
|
50
|
+
serverName: string;
|
|
51
|
+
tokens: number;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Named in README's sample table — the choice is editorial, the numbers are not. */
|
|
55
|
+
export declare const SAMPLE_SERVERS: readonly ['github', 'xcodebuildmcp', 'brave-search', 'notion', 'playwright', 'filesystem', 'markitdown'];
|
|
56
|
+
export declare function floorToTwoSignificant(n: number): number;
|
|
57
|
+
export declare function computePublishedStats(entries: ServerEntry[], root?: string): PublishedStats;
|
|
58
|
+
export type PageFile = 'README.md' | 'docs/index.md' | 'docs/METHODOLOGY.md';
|
|
59
|
+
export declare const PAGE_FILES: PageFile[];
|
|
60
|
+
/**
|
|
61
|
+
* One maintained sentence. `template` is its exact words with slots — `{n}` a
|
|
62
|
+
* comma-formatted count, `{d}` a bare integer, `{f}` a decimal, `{w}` a server
|
|
63
|
+
* or package name — and `values` is what the slots must hold for the data on
|
|
64
|
+
* disk.
|
|
65
|
+
*/
|
|
66
|
+
export interface Claim {
|
|
67
|
+
file: PageFile;
|
|
68
|
+
id: string;
|
|
69
|
+
template: string;
|
|
70
|
+
values(stats: PublishedStats): string[];
|
|
71
|
+
}
|
|
72
|
+
export declare const PAGE_CLAIMS: Claim[];
|
|
73
|
+
/**
|
|
74
|
+
* Claims whose truth the data decides but whose words no template can rewrite —
|
|
75
|
+
* prose whose shape would have to change with the answer. These are asserted in
|
|
76
|
+
* the suite, never patched: if one goes false a person rewrites the sentence.
|
|
77
|
+
*/
|
|
78
|
+
export interface CheckClaim {
|
|
79
|
+
file: PageFile;
|
|
80
|
+
id: string;
|
|
81
|
+
/** The page's words, template-escaped like any claim (wrapping-tolerant). */
|
|
82
|
+
words: string;
|
|
83
|
+
/** null when the data agrees with the words; otherwise why it does not. */
|
|
84
|
+
holds(stats: PublishedStats): string | null;
|
|
85
|
+
}
|
|
86
|
+
export declare const CHECK_CLAIMS: CheckClaim[];
|
|
87
|
+
/** Fixed words with `\s+` for every gap (prose wraps; a claim is its words, not its layout). */
|
|
88
|
+
export declare function compileTemplate(template: string): RegExp;
|
|
89
|
+
export interface ClaimApplication {
|
|
90
|
+
text: string;
|
|
91
|
+
/** Why the claim could not be applied — a missing or ambiguous anchor. */
|
|
92
|
+
problem: string | null;
|
|
93
|
+
/** True when a slot was rewritten. */
|
|
94
|
+
changed: boolean;
|
|
95
|
+
}
|
|
96
|
+
/** Apply one claim: find its anchor exactly once, splice the slots to `want`, touch nothing else. */
|
|
97
|
+
export declare function applyClaim(text: string, claim: Pick<Claim, 'file' | 'id' | 'template'>, want: string[]): ClaimApplication;
|
|
98
|
+
export interface PagePatch {
|
|
99
|
+
text: string;
|
|
100
|
+
problems: string[];
|
|
101
|
+
/** Claim ids whose slots were rewritten. */
|
|
102
|
+
updated: string[];
|
|
103
|
+
}
|
|
104
|
+
/** Apply every claim for one page to its text. Slots are spliced in place; anchors are never rewritten. */
|
|
105
|
+
export declare function patchPageText(file: PageFile, text: string, stats: PublishedStats): PagePatch;
|
|
106
|
+
export interface PublishedStatsResult {
|
|
107
|
+
problems: string[];
|
|
108
|
+
updated: string[];
|
|
109
|
+
changedFiles: PageFile[];
|
|
110
|
+
}
|
|
111
|
+
/** Compute stats and report what regen would rewrite, without writing anything. */
|
|
112
|
+
export declare function verifyPublishedPages(entries: ServerEntry[], root?: string): PublishedStatsResult;
|
|
113
|
+
/** Compute stats and rewrite the pages in place. Returns what changed and any refusals. */
|
|
114
|
+
export declare function applyPublishedStats(entries: ServerEntry[], root?: string): PublishedStatsResult;
|