efaimo 0.1.1 → 0.2.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/CHANGELOG.md +128 -5
- package/README.md +69 -7
- package/SKILL.md +16 -9
- package/dist/check/check.js +8 -0
- package/dist/cli.js +179 -27
- package/dist/clients/introspect.js +10 -2
- package/dist/core/engine.d.ts +2 -1
- package/dist/core/engine.js +4 -0
- package/dist/core/types.d.ts +121 -0
- package/dist/find/bm25.d.ts +77 -0
- package/dist/find/bm25.js +107 -0
- package/dist/find/find.d.ts +120 -0
- package/dist/find/find.js +403 -0
- package/dist/find/tokenize.d.ts +64 -0
- package/dist/find/tokenize.js +116 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +13 -3
- package/dist/reporters/json.d.ts +19 -1
- package/dist/reporters/json.js +13 -2
- package/dist/reporters/markdown.d.ts +2 -1
- package/dist/reporters/markdown.js +109 -11
- package/dist/reporters/pretty.d.ts +12 -1
- package/dist/reporters/pretty.js +177 -10
- package/dist/rules/find/index.d.ts +4 -0
- package/dist/rules/find/index.js +262 -0
- package/dist/rules/injection.d.ts +1 -2
- package/dist/rules/injection.js +0 -11
- package/dist/rules/mcp/index.js +21 -1
- package/dist/rules/skill/index.js +5 -3
- package/dist/rules/version.d.ts +26 -0
- package/dist/rules/version.js +26 -0
- package/dist/targets/repoScan.js +0 -0
- package/dist/testing/anthropicRunner.d.ts +30 -0
- package/dist/testing/anthropicRunner.js +43 -8
- package/dist/testing/harness.d.ts +33 -1
- package/dist/testing/harness.js +66 -7
- package/dist/testing/openaiRunner.js +15 -8
- package/dist/testing/stats.d.ts +16 -2
- package/dist/testing/stats.js +26 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/weigh/anthropicExact.d.ts +12 -1
- package/dist/weigh/anthropicExact.js +9 -1
- package/dist/weigh/weigh.js +10 -3
- package/docs/INTEGRATIONS.md +15 -1
- package/docs/METHODOLOGY.md +111 -7
- package/docs/RULES.md +42 -5
- package/examples/scenario.crm.yaml +8 -0
- package/examples/scenario.example.yaml +8 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -13,12 +13,14 @@ import { findSkills } from "./skills/parse.js";
|
|
|
13
13
|
import { diffServerWeigh } from "./weigh/diff.js";
|
|
14
14
|
import { DEFAULT_CONTEXT_WINDOW, formatWindowShare, setContextWindow } from "./weigh/window.js";
|
|
15
15
|
import { checkMcpRepoOnly, checkMcpTarget, checkSkillSet } from "./check/check.js";
|
|
16
|
-
import {
|
|
16
|
+
import { analyzeFind, DEFAULT_TOP_K } from "./find/find.js";
|
|
17
|
+
import { runFindRules } from "./core/engine.js";
|
|
18
|
+
import { renderCheckPretty, renderDiffPretty, renderFindFindingsPretty, renderFindPretty, renderScenarioPlan, renderServerWeighPretty, renderSkillSetPretty, renderSkillWeighPretty, renderTestReportPretty, setColor, } from "./reporters/pretty.js";
|
|
17
19
|
import { parseScenario, runScenario } from "./testing/harness.js";
|
|
18
|
-
import { anthropicRunner } from "./testing/anthropicRunner.js";
|
|
20
|
+
import { acceptsSamplingParams, anthropicRunner } from "./testing/anthropicRunner.js";
|
|
19
21
|
import { openaiRunner, providerForModel } from "./testing/openaiRunner.js";
|
|
20
22
|
import { toJsonEnvelope } from "./reporters/json.js";
|
|
21
|
-
import { renderCheckMarkdown, renderDiffMarkdown, renderSkillSetMarkdown, renderWeighMarkdown } from "./reporters/markdown.js";
|
|
23
|
+
import { renderCheckMarkdown, renderDiffMarkdown, renderFindMarkdown, renderSkillSetMarkdown, renderWeighMarkdown } from "./reporters/markdown.js";
|
|
22
24
|
import { gradeBadgeSpec, makeBadgeSvg, toShieldsEndpoint, weighBadgeSpec } from "./reporters/badge.js";
|
|
23
25
|
import { loadDotEnv } from "./util/dotenv.js";
|
|
24
26
|
// Load a local .env before any command reads a key. Shell env always wins.
|
|
@@ -36,6 +38,7 @@ examples:
|
|
|
36
38
|
$ npx efaimo weigh --client claude-code
|
|
37
39
|
$ npx efaimo check --mcp "npx -y my-mcp-server" # incl. 2026-07-28 readiness
|
|
38
40
|
$ npx efaimo check --skill ./skills/
|
|
41
|
+
$ npx efaimo find "npx -y my-mcp-server" # would a search surface these tools?
|
|
39
42
|
$ npx efaimo weigh "npx -y my-server" --out base.json && npx efaimo weigh "npx -y my-server" --diff base.json
|
|
40
43
|
`);
|
|
41
44
|
function collectPairs(sep) {
|
|
@@ -82,12 +85,18 @@ function seconds(value, label = "--timeout") {
|
|
|
82
85
|
fail(`${label} must be a positive number of seconds, got "${value}"`);
|
|
83
86
|
return n;
|
|
84
87
|
}
|
|
85
|
-
function parseNumberOpt(value, label) {
|
|
88
|
+
function parseNumberOpt(value, label, bound) {
|
|
86
89
|
if (value === undefined)
|
|
87
90
|
return undefined;
|
|
88
91
|
const n = Number(value);
|
|
89
92
|
if (!Number.isFinite(n))
|
|
90
93
|
fail(`${label} must be a number, got "${value}"`);
|
|
94
|
+
// A budget or a percentage below its floor is not a stricter gate, it is a
|
|
95
|
+
// nonsensical one: `--max-tokens -5` turned "over budget" into always-true
|
|
96
|
+
// with a negative ceiling in the message. Reject it at the boundary.
|
|
97
|
+
if (bound && (bound.exclusive ? n <= bound.min : n < bound.min)) {
|
|
98
|
+
fail(`${label} must be ${bound.exclusive ? "greater than" : "at least"} ${bound.min}, got "${value}"`);
|
|
99
|
+
}
|
|
91
100
|
return n;
|
|
92
101
|
}
|
|
93
102
|
function anthropicKeyFor(opts) {
|
|
@@ -98,6 +107,13 @@ function anthropicKeyFor(opts) {
|
|
|
98
107
|
fail("--anthropic needs ANTHROPIC_API_KEY in the environment or a .env file");
|
|
99
108
|
return { apiKey, model: typeof opts.anthropic === "string" ? opts.anthropic : undefined };
|
|
100
109
|
}
|
|
110
|
+
// `--no-timestamp` is commander's negated form, so `opts.timestamp` is true
|
|
111
|
+
// unless the flag is passed. Kept in one helper because three commands take it
|
|
112
|
+
// and the polarity is easy to invert by accident.
|
|
113
|
+
function envelopeOpts(opts) {
|
|
114
|
+
return { timestamp: opts.timestamp !== false };
|
|
115
|
+
}
|
|
116
|
+
const NO_TIMESTAMP_HELP = "omit generatedAt from JSON output (for committed or diffed artifacts)";
|
|
101
117
|
program
|
|
102
118
|
.command("weigh")
|
|
103
119
|
.description("measure the context cost of MCP tool definitions or Agent Skills")
|
|
@@ -110,6 +126,7 @@ program
|
|
|
110
126
|
.option("--timeout <seconds>", "connect timeout in seconds", "45")
|
|
111
127
|
.option("--json", "print JSON")
|
|
112
128
|
.option("--md", "print Markdown")
|
|
129
|
+
.option("--no-timestamp", NO_TIMESTAMP_HELP)
|
|
113
130
|
.option("--out <file>", "write the JSON result to a file (usable as a --diff baseline)")
|
|
114
131
|
.option("--diff <baseline>", "compare against a baseline JSON from --out")
|
|
115
132
|
.option("--max-tokens <n>", "exit 1 if the primary total exceeds n tokens")
|
|
@@ -122,8 +139,8 @@ program
|
|
|
122
139
|
colorSetup(opts);
|
|
123
140
|
windowSetup(opts);
|
|
124
141
|
const timeoutMs = seconds(opts.timeout) * 1000;
|
|
125
|
-
const maxTokens = parseNumberOpt(opts.maxTokens, "--max-tokens");
|
|
126
|
-
const allowIncreasePct = parseNumberOpt(opts.allowIncrease, "--allow-increase");
|
|
142
|
+
const maxTokens = parseNumberOpt(opts.maxTokens, "--max-tokens", { min: 0, exclusive: true });
|
|
143
|
+
const allowIncreasePct = parseNumberOpt(opts.allowIncrease, "--allow-increase", { min: 0 });
|
|
127
144
|
const anthropic = anthropicKeyFor(opts);
|
|
128
145
|
const targets = [];
|
|
129
146
|
if (opts.client) {
|
|
@@ -206,14 +223,18 @@ program
|
|
|
206
223
|
const single = targets.length === 1 && results.length === 1 ? results[0] : undefined;
|
|
207
224
|
const budgetTotal = results.reduce((s, r) => s + (r.kind === "mcp" ? r.totals.claudeStyle : r.totals.metadata + r.totals.body), 0);
|
|
208
225
|
if (opts.out && single) {
|
|
209
|
-
|
|
226
|
+
// Never stamped. A baseline exists to be compared against, so a field
|
|
227
|
+
// that differs on every write is the one field a comparison must ignore,
|
|
228
|
+
// and a committed baseline that diffs on nothing but the clock trains
|
|
229
|
+
// reviewers to skim exactly the file they should read.
|
|
230
|
+
fs.writeFileSync(opts.out, toJsonEnvelope("weigh", single, { timestamp: false }));
|
|
210
231
|
console.error(pc.dim(`baseline written: ${opts.out}`));
|
|
211
232
|
}
|
|
212
233
|
else if (opts.out) {
|
|
213
234
|
console.error(pc.yellow("--out skipped: it writes a single baseline, but multiple targets were requested"));
|
|
214
235
|
}
|
|
215
236
|
if (opts.json) {
|
|
216
|
-
console.log(toJsonEnvelope("weigh", single ?? results));
|
|
237
|
+
console.log(toJsonEnvelope("weigh", single ?? results, envelopeOpts(opts)));
|
|
217
238
|
}
|
|
218
239
|
else if (opts.md) {
|
|
219
240
|
console.log(results.map(renderWeighMarkdown).join("\n\n---\n\n"));
|
|
@@ -239,8 +260,14 @@ program
|
|
|
239
260
|
fail("--diff works on a single MCP server result");
|
|
240
261
|
const baselineRaw = JSON.parse(fs.readFileSync(opts.diff, "utf8"));
|
|
241
262
|
const baseline = ("data" in baselineRaw && baselineRaw.data ? baselineRaw.data : baselineRaw);
|
|
242
|
-
|
|
243
|
-
|
|
263
|
+
// A skill weigh baseline also carries a `totals` object, so a bare
|
|
264
|
+
// truthiness check passed it through and diffServerWeigh then died on the
|
|
265
|
+
// missing tool array with a raw TypeError. A server baseline is the one
|
|
266
|
+
// whose totals hold the three serializations; require the Claude-style
|
|
267
|
+
// total specifically so a skill baseline is rejected with a clear message.
|
|
268
|
+
if (!baseline?.totals || typeof baseline.totals.claudeStyle !== "number") {
|
|
269
|
+
fail(`"${opts.diff}" is not an MCP server weigh baseline; write one with: efaimo weigh <server> --out ${opts.diff}`);
|
|
270
|
+
}
|
|
244
271
|
const d = diffServerWeigh(baseline, single);
|
|
245
272
|
console.log("");
|
|
246
273
|
console.log(opts.md ? renderDiffMarkdown(d) : renderDiffPretty(d, { maxTokens, allowIncreasePct }));
|
|
@@ -274,6 +301,7 @@ program
|
|
|
274
301
|
.option("--timeout <seconds>", "connect timeout in seconds", "45")
|
|
275
302
|
.option("--json", "print JSON")
|
|
276
303
|
.option("--md", "print Markdown")
|
|
304
|
+
.option("--no-timestamp", NO_TIMESTAMP_HELP)
|
|
277
305
|
.option("--badge [file]", "write a grade badge SVG + shields endpoint JSON")
|
|
278
306
|
.option("--anthropic [model]", "use exact Claude token counts where relevant")
|
|
279
307
|
.option("--window <tokens>", "context window the share is reported against", String(DEFAULT_CONTEXT_WINDOW))
|
|
@@ -322,7 +350,7 @@ program
|
|
|
322
350
|
}
|
|
323
351
|
if (skillSet) {
|
|
324
352
|
if (opts.json)
|
|
325
|
-
console.log(toJsonEnvelope("check", skillSet));
|
|
353
|
+
console.log(toJsonEnvelope("check", skillSet, envelopeOpts(opts)));
|
|
326
354
|
else if (opts.md)
|
|
327
355
|
console.log(renderSkillSetMarkdown(skillSet));
|
|
328
356
|
else
|
|
@@ -338,7 +366,7 @@ program
|
|
|
338
366
|
if (!report)
|
|
339
367
|
return;
|
|
340
368
|
if (opts.json)
|
|
341
|
-
console.log(toJsonEnvelope("check", report));
|
|
369
|
+
console.log(toJsonEnvelope("check", report, envelopeOpts(opts)));
|
|
342
370
|
else if (opts.md)
|
|
343
371
|
console.log(renderCheckMarkdown(report));
|
|
344
372
|
else
|
|
@@ -393,41 +421,165 @@ program
|
|
|
393
421
|
process.exitCode = 1;
|
|
394
422
|
}
|
|
395
423
|
});
|
|
424
|
+
program
|
|
425
|
+
.command("find")
|
|
426
|
+
.description("would a search surface these tools? (findability under deferred tool loading)")
|
|
427
|
+
.argument("[target]", "stdio command | http(s) URL")
|
|
428
|
+
.option("--stdio", "treat target as a stdio command string")
|
|
429
|
+
.option("--header <header>", 'HTTP header "Key: Value" (repeatable)', collectPairs(":"))
|
|
430
|
+
.option("--env <pair>", "KEY=VALUE for stdio servers (repeatable)", collectPairs("="))
|
|
431
|
+
.option("--timeout <seconds>", "connect timeout in seconds", "45")
|
|
432
|
+
.option("--top <n>", `result window to simulate (Anthropic's documented default is ${DEFAULT_TOP_K}; the probe is vacuous when tools <= top, so gate on --min-distinct rather than on this)`, String(DEFAULT_TOP_K))
|
|
433
|
+
.option("--min-distinct <pct>", "exit 1 if fewer than pct percent of tools own a word no other tool has")
|
|
434
|
+
.option("--json", "print JSON")
|
|
435
|
+
.option("--md", "print Markdown")
|
|
436
|
+
.option("--no-timestamp", NO_TIMESTAMP_HELP)
|
|
437
|
+
.option("--no-color", "disable colors")
|
|
438
|
+
.addHelpText("after", "\nA tool marked defer_loading is kept out of the context window until a search finds it, so a tool\n" +
|
|
439
|
+
"nothing surfaces costs nothing and does nothing. Two numbers come back. `distinct` is a property of\n" +
|
|
440
|
+
"the catalog: a tool that owns no word the others lack cannot be matched by any query that does not\n" +
|
|
441
|
+
"also match a competitor. `probe` is a simulated BM25 search, offline and deterministic, and it\n" +
|
|
442
|
+
"saturates on well-formed catalogs, so it is a floor test rather than a ranking.\n" +
|
|
443
|
+
"No API key, no network beyond connecting to the server. docs/METHODOLOGY.md has the method.")
|
|
444
|
+
.action(async (targetArg, opts) => {
|
|
445
|
+
colorSetup(opts);
|
|
446
|
+
if (!targetArg)
|
|
447
|
+
fail("nothing to search: pass an MCP server (stdio command or URL)");
|
|
448
|
+
const timeoutMs = seconds(opts.timeout) * 1000;
|
|
449
|
+
const topK = parseNumberOpt(opts.top, "--top", { min: 1 }) ?? DEFAULT_TOP_K;
|
|
450
|
+
// A window is a count of results, so a fraction of one is not a stricter
|
|
451
|
+
// setting, it is a nonsensical one: `--top 2.5` printed "result window 2.5"
|
|
452
|
+
// while `rank <= 2.5` behaved as 2, so the number in the report and the
|
|
453
|
+
// number in the comparison disagreed.
|
|
454
|
+
if (!Number.isInteger(topK))
|
|
455
|
+
fail(`--top must be a whole number of results, got "${opts.top}"`);
|
|
456
|
+
const minDistinct = parseNumberOpt(opts.minDistinct, "--min-distinct", { min: 0 });
|
|
457
|
+
if (minDistinct !== undefined && minDistinct > 100)
|
|
458
|
+
fail(`--min-distinct is a percentage, got "${opts.minDistinct}"`);
|
|
459
|
+
const target = resolveTarget(targetArg, { forceStdio: opts.stdio, env: opts.env, headers: opts.header });
|
|
460
|
+
if (target.kind === "skillset" || target.kind === "repo") {
|
|
461
|
+
fail(`"${target.label}" is a ${target.kind === "skillset" ? "skill path" : "source directory"}; find needs a live MCP server (stdio command or URL). ` +
|
|
462
|
+
`Skills have no searchable tool catalog; their trigger-overlap check is S103 in \`efaimo check --skill\`.`);
|
|
463
|
+
}
|
|
464
|
+
console.error(pc.dim(`connecting to ${target.label} ...`));
|
|
465
|
+
const intro = await introspectServer(target, { timeoutMs });
|
|
466
|
+
// Weighed only to answer one question: is this catalog big enough that a
|
|
467
|
+
// host would defer it. No API key is involved; the count is the local
|
|
468
|
+
// o200k estimate the rest of the tool uses.
|
|
469
|
+
const weigh = await weighServer(intro);
|
|
470
|
+
const result = analyzeFind(target.label, intro.tools, {
|
|
471
|
+
topK,
|
|
472
|
+
definitionTokens: weigh.totals.claudeStyle,
|
|
473
|
+
});
|
|
474
|
+
const findings = runFindRules({ find: result });
|
|
475
|
+
if (opts.json) {
|
|
476
|
+
console.log(toJsonEnvelope("find", { ...result, findings }, envelopeOpts(opts)));
|
|
477
|
+
}
|
|
478
|
+
else if (opts.md) {
|
|
479
|
+
console.log(renderFindMarkdown(result, findings));
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
console.log(renderFindPretty(result));
|
|
483
|
+
console.log(renderFindFindingsPretty(result, findings));
|
|
484
|
+
}
|
|
485
|
+
// Gated on `distinct`, not on the probe. The probe is 100% by construction
|
|
486
|
+
// whenever the catalog fits inside the result window, so a gate on it
|
|
487
|
+
// would pass without examining anything for every small server. Exclusive
|
|
488
|
+
// vocabulary can fail at two tools and up.
|
|
489
|
+
//
|
|
490
|
+
// At ONE tool it cannot: every term is trivially exclusive and the figure
|
|
491
|
+
// is 100% for any tool whatsoever. Exit 2 rather than 0, because "this
|
|
492
|
+
// gate cannot be evaluated here" is a different fact from "this gate
|
|
493
|
+
// passed", and a CI run that silently passes on a catalog nothing was
|
|
494
|
+
// measured against is the failure this whole command was built around.
|
|
495
|
+
if (minDistinct !== undefined && result.distinctVacuous) {
|
|
496
|
+
fail(`--min-distinct cannot be evaluated on a ${result.toolCount}-tool catalog: with nothing to be distinct from, ` +
|
|
497
|
+
`every term is trivially exclusive and the figure is 100% for any tool. Drop the gate for this server.`);
|
|
498
|
+
}
|
|
499
|
+
if (minDistinct !== undefined && result.distinct.pct < minDistinct) {
|
|
500
|
+
console.error(pc.red(`distinct ${result.distinct.pct}% is below --min-distinct ${minDistinct}% ` +
|
|
501
|
+
`(${result.distinct.total - result.distinct.count} of ${result.distinct.total} tools own no word the others lack)`));
|
|
502
|
+
process.exitCode = 1;
|
|
503
|
+
}
|
|
504
|
+
});
|
|
396
505
|
program
|
|
397
506
|
.command("test")
|
|
398
507
|
.description("does a skill actually improve task completion? (experimental A/B outcome harness)")
|
|
399
508
|
.argument("<scenario>", "a scenario YAML file (see examples/scenario.example.yaml)")
|
|
400
509
|
.option("--live", "run for real (spends tokens; Claude models need ANTHROPIC_API_KEY, GPT models need OPENAI_API_KEY)")
|
|
401
510
|
.option("--model <model>", "override the scenario's model (e.g. gpt-4o-mini, claude-sonnet-5)")
|
|
511
|
+
.option("--judge-model <model>", "grade with a different model than the one under test (removes self-preference)")
|
|
402
512
|
.option("--json", "print JSON")
|
|
403
513
|
.option("--no-color", "disable colors")
|
|
404
|
-
.addHelpText("after", "\nWithout --live this validates the scenario and prints the plan, making no API calls
|
|
514
|
+
.addHelpText("after", "\nWithout --live this validates the scenario and prints the plan, making no API calls.\n" +
|
|
515
|
+
"The judge defaults to the model under test, which means a model grades its own answers; set\n" +
|
|
516
|
+
"judge_model in the scenario or --judge-model here to separate them. Subject and judge may be\n" +
|
|
517
|
+
"different providers, in which case both keys are required.")
|
|
405
518
|
.action(async (file, opts) => {
|
|
406
519
|
colorSetup(opts);
|
|
407
520
|
const parsed = parseScenario(file);
|
|
408
|
-
|
|
521
|
+
// --model moves the judge too WHEN the judge was never pinned separately.
|
|
522
|
+
// Without this, overriding the subject on a scenario that has no
|
|
523
|
+
// `judge_model` would silently leave the judge on the scenario's original
|
|
524
|
+
// model: a cross-model judge nobody asked for, in a run whose whole point
|
|
525
|
+
// is that the comparison is controlled.
|
|
526
|
+
const judgeFollowsSubject = !parsed.judgeModelExplicit;
|
|
527
|
+
const model = opts.model ?? parsed.model;
|
|
528
|
+
const judgeModel = opts.judgeModel ?? (judgeFollowsSubject ? model : parsed.judgeModel);
|
|
529
|
+
const scenario = { ...parsed, model, judgeModel };
|
|
409
530
|
if (!opts.live) {
|
|
410
531
|
console.log(renderScenarioPlan(scenario));
|
|
411
532
|
return;
|
|
412
533
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
534
|
+
// Subject and judge are routed independently, so a Claude subject can be
|
|
535
|
+
// graded by a GPT judge (or the reverse). Every distinct provider in play
|
|
536
|
+
// needs its own key, and the error says which model asked for it.
|
|
537
|
+
const runners = new Map();
|
|
538
|
+
for (const [role, m] of [["model", scenario.model], ["judge model", scenario.judgeModel]]) {
|
|
539
|
+
const provider = providerForModel(m);
|
|
540
|
+
if (provider === "unknown") {
|
|
541
|
+
fail(`${role} "${m}" is not supported: efaimo test runs Claude (claude-*) and OpenAI (gpt-*, o*) models`);
|
|
542
|
+
}
|
|
543
|
+
if (runners.has(provider))
|
|
544
|
+
continue;
|
|
545
|
+
const envVar = provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
|
|
546
|
+
const apiKey = process.env[envVar];
|
|
547
|
+
if (!apiKey)
|
|
548
|
+
fail(`${role} "${m}" needs ${envVar} in the environment or a .env file`);
|
|
549
|
+
if (dotEnvKeys.has(envVar))
|
|
550
|
+
console.error(pc.dim(`using ${envVar} from .env`));
|
|
551
|
+
runners.set(provider, provider === "openai" ? openaiRunner(apiKey) : anthropicRunner(apiKey));
|
|
552
|
+
}
|
|
553
|
+
const runner = (req) => {
|
|
554
|
+
const r = runners.get(providerForModel(req.model));
|
|
555
|
+
// Only the two validated models can reach here today. A future arm that
|
|
556
|
+
// introduces a third would otherwise die as "undefined is not a
|
|
557
|
+
// function" with no idea which model it was.
|
|
558
|
+
if (!r)
|
|
559
|
+
throw new Error(`no runner for model "${req.model}"; efaimo test runs Claude and OpenAI models`);
|
|
560
|
+
return r(req);
|
|
561
|
+
};
|
|
562
|
+
// Say what the measurement cannot pin down, in the report, every time.
|
|
563
|
+
const extraNotes = [];
|
|
564
|
+
if (scenario.judgeModel === scenario.model) {
|
|
565
|
+
extraNotes.push(`the judge is the same model as the subject, so part of any measured effect is a model preferring its own output. Set judge_model (or --judge-model) to a different model to remove that.`);
|
|
566
|
+
}
|
|
567
|
+
if (providerForModel(scenario.judgeModel) === "anthropic" && !acceptsSamplingParams(scenario.judgeModel)) {
|
|
568
|
+
extraNotes.push(`judge sampling is not pinned: ${scenario.judgeModel} does not accept temperature, so the judge is sampled at its default and its own variance is part of this measurement.`);
|
|
416
569
|
}
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
fail(`model "${scenario.model}" needs ${envVar} in the environment or a .env file`);
|
|
421
|
-
if (dotEnvKeys.has(envVar))
|
|
422
|
-
console.error(pc.dim(`using ${envVar} from .env`));
|
|
423
|
-
const runner = provider === "openai" ? openaiRunner(apiKey) : anthropicRunner(apiKey);
|
|
424
|
-
console.error(pc.dim(`running ~${scenario.trials * 4} ${provider} API calls against ${scenario.model} ...`));
|
|
425
|
-
const report = await runScenario(scenario, runner);
|
|
570
|
+
const models = scenario.judgeModel === scenario.model ? scenario.model : `${scenario.model} judged by ${scenario.judgeModel}`;
|
|
571
|
+
console.error(pc.dim(`running ~${scenario.trials * 4} API calls against ${models} ...`));
|
|
572
|
+
const report = await runScenario(scenario, runner, { extraNotes });
|
|
426
573
|
if (opts.json)
|
|
427
574
|
console.log(toJsonEnvelope("test", report));
|
|
428
575
|
else
|
|
429
576
|
console.log(renderTestReportPretty(report));
|
|
430
|
-
|
|
577
|
+
// `inconclusive` fails too. It means an arm produced no scoreable trial, or
|
|
578
|
+
// the judge refused on one arm far more than the other, and in both cases
|
|
579
|
+
// the measurement did not happen. Exiting 0 there would report a green for
|
|
580
|
+
// precisely the runs where the instrument broke down, which is this
|
|
581
|
+
// project's documented dominant failure mode wearing a p-value.
|
|
582
|
+
if (report.verdict === "hurts" || report.verdict === "inconclusive")
|
|
431
583
|
process.exitCode = 1;
|
|
432
584
|
});
|
|
433
585
|
program
|
|
@@ -23,14 +23,22 @@ export async function introspectServer(target, opts = {}) {
|
|
|
23
23
|
stderr: "pipe",
|
|
24
24
|
});
|
|
25
25
|
transport = stdio;
|
|
26
|
+
// Split the budget across the two attempts so a dead server fails in about
|
|
27
|
+
// one timeout, not two: the legacy handshake got the whole timeout AND the
|
|
28
|
+
// stateless fallback then re-waited the whole timeout again, so `--timeout 8`
|
|
29
|
+
// took ~16s wall. The handshake takes the larger share; the fallback gets
|
|
30
|
+
// what is left, with a floor so a genuinely stateless server still has time
|
|
31
|
+
// to answer a bare request.
|
|
32
|
+
const startedAt = Date.now();
|
|
26
33
|
try {
|
|
27
|
-
await withTimeout(client.connect(stdio), timeoutMs, "connect (stdio)");
|
|
34
|
+
await withTimeout(client.connect(stdio), Math.max(1, Math.round(timeoutMs * 0.6)), "connect (stdio)");
|
|
28
35
|
}
|
|
29
36
|
catch (e) {
|
|
30
37
|
await safeClose(client, stdio);
|
|
31
38
|
// A 2026-07-28 stateless server has no initialize, so the SDK handshake
|
|
32
39
|
// fails by design; try bare stateless requests before giving up.
|
|
33
|
-
const
|
|
40
|
+
const remaining = Math.max(timeoutMs - (Date.now() - startedAt), Math.round(timeoutMs * 0.25));
|
|
41
|
+
const rc = await introspectStateless(target, { timeoutMs: remaining });
|
|
34
42
|
if (rc)
|
|
35
43
|
return rc;
|
|
36
44
|
throw connectError(e, target.label, stderrTail);
|
package/dist/core/engine.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { Finding, McpRuleContext } from "./types.js";
|
|
1
|
+
import type { Finding, FindRuleContext, McpRuleContext } from "./types.js";
|
|
2
2
|
/** Run a list of rules, isolating each: a broken rule must never break the audit. */
|
|
3
3
|
export declare function runRules<Ctx>(rules: readonly {
|
|
4
4
|
check(ctx: Ctx): Finding[];
|
|
5
5
|
}[], ctx: Ctx): Finding[];
|
|
6
6
|
export declare function runMcpRules(ctx: McpRuleContext): Finding[];
|
|
7
|
+
export declare function runFindRules(ctx: FindRuleContext): Finding[];
|
package/dist/core/engine.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MCP_RULES } from "../rules/mcp/index.js";
|
|
2
|
+
import { FIND_RULES } from "../rules/find/index.js";
|
|
2
3
|
/** Run a list of rules, isolating each: a broken rule must never break the audit. */
|
|
3
4
|
export function runRules(rules, ctx) {
|
|
4
5
|
const findings = [];
|
|
@@ -33,3 +34,6 @@ export function runRules(rules, ctx) {
|
|
|
33
34
|
export function runMcpRules(ctx) {
|
|
34
35
|
return runRules(MCP_RULES, ctx);
|
|
35
36
|
}
|
|
37
|
+
export function runFindRules(ctx) {
|
|
38
|
+
return runRules(FIND_RULES, ctx);
|
|
39
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -155,6 +155,13 @@ export interface ServerWeighResult {
|
|
|
155
155
|
instructionsTokens: number;
|
|
156
156
|
/** Exact count via Anthropic count_tokens API, when a key was provided. */
|
|
157
157
|
anthropicExactTotal?: number;
|
|
158
|
+
/**
|
|
159
|
+
* The model `anthropicExactTotal` was measured against. Always set when the
|
|
160
|
+
* total is: Claude model lines do not share a tokenizer, so the figure is
|
|
161
|
+
* exact for this model and approximate for any other, and a count printed
|
|
162
|
+
* without its model cannot be reproduced.
|
|
163
|
+
*/
|
|
164
|
+
anthropicExactModel?: string;
|
|
158
165
|
notes: string[];
|
|
159
166
|
}
|
|
160
167
|
export interface SkillWeighEntry {
|
|
@@ -178,6 +185,111 @@ export interface SkillSetWeighResult {
|
|
|
178
185
|
notes: string[];
|
|
179
186
|
}
|
|
180
187
|
export type WeighResult = ServerWeighResult | SkillSetWeighResult;
|
|
188
|
+
/** One tool's result in the findability report (`efaimo find`). */
|
|
189
|
+
export interface ToolFindEntry {
|
|
190
|
+
name: string;
|
|
191
|
+
/**
|
|
192
|
+
* Terms this tool has that no other tool in the catalog has.
|
|
193
|
+
*
|
|
194
|
+
* The primary signal, and the only one here that is not a model of anything:
|
|
195
|
+
* if this is empty, every word the tool contains also appears on some other
|
|
196
|
+
* tool, so no query can match it without also matching a competitor. That is
|
|
197
|
+
* a property of the catalog, provable by inspection, with no assumption
|
|
198
|
+
* about how anyone searches. Capped for display.
|
|
199
|
+
*/
|
|
200
|
+
ownTerms: string[];
|
|
201
|
+
/** How many exclusive terms there are in total, before the display cap. */
|
|
202
|
+
ownTermCount: number;
|
|
203
|
+
/**
|
|
204
|
+
* How many of them survive if the searcher does not know the tool's name.
|
|
205
|
+
*
|
|
206
|
+
* Names are searchable, so a tool whose only exclusive word is its own name
|
|
207
|
+
* is findable in principle. It is not findable by anyone describing a task,
|
|
208
|
+
* which is the situation deferred loading creates. E146 is this number
|
|
209
|
+
* reaching zero while `ownTermCount` does not.
|
|
210
|
+
*/
|
|
211
|
+
ownOutsideNameCount: number;
|
|
212
|
+
/** When ownTermCount is 0: tools whose vocabulary covers this one's (capped). */
|
|
213
|
+
sharedWith: string[];
|
|
214
|
+
/**
|
|
215
|
+
* The terms this tool's own description offers a searcher, most distinctive
|
|
216
|
+
* first. Never includes the tool's name: the point of the probe is that the
|
|
217
|
+
* person searching does not know it.
|
|
218
|
+
*/
|
|
219
|
+
query: string[];
|
|
220
|
+
/** 1-based rank for that query. Undefined when the description offers no searchable word. */
|
|
221
|
+
rank?: number;
|
|
222
|
+
/** BM25 score for its own query, kept so a reader can check the arithmetic. */
|
|
223
|
+
score: number;
|
|
224
|
+
/** Tools that scored higher for this tool's own query (capped at 3). */
|
|
225
|
+
outrankedBy: string[];
|
|
226
|
+
/** Tools whose score is indistinguishable from this one's, so only the tie-break separates them. Capped for display. */
|
|
227
|
+
tiedWith: string[];
|
|
228
|
+
/** How many there are in total, before the display cap. */
|
|
229
|
+
tiedWithCount: number;
|
|
230
|
+
/** Rank falls inside the simulated result window. The per-tool form of `probe`. */
|
|
231
|
+
reachable: boolean;
|
|
232
|
+
/** Every token of the name is a generic word (E144). */
|
|
233
|
+
genericName: boolean;
|
|
234
|
+
}
|
|
235
|
+
export interface FindResult {
|
|
236
|
+
kind: "find";
|
|
237
|
+
label: string;
|
|
238
|
+
toolCount: number;
|
|
239
|
+
/** The whole method, carried with the numbers so a report is self-describing. */
|
|
240
|
+
method: {
|
|
241
|
+
tokenizer: string;
|
|
242
|
+
bm25: {
|
|
243
|
+
k1: number;
|
|
244
|
+
b: number;
|
|
245
|
+
};
|
|
246
|
+
queryTerms: number;
|
|
247
|
+
topK: number;
|
|
248
|
+
};
|
|
249
|
+
perTool: ToolFindEntry[];
|
|
250
|
+
/**
|
|
251
|
+
* The headline: how many tools own at least one word no other tool has.
|
|
252
|
+
* A measured proportion, deliberately not a letter grade (ADR-030).
|
|
253
|
+
*/
|
|
254
|
+
distinct: {
|
|
255
|
+
count: number;
|
|
256
|
+
total: number;
|
|
257
|
+
pct: number;
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Secondary: how many tools a simulated BM25 search for their own
|
|
261
|
+
* description returns inside the result window.
|
|
262
|
+
*
|
|
263
|
+
* Named `probe` in the JSON, in the output and in the docs, all three. It
|
|
264
|
+
* was `reach` in one of them and `probe` in the others for a day, which is
|
|
265
|
+
* how someone scripting a gate on the number they saw printed ends up
|
|
266
|
+
* looking for a field that does not exist.
|
|
267
|
+
*
|
|
268
|
+
* Kept, and kept second, because it is the only number here that models the
|
|
269
|
+
* actual mechanism, and because it saturates: 100% on every real server
|
|
270
|
+
* measured. Treat it as a floor test that catches catastrophe, not as a
|
|
271
|
+
* ranking. See docs/METHODOLOGY.md.
|
|
272
|
+
*/
|
|
273
|
+
probe: {
|
|
274
|
+
returned: number;
|
|
275
|
+
total: number;
|
|
276
|
+
pct: number;
|
|
277
|
+
};
|
|
278
|
+
/** toolCount <= topK, which makes the probe figure 100% by construction. */
|
|
279
|
+
windowCoversCatalog: boolean;
|
|
280
|
+
/**
|
|
281
|
+
* toolCount < 2, which makes the DISTINCT figure 100% by construction: with
|
|
282
|
+
* one tool every term it has is trivially exclusive. `--min-distinct` cannot
|
|
283
|
+
* be evaluated in that state and refuses rather than passing.
|
|
284
|
+
*/
|
|
285
|
+
distinctVacuous: boolean;
|
|
286
|
+
definitionTokens?: number;
|
|
287
|
+
/** Anthropic's guidance recommends tool search for a catalog this shape. */
|
|
288
|
+
deferRecommended: boolean;
|
|
289
|
+
/** Which of the checkable conditions fired, in words. Empty when none did. */
|
|
290
|
+
deferBecause: string[];
|
|
291
|
+
notes: string[];
|
|
292
|
+
}
|
|
181
293
|
export interface SkillInfo {
|
|
182
294
|
dir: string;
|
|
183
295
|
file: string;
|
|
@@ -248,6 +360,9 @@ export interface SkillRuleContext {
|
|
|
248
360
|
set: SkillSet;
|
|
249
361
|
weigh?: SkillSetWeighResult;
|
|
250
362
|
}
|
|
363
|
+
export interface FindRuleContext {
|
|
364
|
+
find: FindResult;
|
|
365
|
+
}
|
|
251
366
|
export interface McpRule {
|
|
252
367
|
id: string;
|
|
253
368
|
title: string;
|
|
@@ -260,3 +375,9 @@ export interface SkillRule {
|
|
|
260
375
|
surface: "skill";
|
|
261
376
|
check(ctx: SkillRuleContext): Finding[];
|
|
262
377
|
}
|
|
378
|
+
export interface FindRule {
|
|
379
|
+
id: string;
|
|
380
|
+
title: string;
|
|
381
|
+
surface: "find";
|
|
382
|
+
check(ctx: FindRuleContext): Finding[];
|
|
383
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, exact BM25 index.
|
|
3
|
+
*
|
|
4
|
+
* `efaimo find` needs to answer "would a search over this catalog surface this
|
|
5
|
+
* tool", and the built-in tool search is server-side: its parameters are not
|
|
6
|
+
* published, so nothing here can be a reproduction. It is the textbook
|
|
7
|
+
* Robertson/Sparck-Jones BM25 with its conventional parameters, run offline on
|
|
8
|
+
* the four fields Anthropic documents as searchable, and both the formula and
|
|
9
|
+
* the parameters are printed in the output. A reader can disagree with the
|
|
10
|
+
* model and still check the arithmetic.
|
|
11
|
+
*
|
|
12
|
+
* Two properties this file exists to guarantee:
|
|
13
|
+
*
|
|
14
|
+
* - **Determinism.** Ties break on the document id by code point, never by
|
|
15
|
+
* `localeCompare`, whose collation depends on the host's ICU data. A tool
|
|
16
|
+
* that reports other people's nondeterminism (E112) must not have any of its
|
|
17
|
+
* own, and "it ranked differently on CI" is exactly the bug that would be
|
|
18
|
+
* impossible to reproduce.
|
|
19
|
+
* - **No silent empties.** `score()` on an empty query returns an empty array
|
|
20
|
+
* rather than the whole catalog at score 0. Callers treat that as a finding,
|
|
21
|
+
* not as a pass.
|
|
22
|
+
*/
|
|
23
|
+
export interface Bm25Doc {
|
|
24
|
+
id: string;
|
|
25
|
+
terms: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface Bm25Hit {
|
|
28
|
+
id: string;
|
|
29
|
+
score: number;
|
|
30
|
+
/**
|
|
31
|
+
* The document's position in the corpus.
|
|
32
|
+
*
|
|
33
|
+
* Present because `id` is not a key. MCP does not forbid two tools sharing a
|
|
34
|
+
* name, and a caller that located its own row with `findIndex(h => h.id ===
|
|
35
|
+
* name)` got the better-scoring twin's rank, score and tie set: a tool that
|
|
36
|
+
* really sat eighth reported rank 1, and the headline moved in the flattering
|
|
37
|
+
* direction. Match on this instead.
|
|
38
|
+
*/
|
|
39
|
+
index: number;
|
|
40
|
+
}
|
|
41
|
+
export interface Bm25Params {
|
|
42
|
+
k1: number;
|
|
43
|
+
b: number;
|
|
44
|
+
}
|
|
45
|
+
/** The conventional defaults. Printed in every report so the method travels with the number. */
|
|
46
|
+
export declare const DEFAULT_BM25: Bm25Params;
|
|
47
|
+
export declare class Bm25Index {
|
|
48
|
+
readonly params: Bm25Params;
|
|
49
|
+
readonly size: number;
|
|
50
|
+
private readonly ids;
|
|
51
|
+
private readonly tf;
|
|
52
|
+
private readonly len;
|
|
53
|
+
private readonly df;
|
|
54
|
+
private readonly avgdl;
|
|
55
|
+
constructor(docs: readonly Bm25Doc[], params?: Bm25Params);
|
|
56
|
+
/** BM25 IDF. Always positive, so a term present in every document still contributes. */
|
|
57
|
+
idf(term: string): number;
|
|
58
|
+
/** Document frequency: how many tools contain this term at all. */
|
|
59
|
+
documentFrequency(term: string): number;
|
|
60
|
+
termFrequency(index: number, term: string): number;
|
|
61
|
+
/**
|
|
62
|
+
* Score every document against a query, highest first.
|
|
63
|
+
*
|
|
64
|
+
* Duplicate query terms are collapsed: a query is a set of keywords here,
|
|
65
|
+
* not a passage, and counting a repeated term twice would silently weight it.
|
|
66
|
+
*/
|
|
67
|
+
score(query: readonly string[]): Bm25Hit[];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Are two scores the same as far as a ranker is concerned?
|
|
71
|
+
*
|
|
72
|
+
* Relative, because BM25 scores are unbounded: an absolute epsilon that is
|
|
73
|
+
* right at score 2 is meaningless at score 40. Two tools this close are
|
|
74
|
+
* separated only by the tie-break, which means the search picks between them
|
|
75
|
+
* on the alphabet rather than on relevance.
|
|
76
|
+
*/
|
|
77
|
+
export declare function scoresTie(a: number, bScore: number, rel?: number): boolean;
|