efaimo 0.1.2 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +68 -6
  3. package/SKILL.md +14 -7
  4. package/dist/cli.js +162 -22
  5. package/dist/core/engine.d.ts +2 -1
  6. package/dist/core/engine.js +4 -0
  7. package/dist/core/types.d.ts +121 -0
  8. package/dist/find/bm25.d.ts +77 -0
  9. package/dist/find/bm25.js +107 -0
  10. package/dist/find/find.d.ts +120 -0
  11. package/dist/find/find.js +403 -0
  12. package/dist/find/tokenize.d.ts +64 -0
  13. package/dist/find/tokenize.js +116 -0
  14. package/dist/index.d.ts +6 -3
  15. package/dist/index.js +13 -3
  16. package/dist/reporters/json.d.ts +19 -1
  17. package/dist/reporters/json.js +13 -2
  18. package/dist/reporters/markdown.d.ts +2 -1
  19. package/dist/reporters/markdown.js +109 -11
  20. package/dist/reporters/pretty.d.ts +12 -1
  21. package/dist/reporters/pretty.js +174 -9
  22. package/dist/rules/find/index.d.ts +4 -0
  23. package/dist/rules/find/index.js +262 -0
  24. package/dist/rules/version.d.ts +26 -0
  25. package/dist/rules/version.js +26 -0
  26. package/dist/testing/anthropicRunner.d.ts +30 -0
  27. package/dist/testing/anthropicRunner.js +43 -8
  28. package/dist/testing/harness.d.ts +32 -1
  29. package/dist/testing/harness.js +28 -3
  30. package/dist/testing/stats.d.ts +16 -0
  31. package/dist/testing/stats.js +26 -0
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/dist/weigh/anthropicExact.d.ts +12 -1
  35. package/dist/weigh/anthropicExact.js +9 -1
  36. package/dist/weigh/weigh.js +10 -3
  37. package/docs/INTEGRATIONS.md +15 -1
  38. package/docs/METHODOLOGY.md +110 -6
  39. package/docs/RULES.md +40 -3
  40. package/examples/scenario.crm.yaml +8 -0
  41. package/examples/scenario.example.yaml +8 -0
  42. package/package.json +1 -1
@@ -0,0 +1,403 @@
1
+ import { Bm25Index, DEFAULT_BM25, scoresTie } from "./bm25.js";
2
+ import { nameIsGeneric, QUERY_STOPWORDS, tokenize } from "./tokenize.js";
3
+ // QUERY_STOPWORDS is used twice here on purpose: a word nobody would type is
4
+ // neither a query term nor vocabulary that makes a tool findable.
5
+ /**
6
+ * Findability: can a search over this catalog pick this tool out at all?
7
+ *
8
+ * Why this exists. Since 2026-01 a host can mark tools `defer_loading: true`,
9
+ * which keeps them out of the system-prompt prefix entirely; the model then
10
+ * finds them by searching the catalog, and Anthropic recommends turning that
11
+ * on once tool definitions pass ~10k tokens. Under deferral a tool no search
12
+ * surfaces costs zero tokens and provides zero capability, and no existing
13
+ * check looks at that: `weigh` measures what a tool costs, `check` measures
14
+ * what a model experiences once the tool is already in front of it.
15
+ *
16
+ * Two measurements, and they are not the same kind of thing.
17
+ *
18
+ * 1. EXCLUSIVE VOCABULARY, the headline. Which terms does a tool have that no
19
+ * other tool in the catalog has? A tool with none cannot be matched by any
20
+ * query that does not also match a competitor, because every word it
21
+ * contains is somewhere else too. That is a fact about the catalog, checked
22
+ * by inspection, with no model of how anyone searches, no parameters, and
23
+ * nothing to disagree with except the tokenizer.
24
+ *
25
+ * It also discriminates, which the alternative did not. Measured on
26
+ * `@playwright/mcp` 0.0.78: 22 of 24 tools own a word, and `browser_close`
27
+ * and `browser_navigate` own none.
28
+ *
29
+ * 2. THE SEARCH PROBE, secondary. Simulated BM25 over the four fields tool
30
+ * search documents as searchable, each tool queried with the top terms of
31
+ * its own description. This is a model, and a generous one: the query comes
32
+ * from the tool's own words. It reads 100% on both the official reference
33
+ * server and playwright, so it is a floor test that catches catastrophe -
34
+ * an empty description, a literal duplicate - and not a ranking. The report
35
+ * says so on every run rather than letting a saturated number look like a
36
+ * good score.
37
+ *
38
+ * Neither is a grade. See ADR-030 for why this ships as proportions.
39
+ */
40
+ /** How many of a tool's own terms make up its self-query. */
41
+ export const DEFAULT_QUERY_TERMS = 4;
42
+ /**
43
+ * How many results a search returns. Anthropic documents a default of 5 (the
44
+ * model may raise it), so 5 is what a tool has to land inside to be seen
45
+ * without the model deciding to look harder.
46
+ */
47
+ export const DEFAULT_TOP_K = 5;
48
+ /**
49
+ * When Anthropic's own guidance says to turn tool search on.
50
+ *
51
+ * The doc lists five conditions joined by "any of the following apply", and
52
+ * two of them can be checked from a tool list: "You have 10 or more tools
53
+ * available" and "Your tool definitions consume more than 10k tokens". The
54
+ * other three are about the operator's situation, not the catalog.
55
+ *
56
+ * Both are checked. An earlier version implemented only the token clause,
57
+ * which told a reader that a 24-tool, 3.5k-token server was "probably loaded
58
+ * up front today" when Anthropic's first condition already applied to it, and
59
+ * left E145 structurally unable to fire on the most common shape there is: a
60
+ * catalog with plenty of tools and modest descriptions.
61
+ */
62
+ export const DEFER_RECOMMENDED_TOKENS = 10_000;
63
+ export const DEFER_RECOMMENDED_TOOLS = 10;
64
+ /**
65
+ * Bounded walk of a JSON Schema, collecting argument names and descriptions.
66
+ *
67
+ * Two things this got wrong and now does not.
68
+ *
69
+ * It used `out.push(...tokenize(text))`, which passes every token as a separate
70
+ * argument. One argument description long enough to produce ~125,000 tokens
71
+ * (about a megabyte, entirely within reach of an untrusted third-party server)
72
+ * blew the call-stack limit and took the whole run down with a `RangeError`.
73
+ * Tokens are appended one at a time now, and there is a token budget as well
74
+ * as a node budget: bounding node COUNT does not bound the work one node can do.
75
+ *
76
+ * And it walked only `properties` and a singular `items`, which misses the two
77
+ * shapes generated schemas actually take: pydantic hoists everything into
78
+ * `$defs` and points at it with `$ref`, and zod emits `anyOf`/`oneOf`. Against
79
+ * those, argument names and descriptions went uncollected, which makes
80
+ * `distinct` PESSIMISTIC: a tool that really does own a word gets reported as
81
+ * owning none and E141 fires on a server that is fine. `$defs` is walked
82
+ * directly rather than resolving `$ref`, because the text is what is wanted,
83
+ * not the structure, and a walk cannot loop that way.
84
+ */
85
+ function schemaTerms(schema, depth = 0, budget = { nodes: 600, tokens: 20_000 }) {
86
+ const out = [];
87
+ if (depth > 4 || budget.nodes <= 0 || budget.tokens <= 0 || !schema || typeof schema !== "object")
88
+ return out;
89
+ budget.nodes--;
90
+ const node = schema;
91
+ const take = (text) => {
92
+ if (typeof text !== "string")
93
+ return;
94
+ for (const t of tokenize(text)) {
95
+ if (budget.tokens-- <= 0)
96
+ return;
97
+ out.push(t);
98
+ }
99
+ };
100
+ const descend = (child) => {
101
+ for (const t of schemaTerms(child, depth + 1, budget)) {
102
+ if (budget.tokens-- <= 0)
103
+ return;
104
+ out.push(t);
105
+ }
106
+ };
107
+ for (const key of ["properties", "$defs", "definitions", "patternProperties"]) {
108
+ const bag = node[key];
109
+ if (!bag || typeof bag !== "object" || Array.isArray(bag))
110
+ continue;
111
+ for (const [name, value] of Object.entries(bag)) {
112
+ if (budget.nodes <= 0 || budget.tokens <= 0)
113
+ break;
114
+ // Only `properties` and `patternProperties` keys are argument names a
115
+ // caller types; a `$defs` key is a type name the model never sees.
116
+ if (key === "properties" || key === "patternProperties")
117
+ take(name);
118
+ if (value && typeof value === "object") {
119
+ // Description only. A property `title` is no more a searchable field
120
+ // than a tool `title` is, and indexing it would be the same mistake
121
+ // one level down.
122
+ take(value.description);
123
+ descend(value);
124
+ }
125
+ }
126
+ }
127
+ for (const key of ["items", "additionalProperties", "not", "contains"]) {
128
+ const child = node[key];
129
+ // A tuple schema writes `items` as an ARRAY; that shape is handled below,
130
+ // and descending into the array itself would spend a node on nothing.
131
+ if (child && typeof child === "object" && !Array.isArray(child))
132
+ descend(child);
133
+ }
134
+ for (const key of ["anyOf", "oneOf", "allOf", "prefixItems"]) {
135
+ const arr = node[key];
136
+ if (!Array.isArray(arr))
137
+ continue;
138
+ for (const child of arr) {
139
+ if (budget.nodes <= 0 || budget.tokens <= 0)
140
+ break;
141
+ descend(child);
142
+ }
143
+ }
144
+ // A tuple schema writes `items: [ {...}, {...} ]`, which the object branch
145
+ // above skips because an array is typeof "object" but has no keys of interest.
146
+ if (Array.isArray(node.items)) {
147
+ for (const child of node.items) {
148
+ if (budget.nodes <= 0 || budget.tokens <= 0)
149
+ break;
150
+ descend(child);
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+ /**
156
+ * The four fields tool search matches on: name, description, argument names,
157
+ * argument descriptions. Kept in one function so the index and the docs can
158
+ * never disagree about what is searched.
159
+ *
160
+ * `title` is deliberately NOT among them, for two reasons that point the same
161
+ * way. Anthropic's list of searchable fields is these four and does not
162
+ * include it. And MCP `title` is a human-readable restatement of the name:
163
+ * every one of the reference server's thirteen tools sets one, and every one
164
+ * is the name title-cased ("Echo Tool", "Get Annotated Message Tool"). An
165
+ * earlier version indexed and PROBED with it, which quietly put the tool's own
166
+ * name back into its own query through the back door and undid the whole
167
+ * reason the name is excluded. Two tools with identical descriptions and
168
+ * name-derived titles scored a clean rank 1 each instead of tying.
169
+ *
170
+ * Indexing a field the real search does not read would also make `distinct`
171
+ * optimistic: a tool could "own" a word that exists only in its title, where
172
+ * no query can reach it.
173
+ */
174
+ export function indexedTerms(tool) {
175
+ return [
176
+ ...tokenize(tool.name),
177
+ ...tokenize(tool.description ?? ""),
178
+ ...schemaTerms(tool.inputSchema),
179
+ ];
180
+ }
181
+ /**
182
+ * The terms a person would plausibly type to reach this tool.
183
+ *
184
+ * Drawn from the description, and deliberately NOT from the name, the title,
185
+ * or the argument names.
186
+ *
187
+ * Not the name, because the person searching does not know it. That is the
188
+ * whole situation being modelled: the tool is deferred, the user describes a
189
+ * task, and the model turns that description into a query. An earlier version
190
+ * of this drew query terms from the name too, and the result was a metric that
191
+ * could barely fail - a unique name token has maximal idf, so it entered every
192
+ * query and pulled its own tool to the top. Two tools with the same
193
+ * description and different names both scored a clean rank 1, which is exactly
194
+ * backwards: identical descriptions are the case where a searcher cannot tell
195
+ * them apart. The name stays in the INDEX, so a name whose words echo the
196
+ * description still helps the tool rank.
197
+ *
198
+ * Not the title either, and that one is the same bug wearing a different hat:
199
+ * MCP `title` is usually the name title-cased, so reading it here put the name
200
+ * back into its own query after all the trouble taken to keep it out. See
201
+ * `indexedTerms`, which drops it from the index for the same reason.
202
+ *
203
+ * Not argument names, because a user describes a task ("post a message to
204
+ * slack"), not a parameter list. They are indexed, so a search can still reach
205
+ * them; they are just never what the tool is probed with.
206
+ *
207
+ * Ranked by tf*idf inside this catalog, so the terms chosen are the ones that
208
+ * separate this tool from its siblings, with stopwords excluded because in a
209
+ * small catalog a function word repeated in one description can outrank the
210
+ * noun that identifies the tool.
211
+ */
212
+ export function taskQuery(tool, index, limit = DEFAULT_QUERY_TERMS) {
213
+ const source = tokenize(tool.description ?? "");
214
+ const tf = new Map();
215
+ for (const t of source) {
216
+ if (QUERY_STOPWORDS.has(t))
217
+ continue;
218
+ tf.set(t, (tf.get(t) ?? 0) + 1);
219
+ }
220
+ return [...tf.entries()]
221
+ .map(([term, count]) => ({ term, weight: count * index.idf(term) }))
222
+ // Code-point tie-break, not localeCompare: the query has to be the same on
223
+ // every machine or the whole report is unreproducible.
224
+ .sort((a, b) => (b.weight - a.weight) || (a.term < b.term ? -1 : a.term > b.term ? 1 : 0))
225
+ .slice(0, limit)
226
+ .map((x) => x.term);
227
+ }
228
+ export function analyzeFind(label, tools, opts = {}) {
229
+ const topK = opts.topK ?? DEFAULT_TOP_K;
230
+ const queryTerms = opts.queryTerms ?? DEFAULT_QUERY_TERMS;
231
+ // An empty catalog is a failure, never a clean report. `find` on a server
232
+ // that exposes no tools would otherwise print a perfect 0/0 = 100% reach,
233
+ // which is the project's documented dominant failure mode: a green that
234
+ // examined nothing. The caller turns this into an exit code.
235
+ if (!tools.length) {
236
+ throw new Error(`"${label}" exposes no tools, so there is nothing to search for. ` +
237
+ `An empty catalog is not a findable one; check that the server started and listed its tools.`);
238
+ }
239
+ const docs = tools.map((t) => ({ id: t.name, terms: indexedTerms(t) }));
240
+ const index = new Bm25Index(docs, DEFAULT_BM25);
241
+ // Exclusive vocabulary. Computed from the document frequencies the index
242
+ // already holds, so the two measurements cannot disagree about what a term
243
+ // is: one tokenizer, one set of documents.
244
+ const termSets = docs.map((d) => new Set(d.terms));
245
+ const nameTermSets = tools.map((t) => new Set(tokenize(t.name)));
246
+ /** How many "who shares my vocabulary" scans to run. See the comment at its use. */
247
+ const SHARED_WITH_BUDGET = 200;
248
+ let sharedComputed = 0;
249
+ const perTool = tools.map((tool, i) => {
250
+ // Exclusive terms, with function words removed.
251
+ //
252
+ // A tool can be the only one in a catalog that happens to say "the", and
253
+ // counting that as vocabulary it owns would be true and useless: nobody
254
+ // searches for it. Function words are already excluded from the queries
255
+ // this file builds, for the same reason, and letting them count here would
256
+ // have let a catalog of near-identical tools score a clean 100%.
257
+ //
258
+ // Most-used first, then alphabetical. The list is capped for display, and
259
+ // an alphabetical cap shows whichever exclusive words happen to start with
260
+ // an early letter rather than the ones the tool is actually about: on
261
+ // `get_weather` that hid `weather` behind `city, conditions, current`.
262
+ const own = [...termSets[i]]
263
+ .filter((t) => index.documentFrequency(t) === 1 && !QUERY_STOPWORDS.has(t))
264
+ .sort((a, b) => index.termFrequency(i, b) - index.termFrequency(i, a) || (a < b ? -1 : a > b ? 1 : 0));
265
+ // How much of that survives if the searcher does not know the name. A tool
266
+ // whose only exclusive word IS its name is reachable in principle (names
267
+ // are searchable) and unreachable in practice by anyone describing a task.
268
+ const ownOutsideName = own.filter((t) => !nameTermSets[i].has(t));
269
+ // Who shares this tool's vocabulary when it owns nothing. Ranked by how
270
+ // much of it they cover, so the first name listed is the tool most worth
271
+ // comparing against.
272
+ // Only for tools that own nothing, and only for the first few of those.
273
+ //
274
+ // This is the one quadratic step here, and it used to re-materialise
275
+ // `[...termSets[i]]` as a fresh array inside the loop, once per sibling:
276
+ // 1,000 tools that all share their vocabulary took about four seconds and
277
+ // 10,000 extrapolated to over half an hour. Iterating the smaller set with
278
+ // no allocation removes most of that, and the cap removes the rest. The
279
+ // cap costs nothing visible: the rules report at most five of these and
280
+ // the table shows fifteen rows.
281
+ const sharedWith = own.length || sharedComputed >= SHARED_WITH_BUDGET ? [] : (() => {
282
+ sharedComputed++;
283
+ const mine = termSets[i];
284
+ const scored = [];
285
+ for (let j = 0; j < tools.length; j++) {
286
+ if (j === i)
287
+ continue;
288
+ const other = termSets[j];
289
+ const [small, big] = mine.size <= other.size ? [mine, other] : [other, mine];
290
+ let overlap = 0;
291
+ for (const t of small)
292
+ if (big.has(t))
293
+ overlap++;
294
+ if (overlap > 0)
295
+ scored.push({ name: tools[j].name, overlap });
296
+ }
297
+ return scored
298
+ .sort((a, b) => b.overlap - a.overlap || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
299
+ .slice(0, 3)
300
+ .map((x) => x.name);
301
+ })();
302
+ const query = taskQuery(tool, index, queryTerms);
303
+ const hits = index.score(query);
304
+ // By POSITION, not by name. Two tools may share a name, and matching on it
305
+ // handed every duplicate the better twin's rank, score and tie set.
306
+ const at = hits.findIndex((h) => h.index === i);
307
+ const rank = at >= 0 ? at + 1 : undefined;
308
+ const score = at >= 0 ? hits[at].score : 0;
309
+ const outrankedBy = at > 0 ? hits.slice(0, at).map((h) => h.id) : [];
310
+ const tied = at >= 0
311
+ ? hits.filter((h, pos) => pos !== at && scoresTie(h.score, score) && h.score > 0).map((h) => h.id)
312
+ : [];
313
+ return {
314
+ name: tool.name,
315
+ ownTerms: own.slice(0, 4),
316
+ ownTermCount: own.length,
317
+ ownOutsideNameCount: ownOutsideName.length,
318
+ sharedWith,
319
+ query,
320
+ rank,
321
+ score,
322
+ outrankedBy: outrankedBy.slice(0, 3),
323
+ tiedWith: tied.slice(0, 3),
324
+ // Kept separately from the display cap. The rule that counts pairs was
325
+ // counting the capped list, so a catalog where every tool ties with
326
+ // every other reported 30 pairs instead of the real 66: a count of what
327
+ // survived a display cap, presented as a count of the problem.
328
+ tiedWithCount: tied.length,
329
+ reachable: rank !== undefined && rank <= topK,
330
+ genericName: nameIsGeneric(tool.name),
331
+ };
332
+ });
333
+ const distinctCount = perTool.filter((t) => t.ownTermCount > 0).length;
334
+ const reachable = perTool.filter((t) => t.reachable).length;
335
+ const windowCoversCatalog = tools.length <= topK;
336
+ const distinctVacuous = tools.length < 2;
337
+ const deferBecause = [];
338
+ if (tools.length >= DEFER_RECOMMENDED_TOOLS) {
339
+ deferBecause.push(`${tools.length} tools (Anthropic recommends tool search at ${DEFER_RECOMMENDED_TOOLS} or more)`);
340
+ }
341
+ if ((opts.definitionTokens ?? 0) > DEFER_RECOMMENDED_TOKENS) {
342
+ deferBecause.push(`~${(opts.definitionTokens ?? 0).toLocaleString("en-US")} tokens of definitions (Anthropic recommends tool search over ${DEFER_RECOMMENDED_TOKENS.toLocaleString("en-US")})`);
343
+ }
344
+ const deferRecommended = deferBecause.length > 0;
345
+ const notes = [
346
+ `method: terms come from the four fields tool search documents as searchable (tool name, description, argument names, argument descriptions). ` +
347
+ `"distinct" is a property of the catalog, not a model. The probe is a simulated BM25 (k1=${DEFAULT_BM25.k1}, b=${DEFAULT_BM25.b}), ` +
348
+ `each tool queried with the ${queryTerms} highest tf-idf terms of its own description (never its name or title, which a searcher does not know), window ${topK}. docs/METHODOLOGY.md.`,
349
+ `the probe reads 100% on every real server measured so far, so treat it as a floor test that catches an empty or duplicated description, not as a ranking.`,
350
+ ];
351
+ // Vacuity is stated, not hidden, and there are TWO kinds of it.
352
+ //
353
+ // The probe goes vacuous whenever the catalog fits inside the result window.
354
+ // `distinct` goes vacuous only at one tool, where every term trivially has
355
+ // document frequency 1 and the figure is 100% for any catalog whatsoever.
356
+ // The first version of this said "exclusive vocabulary can fail at any
357
+ // catalog size, including two tools", which is true at two and false at one,
358
+ // and then reassured the reader that everything except the probe was "still
359
+ // meaningful" while the headline number was the one that had stopped being
360
+ // evidence. One-tool servers are real: sequential-thinking is one.
361
+ if (windowCoversCatalog) {
362
+ notes.push(`VACUOUS PROBE: ${tools.length} tools and a result window of ${topK}, so no tool can fall outside it. ` +
363
+ `The probe figure is 100% by construction. Ranks, exclusive vocabulary and the findings below are still evidence.`);
364
+ }
365
+ if (distinctVacuous) {
366
+ notes.push(`VACUOUS DISTINCT: a one-tool catalog has nothing to be distinct from, so every term it has is trivially exclusive ` +
367
+ `and the figure is 100% for any tool whatsoever. It is not evidence about this server, and --min-distinct cannot be evaluated here.`);
368
+ }
369
+ // Duplicate names are a real defect and nothing else here reports them. The
370
+ // ranking no longer confuses two tools that share a name, but a client that
371
+ // routes by name still will.
372
+ const dupNames = [...new Set(tools.map((t) => t.name).filter((nm, i, all) => all.indexOf(nm) !== i))];
373
+ if (dupNames.length) {
374
+ notes.push(`${dupNames.length} tool name${dupNames.length === 1 ? " is" : "s are"} used by more than one tool (${dupNames.slice(0, 3).join(", ")}). ` +
375
+ `Ranks here are per tool, but a client that addresses tools by name cannot tell them apart at all.`);
376
+ }
377
+ notes.push(deferRecommended
378
+ ? `tool search is recommended for this catalog (${deferBecause.join("; ")}), so assume these tools are deferred and that findability is what decides whether they get used.`
379
+ : `${tools.length} tools and ~${(opts.definitionTokens ?? 0).toLocaleString("en-US")} tokens of definitions, under both thresholds where Anthropic recommends tool search, so these tools are probably loaded up front today and this report is advisory rather than urgent.`);
380
+ return {
381
+ kind: "find",
382
+ label,
383
+ toolCount: tools.length,
384
+ method: { tokenizer: "efaimo-v1", bm25: { ...DEFAULT_BM25 }, queryTerms, topK },
385
+ perTool,
386
+ distinct: {
387
+ count: distinctCount,
388
+ total: tools.length,
389
+ pct: Math.round((distinctCount / tools.length) * 1000) / 10,
390
+ },
391
+ probe: {
392
+ returned: reachable,
393
+ total: tools.length,
394
+ pct: Math.round((reachable / tools.length) * 1000) / 10,
395
+ },
396
+ windowCoversCatalog,
397
+ distinctVacuous,
398
+ definitionTokens: opts.definitionTokens,
399
+ deferRecommended,
400
+ deferBecause,
401
+ notes,
402
+ };
403
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The tokenizer the findability simulation runs on.
3
+ *
4
+ * Tool search (both variants) matches against a tool's name, description,
5
+ * argument names and argument descriptions. Anthropic does not publish the
6
+ * analyzer it uses, so this is a stated approximation rather than a
7
+ * reproduction, and docs/METHODOLOGY.md says exactly that. What matters is
8
+ * that it is deterministic and written down: the same catalog always produces
9
+ * the same terms, on every machine.
10
+ *
11
+ * Splitting rules, in order:
12
+ * 1. camelCase and PascalCase boundaries become separators (`getWeather` ->
13
+ * `get weather`), because a regex like `.*weather.*` has to reach the
14
+ * second half of a camelCase name and a naive split on `[^a-z0-9]` never
15
+ * would.
16
+ * 2. Runs of digits after letters split too (`s3Bucket` keeps `s3`, but
17
+ * `getV2Config` yields `get v2 config`).
18
+ * 3. Everything that is not a letter or a digit is a separator, so
19
+ * `get_weather`, `get-weather` and `get.weather` all land on the same
20
+ * two terms.
21
+ * 4. Lowercase, and drop single characters, which carry no retrieval signal
22
+ * and only distort the length normalization.
23
+ */
24
+ /**
25
+ * Longest field this will tokenize.
26
+ *
27
+ * Every string here comes from the audited server, which may be hostile and is
28
+ * in any case not obliged to be sensible. The split below is linear, but linear
29
+ * on ten megabytes is still ten megabytes, done twice per description, and the
30
+ * connect timeout bounds the RPC rather than what happens after it. A tool
31
+ * definition that needs more than this to describe itself has a finding of its
32
+ * own waiting in `check`.
33
+ */
34
+ export declare const MAX_FIELD_CHARS = 200000;
35
+ export declare function tokenize(text: string): string[];
36
+ /**
37
+ * English function words, excluded from QUERY SELECTION only.
38
+ *
39
+ * They stay in the index on purpose. BM25's IDF already discounts a term that
40
+ * appears in most documents, and removing them from the index would also
41
+ * change every document's length, which changes every score. They are excluded
42
+ * from query selection because that step ranks a tool's own terms by TF-IDF,
43
+ * and in a small catalog a function word repeated three times in one
44
+ * description can outrank the domain noun that actually identifies the tool.
45
+ *
46
+ * Deliberately short and boring: this list is part of the published method, so
47
+ * every word in it has to be defensible as a word nobody searches for.
48
+ */
49
+ export declare const QUERY_STOPWORDS: Set<string>;
50
+ /**
51
+ * Name tokens that identify nothing on their own.
52
+ *
53
+ * Used by E144 only. Anthropic's own debugging note for tool search says
54
+ * Claude writes broad patterns such as `.*weather.*`, and that adding common
55
+ * keywords to descriptions improves discoverability. A name assembled purely
56
+ * from these words carries no such keyword, so the only thing that can reach
57
+ * the tool is its description.
58
+ *
59
+ * A word being here does NOT make a name bad: `get_weather` has `weather`.
60
+ * The rule fires only when EVERY token of a name is in this set.
61
+ */
62
+ export declare const GENERIC_NAME_TOKENS: Set<string>;
63
+ /** True when a tool name is built only from words that identify nothing. */
64
+ export declare function nameIsGeneric(name: string): boolean;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The tokenizer the findability simulation runs on.
3
+ *
4
+ * Tool search (both variants) matches against a tool's name, description,
5
+ * argument names and argument descriptions. Anthropic does not publish the
6
+ * analyzer it uses, so this is a stated approximation rather than a
7
+ * reproduction, and docs/METHODOLOGY.md says exactly that. What matters is
8
+ * that it is deterministic and written down: the same catalog always produces
9
+ * the same terms, on every machine.
10
+ *
11
+ * Splitting rules, in order:
12
+ * 1. camelCase and PascalCase boundaries become separators (`getWeather` ->
13
+ * `get weather`), because a regex like `.*weather.*` has to reach the
14
+ * second half of a camelCase name and a naive split on `[^a-z0-9]` never
15
+ * would.
16
+ * 2. Runs of digits after letters split too (`s3Bucket` keeps `s3`, but
17
+ * `getV2Config` yields `get v2 config`).
18
+ * 3. Everything that is not a letter or a digit is a separator, so
19
+ * `get_weather`, `get-weather` and `get.weather` all land on the same
20
+ * two terms.
21
+ * 4. Lowercase, and drop single characters, which carry no retrieval signal
22
+ * and only distort the length normalization.
23
+ */
24
+ /**
25
+ * Longest field this will tokenize.
26
+ *
27
+ * Every string here comes from the audited server, which may be hostile and is
28
+ * in any case not obliged to be sensible. The split below is linear, but linear
29
+ * on ten megabytes is still ten megabytes, done twice per description, and the
30
+ * connect timeout bounds the RPC rather than what happens after it. A tool
31
+ * definition that needs more than this to describe itself has a finding of its
32
+ * own waiting in `check`.
33
+ */
34
+ export const MAX_FIELD_CHARS = 200_000;
35
+ export function tokenize(text) {
36
+ if (!text)
37
+ return [];
38
+ return (text.length > MAX_FIELD_CHARS ? text.slice(0, MAX_FIELD_CHARS) : text)
39
+ // ACRONYMFollowed -> ACRONYM Followed, before the general camel split, so
40
+ // `HTTPServer` does not become `httpserver` or `h t t p server`.
41
+ //
42
+ // One capital, not `[A-Z]+`. The quantified form is quadratic on a run of
43
+ // capitals with no lowercase after it: every start position backtracks the
44
+ // whole run. Measured on the old expression, `'A'.repeat(n)` took 43ms at
45
+ // 10k, 7.8s at 100k and 147s at 400k, so one 300KB shouted description
46
+ // pinned a core for minutes while `weigh` and `check` on the same server
47
+ // answered in under a second. `([A-Z])([A-Z][a-z])` matches the same
48
+ // boundaries in linear time: on `HTTPServer` it still splits at `P|Se`.
49
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2")
50
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
51
+ .replace(/([A-Za-z])(\d)/g, "$1 $2")
52
+ .split(/[^A-Za-z0-9]+/)
53
+ .map((s) => s.toLowerCase())
54
+ .filter((s) => s.length > 1);
55
+ }
56
+ /**
57
+ * English function words, excluded from QUERY SELECTION only.
58
+ *
59
+ * They stay in the index on purpose. BM25's IDF already discounts a term that
60
+ * appears in most documents, and removing them from the index would also
61
+ * change every document's length, which changes every score. They are excluded
62
+ * from query selection because that step ranks a tool's own terms by TF-IDF,
63
+ * and in a small catalog a function word repeated three times in one
64
+ * description can outrank the domain noun that actually identifies the tool.
65
+ *
66
+ * Deliberately short and boring: this list is part of the published method, so
67
+ * every word in it has to be defensible as a word nobody searches for.
68
+ */
69
+ export const QUERY_STOPWORDS = new Set([
70
+ // Two and three letter function words. The tokenizer drops single
71
+ // characters, so without these a description like "Delete all records in the
72
+ // datastore" probes with `in`, and a term that appears in half the catalog
73
+ // spends one of the four query slots saying nothing.
74
+ "in", "on", "at", "to", "of", "or", "as", "by", "is", "be", "an", "if",
75
+ "do", "so", "up", "we", "us", "am", "he", "she", "him", "her", "his", "me",
76
+ "my", "no", "yes",
77
+ "the", "and", "for", "with", "that", "this", "from", "into", "onto", "than",
78
+ "then", "they", "them", "their", "there", "these", "those", "will", "would",
79
+ "shall", "should", "can", "could", "may", "might", "must", "not", "but",
80
+ "are", "was", "were", "been", "being", "have", "has", "had", "does", "did",
81
+ "you", "your", "yours", "its", "it", "our", "ours", "any", "all", "each",
82
+ "such", "only", "also", "more", "most", "some", "other", "when", "where",
83
+ "which", "while", "what", "who", "whom", "how", "why", "use", "used",
84
+ "using", "via", "per", "out", "off", "over", "under", "about", "above",
85
+ "below", "between", "before", "after", "here", "one", "two", "yes", "no",
86
+ ]);
87
+ /**
88
+ * Name tokens that identify nothing on their own.
89
+ *
90
+ * Used by E144 only. Anthropic's own debugging note for tool search says
91
+ * Claude writes broad patterns such as `.*weather.*`, and that adding common
92
+ * keywords to descriptions improves discoverability. A name assembled purely
93
+ * from these words carries no such keyword, so the only thing that can reach
94
+ * the tool is its description.
95
+ *
96
+ * A word being here does NOT make a name bad: `get_weather` has `weather`.
97
+ * The rule fires only when EVERY token of a name is in this set.
98
+ */
99
+ export const GENERIC_NAME_TOKENS = new Set([
100
+ "get", "set", "put", "post", "patch", "add", "new", "create", "make",
101
+ "update", "edit", "modify", "change", "delete", "remove", "drop",
102
+ "list", "read", "write", "fetch", "load", "save", "send", "call",
103
+ "run", "exec", "execute", "invoke", "do", "handle", "process", "perform",
104
+ "query", "search", "find", "lookup", "check", "test", "start", "stop",
105
+ "open", "close", "begin", "end", "init", "setup", "config", "configure",
106
+ "tool", "api", "data", "item", "items", "object", "thing", "value",
107
+ "values", "result", "results", "action", "request", "response", "info",
108
+ "helper", "util", "utils", "main", "default", "generic", "custom", "my",
109
+ ]);
110
+ /** True when a tool name is built only from words that identify nothing. */
111
+ export function nameIsGeneric(name) {
112
+ const terms = tokenize(name);
113
+ if (!terms.length)
114
+ return true;
115
+ return terms.every((t) => GENERIC_NAME_TOKENS.has(t));
116
+ }
package/dist/index.d.ts CHANGED
@@ -7,10 +7,13 @@ export { weighServer, weighSkills } from "./weigh/weigh.js";
7
7
  export { diffServerWeigh, type WeighDiff } from "./weigh/diff.js";
8
8
  export { countTokens } from "./weigh/tokens.js";
9
9
  export { findSkills, parseSkillFile } from "./skills/parse.js";
10
+ export { analyzeFind, indexedTerms, taskQuery, DEFAULT_TOP_K, DEFAULT_QUERY_TERMS, DEFER_RECOMMENDED_TOKENS, DEFER_RECOMMENDED_TOOLS, type FindOptions } from "./find/find.js";
11
+ export { runFindRules } from "./core/engine.js";
12
+ export { RULES_VERSION } from "./rules/version.js";
10
13
  export { checkMcpTarget, checkMcpRepoOnly, checkSkillSet, type SkillReport, type CheckSkillResult } from "./check/check.js";
11
14
  export { parseScenario, runScenario, armSystems, type Scenario, type TestReport, type Runner, } from "./testing/harness.js";
12
- export { anthropicRunner } from "./testing/anthropicRunner.js";
15
+ export { anthropicRunner, acceptsSamplingParams, anthropicRequestBody } from "./testing/anthropicRunner.js";
13
16
  export { openaiRunner, providerForModel } from "./testing/openaiRunner.js";
14
- export { renderCheckPretty, renderSkillSetPretty, renderServerWeighPretty, renderSkillWeighPretty, renderDiffPretty, renderScenarioPlan, renderTestReportPretty, } from "./reporters/pretty.js";
15
- export { renderCheckMarkdown, renderSkillSetMarkdown, renderWeighMarkdown, renderDiffMarkdown, } from "./reporters/markdown.js";
17
+ export { renderCheckPretty, renderSkillSetPretty, renderServerWeighPretty, renderSkillWeighPretty, renderDiffPretty, renderScenarioPlan, renderTestReportPretty, renderFindPretty, renderFindFindingsPretty, } from "./reporters/pretty.js";
18
+ export { renderCheckMarkdown, renderSkillSetMarkdown, renderWeighMarkdown, renderDiffMarkdown, renderFindMarkdown, } from "./reporters/markdown.js";
16
19
  export { toJsonEnvelope } from "./reporters/json.js";
package/dist/index.js CHANGED
@@ -14,13 +14,23 @@ export { diffServerWeigh } from "./weigh/diff.js";
14
14
  export { countTokens } from "./weigh/tokens.js";
15
15
  // Skills
16
16
  export { findSkills, parseSkillFile } from "./skills/parse.js";
17
+ // Find (findability under deferred tool loading)
18
+ //
19
+ // Exported because docs/INTEGRATIONS.md promises "every capability is also a
20
+ // typed function", and the blanket re-export of core/types.js was already
21
+ // publishing FindResult and ToolFindEntry: a public type with nothing public
22
+ // that produces one. runFindRules is here for the same reason its output is,
23
+ // even though the rule TABLE stays private like the other two.
24
+ export { analyzeFind, indexedTerms, taskQuery, DEFAULT_TOP_K, DEFAULT_QUERY_TERMS, DEFER_RECOMMENDED_TOKENS, DEFER_RECOMMENDED_TOOLS } from "./find/find.js";
25
+ export { runFindRules } from "./core/engine.js";
26
+ export { RULES_VERSION } from "./rules/version.js";
17
27
  // Check (audit)
18
28
  export { checkMcpTarget, checkMcpRepoOnly, checkSkillSet } from "./check/check.js";
19
29
  // Test (skill A/B outcome harness)
20
30
  export { parseScenario, runScenario, armSystems, } from "./testing/harness.js";
21
- export { anthropicRunner } from "./testing/anthropicRunner.js";
31
+ export { anthropicRunner, acceptsSamplingParams, anthropicRequestBody } from "./testing/anthropicRunner.js";
22
32
  export { openaiRunner, providerForModel } from "./testing/openaiRunner.js";
23
33
  // Reporters (report -> string; stable value-in/string-out contract)
24
- export { renderCheckPretty, renderSkillSetPretty, renderServerWeighPretty, renderSkillWeighPretty, renderDiffPretty, renderScenarioPlan, renderTestReportPretty, } from "./reporters/pretty.js";
25
- export { renderCheckMarkdown, renderSkillSetMarkdown, renderWeighMarkdown, renderDiffMarkdown, } from "./reporters/markdown.js";
34
+ export { renderCheckPretty, renderSkillSetPretty, renderServerWeighPretty, renderSkillWeighPretty, renderDiffPretty, renderScenarioPlan, renderTestReportPretty, renderFindPretty, renderFindFindingsPretty, } from "./reporters/pretty.js";
35
+ export { renderCheckMarkdown, renderSkillSetMarkdown, renderWeighMarkdown, renderDiffMarkdown, renderFindMarkdown, } from "./reporters/markdown.js";
26
36
  export { toJsonEnvelope } from "./reporters/json.js";
@@ -1 +1,19 @@
1
- export declare function toJsonEnvelope(kind: "check" | "weigh" | "test", data: unknown): string;
1
+ export type EnvelopeKind = "check" | "weigh" | "test" | "find";
2
+ export interface EnvelopeOptions {
3
+ /**
4
+ * Include `generatedAt`. Default true for a report a human reads.
5
+ *
6
+ * Off for anything meant to be committed, diffed or compared. A wall-clock
7
+ * stamp inside the payload makes every regeneration a diff even when not one
8
+ * measured number moved, which is how a re-run that changed nothing still
9
+ * produces a commit that has to be reviewed as though it might have. This
10
+ * project has shipped several releases whose only data change was described
11
+ * in its own notes as "stamp-only".
12
+ *
13
+ * `--out` baselines always drop it: a baseline exists to be compared against,
14
+ * and a field that differs on every write is the one field a comparison must
15
+ * ignore.
16
+ */
17
+ timestamp?: boolean;
18
+ }
19
+ export declare function toJsonEnvelope(kind: EnvelopeKind, data: unknown, opts?: EnvelopeOptions): string;