@browserstack/mcp-server 1.5.1 → 2.0.0-beta.1

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 (38) hide show
  1. package/README.md +54 -1
  2. package/capability/tm.capability-index.json +50420 -0
  3. package/capability/tra.capability-index.json +52096 -0
  4. package/dist/lib/central-oauth.d.ts +133 -0
  5. package/dist/lib/central-oauth.js +246 -0
  6. package/dist/lib/instrumentation.d.ts +14 -1
  7. package/dist/lib/instrumentation.js +8 -1
  8. package/dist/server-factory.js +5 -0
  9. package/dist/tools/ask-browserstack/central-oauth.d.ts +25 -46
  10. package/dist/tools/ask-browserstack/central-oauth.js +55 -177
  11. package/dist/tools/capability-registry/bind.d.ts +28 -0
  12. package/dist/tools/capability-registry/bind.js +407 -0
  13. package/dist/tools/capability-registry/config.d.ts +62 -0
  14. package/dist/tools/capability-registry/config.js +218 -0
  15. package/dist/tools/capability-registry/discovery.d.ts +44 -0
  16. package/dist/tools/capability-registry/discovery.js +101 -0
  17. package/dist/tools/capability-registry/egress.d.ts +51 -0
  18. package/dist/tools/capability-registry/egress.js +197 -0
  19. package/dist/tools/capability-registry/index-loader.d.ts +162 -0
  20. package/dist/tools/capability-registry/index-loader.js +523 -0
  21. package/dist/tools/capability-registry/oauth.d.ts +63 -0
  22. package/dist/tools/capability-registry/oauth.js +176 -0
  23. package/dist/tools/capability-registry/redact.d.ts +34 -0
  24. package/dist/tools/capability-registry/redact.js +98 -0
  25. package/dist/tools/capability-registry/register.d.ts +48 -0
  26. package/dist/tools/capability-registry/register.js +956 -0
  27. package/dist/tools/capability-registry/resolve.d.ts +38 -0
  28. package/dist/tools/capability-registry/resolve.js +104 -0
  29. package/dist/tools/capability-registry/routing-token.d.ts +25 -0
  30. package/dist/tools/capability-registry/routing-token.js +87 -0
  31. package/dist/tools/capability-registry/search.d.ts +197 -0
  32. package/dist/tools/capability-registry/search.js +907 -0
  33. package/dist/tools/capability-registry/types.d.ts +341 -0
  34. package/dist/tools/capability-registry/types.js +33 -0
  35. package/dist/tools/testmanagement.js +5 -5
  36. package/dist/tools/tool-handoff.d.ts +15 -12
  37. package/dist/tools/tool-handoff.js +21 -18
  38. package/package.json +10 -3
@@ -0,0 +1,907 @@
1
+ /**
2
+ * Ranking capabilities against a plain-language query.
3
+ *
4
+ * Ported from the Python `discover._score`, including the two properties that were each
5
+ * fixed after a live mis-ranking:
6
+ *
7
+ * * PENALTIES REORDER, THEY DO NOT EXCLUDE. `matched` is the pre-penalty term score and is
8
+ * what decides inclusion; `ranked` carries the preferences. Conflating them dropped 40
9
+ * legitimate matches outright, because a cardinality penalty took an otherwise-valid
10
+ * score to zero and the caller saw "no such capability".
11
+ * * CARDINALITY. A "list" query answered by a single-record getter sends the caller to a
12
+ * capability needing an id it cannot possibly have yet.
13
+ */
14
+ /**
15
+ * Every non-alphanumeric character separates, `_` included.
16
+ *
17
+ * `_` used to be a word character, which made `test_case` a single token while every
18
+ * haystack rendered it as "test case" — so the two could never match. That is the exact
19
+ * string `listProducts` hands back, so a caller following the documented flow searched with
20
+ * a term guaranteed to score zero: "list test_runs" matched 19 capabilities and put an
21
+ * admin settings endpoint first, where "list test runs" matched 103 and put the test-runs
22
+ * listing first.
23
+ */
24
+ const WORD = /[a-z0-9]+/g;
25
+ const STOPWORDS = new Set([
26
+ "a",
27
+ "an",
28
+ "and",
29
+ "are",
30
+ "as",
31
+ "at",
32
+ "be",
33
+ "by",
34
+ "can",
35
+ "do",
36
+ "for",
37
+ "from",
38
+ "how",
39
+ "i",
40
+ "in",
41
+ "is",
42
+ "it",
43
+ "me",
44
+ "my",
45
+ "of",
46
+ "on",
47
+ "or",
48
+ "has",
49
+ "have",
50
+ "that",
51
+ "the",
52
+ "these",
53
+ "this",
54
+ "those",
55
+ "to",
56
+ "want",
57
+ "what",
58
+ "which",
59
+ "with",
60
+ "you",
61
+ ]);
62
+ // Verbs that reveal what the caller means to DO. A preference, not a filter — an explicit
63
+ // `mode` argument is the filter.
64
+ const READ_VERBS = new Set([
65
+ "list",
66
+ "get",
67
+ "show",
68
+ "find",
69
+ "fetch",
70
+ "read",
71
+ "count",
72
+ "search",
73
+ "view",
74
+ "which",
75
+ "how",
76
+ ]);
77
+ const WRITE_VERBS = new Set([
78
+ "create",
79
+ "add",
80
+ "update",
81
+ "edit",
82
+ "delete",
83
+ "remove",
84
+ "move",
85
+ "copy",
86
+ "archive",
87
+ "assign",
88
+ "restore",
89
+ "reorder",
90
+ "bulk",
91
+ "set",
92
+ "upload",
93
+ "import",
94
+ "clone",
95
+ ]);
96
+ // Words that mean "give me many", which is what makes a single-record getter the wrong answer.
97
+ const PLURAL_INTENT = new Set([
98
+ "list",
99
+ "all",
100
+ "every",
101
+ "many",
102
+ "count",
103
+ "search",
104
+ "find",
105
+ "which",
106
+ "each",
107
+ ]);
108
+ /**
109
+ * Query/haystack terms. Verbs are deliberately NOT stopwords — they carry the intent.
110
+ *
111
+ * camelCase is split before lowercasing, so `testRunId`, `test_run_id` and `test run id`
112
+ * all tokenize alike.
113
+ */
114
+ /**
115
+ * Is this the browse wildcard rather than a search?
116
+ *
117
+ * Accepts `*` and `all` — both are what a caller reaches for when the intent is "show me
118
+ * everything", and treating only one of them as the wildcard means the other silently
119
+ * becomes a bad search. Whitespace is tolerated so a pasted value still works.
120
+ */
121
+ export function isBrowseQuery(query) {
122
+ const q = (query || "").trim().toLowerCase();
123
+ return q === "*" || q === "all";
124
+ }
125
+ export function terms(text) {
126
+ return [
127
+ ...(text || "")
128
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
129
+ .toLowerCase()
130
+ .matchAll(WORD),
131
+ ]
132
+ .map((match) => match[0])
133
+ .filter((word) => !STOPWORDS.has(word));
134
+ }
135
+ /**
136
+ * A term plus its naive singular variants.
137
+ *
138
+ * QUERY SIDE ONLY, which is what makes this cheap and safe. Matching is one-directional
139
+ * substring containment, so indexed `attachments` already contains a query of `attachment`;
140
+ * only the reverse — a plural query against singular text — needs help. Stemming the
141
+ * indexed side too would mean rewriting the product's own vocabulary to guess at English,
142
+ * for no additional match.
143
+ */
144
+ export function termForms(term) {
145
+ const forms = [term];
146
+ // A stripped form must still be three characters. `has` -> `ha` matched more than half
147
+ // the surface as a substring and pushed a correct answer out of the top 8 entirely;
148
+ // short fragments are noise, not variants.
149
+ const add = (form) => {
150
+ if (form.length >= 3)
151
+ forms.push(form);
152
+ };
153
+ if (term.endsWith("es"))
154
+ add(term.slice(0, -2));
155
+ if (term.endsWith("s") && !term.endsWith("ss"))
156
+ add(term.slice(0, -1));
157
+ return forms;
158
+ }
159
+ /**
160
+ * ONE canonical spelling of a word, so two spellings of a noun cannot look like two terms.
161
+ *
162
+ * `termForms` is deliberately generous — it offers every candidate and lets substring
163
+ * containment sort them out. Canonicalising needs the opposite: exactly one answer, and
164
+ * the right one. Taking the SHORTEST candidate is what a first cut did, and it folded
165
+ * `cases` to `cas`, so the vocabulary entry `test case` matched no query containing "test
166
+ * cases" — the phrase it exists for. The ambiguity check then fell through to `test`
167
+ * alone, reaching the right verdict by the wrong route.
168
+ *
169
+ * Strip `es` only after a sibilant, where English actually inserts it (`boxes`,
170
+ * `batches`, `statuses`). Otherwise strip the single `s`, which is right for the `-e`
171
+ * plurals this vocabulary is full of: case, suite, phase, template.
172
+ */
173
+ export function singular(word) {
174
+ if (word.endsWith("ss"))
175
+ return word;
176
+ // histories -> history. Without this the alias `histories` and the entity word
177
+ // `history` are two unrelated terms, which is the same defect in another spelling.
178
+ if (word.endsWith("ies") && word.length > 4)
179
+ return `${word.slice(0, -3)}y`;
180
+ // Only where the `e` is genuinely inserted. `s` is NOT in this set: `cases` ends in
181
+ // `ses` and is `case` + `s`, not `cas` + `es`, and no rule can tell it from `statuses`
182
+ // by suffix alone — so the commoner reading wins and `-s` is stripped below.
183
+ if (/(?:ch|sh|x|z)es$/.test(word))
184
+ return word.slice(0, -2);
185
+ if (word.endsWith("s") && word.length > 3)
186
+ return word.slice(0, -1);
187
+ return word;
188
+ }
189
+ /** A haystack as one lowercased, space-separated string, ready for containment tests. */
190
+ function haystack(text) {
191
+ return terms(text).join(" ");
192
+ }
193
+ export function modeHint(query) {
194
+ const words = new Set(terms(query));
195
+ const wantsWrite = [...words].some((word) => WRITE_VERBS.has(word));
196
+ if (wantsWrite)
197
+ return "write";
198
+ const wantsRead = [...words].some((word) => READ_VERBS.has(word));
199
+ return wantsRead ? "read" : "";
200
+ }
201
+ export function wantsCollection(query) {
202
+ return [...(query || "").toLowerCase().matchAll(WORD)].some((match) => PLURAL_INTENT.has(match[0]));
203
+ }
204
+ /**
205
+ * True when a capability answers with many records rather than one.
206
+ *
207
+ * Pagination is the reliable signal — a paged operation is a listing by construction. The
208
+ * plural terminal path segment is a weaker fallback for unpaged collections. (The Python
209
+ * side used the capability NAME here. tm now publishes one, but its verbs are not
210
+ * consistent — the endpoint that lists folders is `list_root_folders` — so the path's
211
+ * terminal noun remains the better signal.)
212
+ */
213
+ export function isCollection(capability) {
214
+ if (capability.paginated)
215
+ return true;
216
+ const segments = capability.path
217
+ .split("/")
218
+ .filter((s) => s && !s.startsWith("{"));
219
+ const tail = segments[segments.length - 1] || "";
220
+ return tail.endsWith("s") && !tail.endsWith("ss");
221
+ }
222
+ /**
223
+ * Does this capability's mode answer what the caller asked to DO?
224
+ *
225
+ * `destructive` counts as a write. It has to: `modeHint` reads "delete" and "remove" as
226
+ * WRITE verbs, but a delete endpoint's mode is `destructive`, so a strict equality check
227
+ * penalised every destructive capability by 20 FOR BEING A DELETE — on exactly the queries
228
+ * that wanted one. "bulk delete test cases" scored 15.8 on terms, the highest of any missed
229
+ * query in the eval, and still fell out of the top 8.
230
+ *
231
+ * That accounted for 15 of the eval's 36 misses. The mode hint exists to separate reading
232
+ * from changing; destructive is changing.
233
+ *
234
+ * A read hint still refuses destructive, which is the useful half: "show me the test plans"
235
+ * must not surface a delete.
236
+ */
237
+ function satisfiesHint(mode, hint) {
238
+ if (hint === "write")
239
+ return mode === "write" || mode === "destructive";
240
+ return mode === hint;
241
+ }
242
+ /** Everything a caller might say that lives on a parameter rather than in the prose. */
243
+ function parameterText(capability) {
244
+ const parts = [];
245
+ for (const group of [
246
+ capability.path_params,
247
+ capability.query,
248
+ capability.body,
249
+ ]) {
250
+ for (const param of group || []) {
251
+ parts.push(param.name);
252
+ if (param.description)
253
+ parts.push(param.description);
254
+ if (param.values)
255
+ parts.push(param.values.map(String).join(" "));
256
+ }
257
+ }
258
+ return parts.join(" ");
259
+ }
260
+ /**
261
+ * Path words are the identity haystack. The published `name` is deliberately NOT scored.
262
+ *
263
+ * That is a measured result, not an oversight. tm now names every capability, and adding
264
+ * the name to the ranking was tried three ways against tests/fixtures/search-eval.json,
265
+ * which the pre-names index serves 18/18:
266
+ *
267
+ * folded into this field, weight 6 17/18
268
+ * its own field, weights 1 / 2 / 3 / 4 / 6 17 / 17 / 17 / 17 / 16
269
+ * only the words the path lacks, 1..6 17 / 16 / 15 / 15 / 14
270
+ *
271
+ * Every variant loses, for two reasons. The nouns in a name are the route restated — the
272
+ * name is snake-cased from the operationId, itself derived from the path — so scoring them
273
+ * again rewards verbose names for repeating themselves: `list_test_run_test_cases_by_integer_id`
274
+ * displaced `create_test_result_for_test_case` on "record a pass or fail for a test case in
275
+ * a run", putting a read above the write that answers it. And what a name adds beyond the
276
+ * route is mostly its verb, which tm applies inconsistently (`list_root_folders` lists,
277
+ * `list_folder_test_cases` also lists), so the verb is noise as often as signal.
278
+ *
279
+ * The query this was meant to fix, "list all projects", only went 7 -> 4 even where it
280
+ * helped: `projects` is in 156 of 173 paths as a scope prefix, so rarity correctly values it
281
+ * near zero and no amount of name weighting recovers it. That one is fixed instead by the
282
+ * terminal-segment bonus in `score`, which tells "is that thing" from "is scoped by it".
283
+ *
284
+ * Revisit when a product ships a consistent verb convention — then the verb becomes signal.
285
+ */
286
+ /**
287
+ * The terminal path segment — the thing this endpoint is actually ABOUT.
288
+ *
289
+ * A REST path mixes two different things: the resources it is SCOPED BY, and the resource it
290
+ * ADDRESSES. Only the last segment is the latter. See the bonus in `score` for why that
291
+ * distinction matters and why it is applied flat rather than weighted.
292
+ *
293
+ * Trailing placeholders are skipped, so `/test-cases/{id}` is still about test cases.
294
+ * Action tails (`close`, `edit`, `delete`) are kept rather than skipped: for "close a test
295
+ * run" the tail IS the most specific thing the caller said.
296
+ */
297
+ function resourceText(capability) {
298
+ const segments = capability.path
299
+ .split("/")
300
+ .filter((s) => s && !s.startsWith("{") && s !== "api");
301
+ return (segments[segments.length - 1] || "").replace(/[-_]/g, " ");
302
+ }
303
+ function identityText(capability) {
304
+ return capability.path
305
+ .split("/")
306
+ .filter((segment) => segment && !segment.startsWith("{") && segment !== "api")
307
+ .join(" ")
308
+ .replace(/[-_]/g, " ");
309
+ }
310
+ /**
311
+ * How much one term is worth, by how rare it is.
312
+ *
313
+ * Containment made every project-scoped endpoint match the term `project` — ~150 of tm's
314
+ * 173 capabilities — so that word carried as much weight as `access`, which appears in
315
+ * exactly one. Rarity is what separates them: a term matching everything scores near zero,
316
+ * a term matching one capability scores near one.
317
+ *
318
+ * This is the IDF idea alone, not BM25. The term-frequency saturation and length
319
+ * normalisation BM25 adds would rescale every score, and the mode and cardinality
320
+ * adjustments below are absolute constants fitted against live mis-rankings. Bounding the
321
+ * factor to 0..1 keeps those constants meaningful.
322
+ */
323
+ function rarity(documents, forms) {
324
+ let df = 0;
325
+ for (const text of documents) {
326
+ if (forms.some((form) => text.includes(form)))
327
+ df += 1;
328
+ }
329
+ const total = documents.length || 1;
330
+ return Math.log((total + 1) / (df + 1)) / Math.log(total + 1);
331
+ }
332
+ /**
333
+ * Sized to sit alongside the mode (+6) and cardinality (+8) constants, not to dwarf them.
334
+ * The pinned eval is unchanged at every value from 2 to 14 — the bonus only ever fires on
335
+ * queries it does not cover — so this was chosen on the wider sweep: at 10, "close a test
336
+ * run" starts pulling `close_exploratory_session` into second place on the tail match alone.
337
+ */
338
+ const RESOURCE_BONUS = 6;
339
+ /**
340
+ * How much a cardinality guess is worth. It used to be 8, and it should not have been.
341
+ *
342
+ * `isCollection` is a GUESS read off the URL, and the index carries nothing better: of 88
343
+ * read capabilities, zero declare an array in their response schema, and `paginated` — the
344
+ * one signal that looks trustworthy — is true for `get_report_detail`, a single-record
345
+ * endpoint. There is no derived cardinality in this artifact to appeal to.
346
+ *
347
+ * At ±8 that guess swung 16 points and buried 11 correct answers: `/test-runs/closed` and
348
+ * `/{entity}/search` are listings whose last path segment is not a plural noun, `users-v2`
349
+ * is plural with a version suffix in the way, and the count endpoints answer "how many"
350
+ * with a scalar. All were penalised for what they are called.
351
+ *
352
+ * The fix is not a better classifier — a qualifier word-list would encode tm's route
353
+ * conventions into generic code and rot on the next product. It is to stop betting so much
354
+ * on an unreliable signal. Swept against the eval, holding top1 at 138 with no ceiling
355
+ * violations: ±7 retires 1, ±6 retires 2, ±5 retires 5, ±4 retires 6. Below that it starts
356
+ * costing more than it returns — ±3.5 retires 8 but breaks a case, ±3 breaks two.
357
+ */
358
+ const CARDINALITY = 4;
359
+ /**
360
+ * The mode penalty stays at 20, unlike the cardinality one — it is earned.
361
+ *
362
+ * Where `isCollection` is a guess off the URL, `modeHint` is measured right: across the
363
+ * eval it agrees with the correct answer's mode 125 times and disagrees 3. A signal that
364
+ * accurate deserves to be decisive.
365
+ *
366
+ * Sweeping it 20 -> 6 retires none of the three misses filed against it and regresses
367
+ * nothing, which says the penalty is not what holds them back. It isn't: they sit at ranks
368
+ * 41, 62 and 78 of the matched set, far below anything a constant could lift. All three
369
+ * are vocabulary gaps wearing a mode-penalty label — "what gets removed" wants a path
370
+ * spelled `rm-summary`, "get rid of" wants `delete`, and "the option set" wants what the
371
+ * product calls a `dataset`. The target barely matches on TERMS; the hint is incidental.
372
+ *
373
+ * Left at 20 deliberately. There was no evidence for moving it, and an unjustified constant
374
+ * is how this scorer got into trouble in the first place.
375
+ */
376
+ const MODE_PENALTY = 20;
377
+ function score(capability, wanted, weights, aliases, hint, plural) {
378
+ if (wanted.length === 0)
379
+ return { matched: 1, ranked: 1 };
380
+ const haystacks = [
381
+ [identityText(capability), IDENTITY_WEIGHT],
382
+ [capability.entity, 4],
383
+ [(aliases[capability.entity] || []).join(" "), 4],
384
+ [capability.intent || "", 2],
385
+ // `returns` is scored BELOW identity, not gated on it. At parity with intent it put a
386
+ // projects listing at #2 for "list test cases in a project" (its returns carries
387
+ // `test_cases_count`); gating it on an identity match instead made a field reachable
388
+ // only through returns unreachable, which is worse.
389
+ [(capability.returns || []).join(" "), 1],
390
+ [(capability.guidance || []).join(" "), 1],
391
+ // Parameter names, their descriptions, and their enum values — 330 descriptions and 34
392
+ // value lists that the artifact already carries and nothing was reading. The vocabulary
393
+ // a caller uses is often the value they mean to send: `pass` and `fail` appear nowhere
394
+ // else in the index, only as the `status` enum on the test-result writes.
395
+ [parameterText(capability), 1],
396
+ ];
397
+ // CONTAINMENT, not set membership. A query of `attachment` has to reach an endpoint whose
398
+ // path says `attachments`; under exact token equality it did not, and that endpoint fell
399
+ // out of the results entirely. A term scores its field once however many forms match.
400
+ let ranked = 0;
401
+ for (const [text, weight] of haystacks) {
402
+ const blob = haystack(text);
403
+ if (!blob)
404
+ continue;
405
+ for (let i = 0; i < wanted.length; i += 1) {
406
+ if (wanted[i].some((form) => blob.includes(form)))
407
+ ranked += weight * weights[i];
408
+ }
409
+ }
410
+ // PHRASE. Adjacent query terms occurring together say more than the same two words
411
+ // scattered: "test case" is one noun in this vocabulary, "test" and "case" separately
412
+ // are two of the commonest words in the index. Scored at half the field's weight and
413
+ // still scaled by rarity, so it sharpens an existing match rather than creating one.
414
+ for (const [text, weight] of haystacks) {
415
+ const blob = haystack(text);
416
+ if (!blob)
417
+ continue;
418
+ for (let i = 0; i + 1 < wanted.length; i += 1) {
419
+ if (blob.includes(`${wanted[i][0]} ${wanted[i + 1][0]}`)) {
420
+ ranked += weight * 0.5 * (weights[i] + weights[i + 1]);
421
+ }
422
+ }
423
+ }
424
+ const matched = ranked;
425
+ // THE ENDPOINT IS THAT THING, not merely scoped by it.
426
+ //
427
+ // A REST path mixes the resources it is SCOPED BY with the one it ADDRESSES. `projects` is
428
+ // in 156 of tm's 173 paths but is the terminal segment in 3, so whole-corpus rarity —
429
+ // correctly — values it near nothing, and every project-scoped listing scored the same as
430
+ // the projects listing itself. The top 8 for "list all projects" spanned 15.9 to 14.8,
431
+ // where +8 collection and +6 mode already account for 14: the term signal was ~1 point of
432
+ // noise and the right answer sat 7th.
433
+ //
434
+ // Flat, and deliberately NOT rarity-scaled. Rarity would reintroduce the same problem in
435
+ // reverse — a rare scope noun outranking the real target, which is exactly how a weighted
436
+ // version of this put `/projects/{id}/folders` above `/folder/{id}/test-cases` for "tc list
437
+ // for a folder". This asks one yes/no question instead: is the caller's own word the last
438
+ // thing in the path?
439
+ //
440
+ // Equality is against the QUERY's forms, never the haystack's — the same one-directional
441
+ // rule as containment. `projects` is in forms("projects"), so the projects listing hits;
442
+ // `folders` is not in forms("folder"), so a folder-scoped query does not drag in the
443
+ // folders listing.
444
+ const tail = terms(resourceText(capability));
445
+ if (tail.length &&
446
+ wanted.some((forms) => tail.every((word) => forms.includes(word)))) {
447
+ ranked += RESOURCE_BONUS;
448
+ }
449
+ if (hint)
450
+ ranked += satisfiesHint(capability.mode, hint) ? 6 : -MODE_PENALTY;
451
+ if (plural)
452
+ ranked += isCollection(capability) ? CARDINALITY : -CARDINALITY;
453
+ return { matched, ranked };
454
+ }
455
+ /**
456
+ * How much of a perfect match the best hit actually achieved, below which we doubt it.
457
+ *
458
+ * A RATIO, because the raw score is corpus-relative and an absolute threshold therefore
459
+ * cannot travel. `rarity` is measured over whatever is being searched, so the same query
460
+ * scores differently depending on scope: "list my load tests" tops out at 1.9 against Load
461
+ * Testing alone and 10.1 against both products. The old absolute cut of 3 called the first
462
+ * of those weak and the second strong — the same query, the same right answer, opposite
463
+ * verdicts, with the smaller corpus penalised precisely because it is smaller.
464
+ *
465
+ * Both halves of this ratio move with the corpus, so it does not:
466
+ *
467
+ * coverage = top_matched / (identity weight x Σ rarity of the query's terms)
468
+ *
469
+ * — "what fraction of a perfect identity match did the best hit manage". Above 1 is
470
+ * ordinary, since a good hit matches several fields, not just the route.
471
+ *
472
+ * Measured across tm-only, LT-only and both: genuine misses land at 0.00–0.18, queries
473
+ * that work at 0.44–1.94. The absolute score cannot separate those (misses reach 2.9,
474
+ * good queries drop to 1.9); this does, with the gap in the same place in all three
475
+ * corpora, which is the property that was missing.
476
+ *
477
+ * RECALIBRATED AT v1.14, and the reason matters more than the number. The ratio is
478
+ * corpus-relative and still is — "make a new bucket for my tests" scores 0.259 against tm
479
+ * alone and 0.258 against both products, which is the property this was built for. What
480
+ * moved is neither corpus size nor query: it is how much TEXT each capability carries.
481
+ * v1.13 gave all 242 entries guidance and tripled the length of `intent`, so the numerator
482
+ * — the best hit's term score — roughly doubled, while the denominator depends only on the
483
+ * rarity of the query's own terms and did not. Both bands shifted up together: genuine
484
+ * misses now land at 0.00–0.46 and working queries at 0.24–4.16.
485
+ *
486
+ * At the old 0.25 the hand-off had silently stopped firing — "bucket", which is nobody's
487
+ * word for a folder, came in at 0.259 and read as a confident answer. Re-measured over 173
488
+ * served eval cases against five queries the product has no words for, 0.50 catches 5 of 5
489
+ * while flagging 3 of 173 served queries (1.7%), which is the same trade the original cut
490
+ * made. Expect to move it again the next time the artifact's text volume changes; the
491
+ * threshold tracks prose per capability, not the number of capabilities.
492
+ */
493
+ const WEAK_COVERAGE = 0.5;
494
+ /** The heaviest field, and so the yardstick a perfect match is measured against. */
495
+ const IDENTITY_WEIGHT = 6;
496
+ // THE CROSS-PRODUCT FLOOR WAS HERE, and is gone with the thing that needed it.
497
+ //
498
+ // It reserved two slots per matching product so a small product could not be shut out by
499
+ // a large one — tm has 173 capabilities to Load Testing's 20, and on a word both share
500
+ // ("test", "config", "tag") tm took the whole page. "add a tag to xyz test" returned
501
+ // tm tm LT tm tm, and an agent reading the first row went to the wrong product.
502
+ //
503
+ // `product` is now REQUIRED on searchCapability, so a search never spans products and the
504
+ // floor could never fire. The bug it fixed is fixed harder: the agent chooses the product
505
+ // deliberately against listProducts, or asks the user, instead of inferring it from which
506
+ // row happened to rank first. Keeping the code would mean carrying a branch nothing
507
+ // reaches and tests asserting behaviour the surface cannot produce.
508
+ //
509
+ // If searching across products ever returns — a "search everywhere" mode, or a caller
510
+ // that legitimately cannot know the product — this comes back with it. It is in the
511
+ // history at 617b4b9, and the reasoning above is why it was right.
512
+ /** Which products a query could plausibly be about, when more than one could. */
513
+ /** Words too common in this domain to settle a product on their own. */
514
+ const GENERIC_ANCHORS = new Set(["test", "tests", "testing"]);
515
+ /**
516
+ * Products whose OWN vocabulary the query hits, when no word in the query settles it.
517
+ *
518
+ * Requiring `product` made every CALL unambiguous and did nothing about the agent making
519
+ * two of them: "list all projects" was answered by searching tm, then Load Testing, then
520
+ * merging — a question about which product the user meant, answered by guessing both.
521
+ * Tool-description prose asking the agent to check with the user is a suggestion it is
522
+ * free to decline, and declining is cheaper than interrupting someone.
523
+ *
524
+ * WHOLE VOCABULARY ENTRIES, never their constituent words. Splitting them makes almost
525
+ * everything look shared: Load Testing's "load test" and tm's "test case" both yield
526
+ * `test`, which flagged 135 of the 198 eval queries. Matching entries whole flags 27.
527
+ *
528
+ * A UNIQUE TERM SETTLES IT. "create a test case in a folder" contains `folder`, which
529
+ * both products claim, and `test case`, which only tm does — so the query has already
530
+ * answered the question and there is nothing to ask. Only queries whose every recognised
531
+ * word is shared are genuinely undecided.
532
+ */
533
+ export function ambiguousProducts(products, query) {
534
+ const none = { products: [], terms: [] };
535
+ if (terms(query).length === 0)
536
+ return none;
537
+ /**
538
+ * One spelling per word, so a plural cannot masquerade as a different term.
539
+ *
540
+ * Both halves need it. On the QUERY side, the entry is `project` and the user types
541
+ * "list all projects" — the reported case, which exact containment missed. On the
542
+ * VOCABULARY side the same fold is what keeps the answer honest: tm lists both `report`
543
+ * and `reports` as aliases, and treating them as two entries made `reports` look
544
+ * tm-exclusive, so "show me the report" read as settled when both products claim it.
545
+ */
546
+ const fold = (text) => terms(text).map(singular).join(" ");
547
+ const asked_ = ` ${fold(query || "")} `;
548
+ const owners = new Map();
549
+ for (const [product, entries] of Object.entries(vocabularyOf(products))) {
550
+ for (const entry of entries) {
551
+ for (const word of [entry.entity, ...(entry.aliases ?? [])]) {
552
+ const key = fold(String(word));
553
+ if (!key)
554
+ continue;
555
+ if (!owners.has(key))
556
+ owners.set(key, new Set());
557
+ owners.get(key).add(product);
558
+ }
559
+ }
560
+ }
561
+ // WORDS A PRODUCT OWNS WITHOUT MODELLING THEM. `routing_terms` is how a product claims
562
+ // what its users call it, as opposed to what it contains, and unlike a summary it MAY
563
+ // introduce a term: that is the whole point of it, since the words most in need of
564
+ // routing — `flaky` — are the ones no index names an entity after.
565
+ for (const [product, index] of Object.entries(products)) {
566
+ for (const word of index.routing_terms ?? []) {
567
+ const key = fold(String(word));
568
+ if (!key)
569
+ continue;
570
+ if (!owners.has(key))
571
+ owners.set(key, new Set());
572
+ owners.get(key).add(product);
573
+ }
574
+ }
575
+ // THE SUMMARY CAN ADD A CLAIMANT, NEVER A TERM.
576
+ //
577
+ // A product that models `view` as an entity and one that only describes views in its
578
+ // summary both answer questions about views, but only the first was visible here, so
579
+ // "delete the saved view" read as settled. Letting the summary claim a word the other
580
+ // product already models catches that, and refusing to mine NEW terms out of it is what
581
+ // keeps the prose from taking over: a summary is a paragraph of verbs and nouns written
582
+ // to sell the product, and every word in it would otherwise become routing vocabulary.
583
+ const summaries = Object.fromEntries(Object.entries(products).map(([product, index]) => [
584
+ product,
585
+ ` ${fold(index.summary ?? "")} `,
586
+ ]));
587
+ for (const [key, claimants] of owners) {
588
+ for (const [product, text] of Object.entries(summaries)) {
589
+ if (text.includes(` ${key} `))
590
+ claimants.add(product);
591
+ }
592
+ }
593
+ const shared = new Map();
594
+ const decided = new Set();
595
+ const decidedBy = new Map();
596
+ for (const [key, claimants] of owners) {
597
+ if (!asked_.includes(` ${key} `))
598
+ continue;
599
+ // A word this domain uses in every other sentence settles nothing and clashes over
600
+ // nothing: `test` is an alias on one side and appears throughout both summaries, so
601
+ // counting it would make almost any request look like a clash.
602
+ if (GENERIC_ANCHORS.has(key))
603
+ continue;
604
+ if (claimants.size < 2) {
605
+ // A term only one product claims POINTS somewhere; whether it decides the question
606
+ // depends on what else is in the sentence. It used to end the check outright, and
607
+ // that is what made this gate almost silent: an agent's paraphrase nearly always
608
+ // carries some incidental one-product word — `upload`, `saved report` — so the
609
+ // shared noun the user actually said stopped being examined. Worse, a sentence
610
+ // carrying one-product words for BOTH products read as settled, which is the case
611
+ // most in need of asking.
612
+ if (!GENERIC_ANCHORS.has(key)) {
613
+ const owner = [...claimants][0];
614
+ decided.add(owner);
615
+ decidedBy.set(owner, [...(decidedBy.get(owner) ?? []), key]);
616
+ }
617
+ continue;
618
+ }
619
+ shared.set(key, claimants);
620
+ }
621
+ // NOTHING RECOGNISED IS THE MOST AMBIGUOUS CASE, NOT THE LEAST.
622
+ //
623
+ // "Can we ship this?", "What should we fix first?", "Show me the failed tests." — no
624
+ // word in them is any product's, so the shared-term check finds nothing and used to
625
+ // report the same calm verdict it gives a request that genuinely settles itself. These
626
+ // are the requests with the least to go on, and answering them for a product nobody
627
+ // chose is exactly the guess this whole check exists to stop.
628
+ if (shared.size === 0 && decided.size === 0)
629
+ return { ...none, unknown: true };
630
+ // Decided, with no shared word anywhere in it — "bulk delete test cases". There is
631
+ // nothing to ask about, and saying WHICH product it landed on matters as much here as
632
+ // it does below: a caller that only hears "no clash" can still search the other one.
633
+ if (shared.size === 0)
634
+ return decided.size === 1
635
+ ? {
636
+ ...none,
637
+ settled: [...decided][0],
638
+ because: (decidedBy.get([...decided][0]) ?? []).sort(),
639
+ }
640
+ : none;
641
+ // One product, and only one, is pointed at by the unshared words: the sentence answers
642
+ // itself and there is nothing to ask. It is still worth SAYING which one, because the
643
+ // caller sees only that nothing needs asking — "tell me about this project, including
644
+ // its settings" settles on the product that models settings, and an answer of silence
645
+ // let the other one be searched instead, which is the same wrong turn by a quieter route.
646
+ if (decided.size === 1)
647
+ return {
648
+ ...none,
649
+ settled: [...decided][0],
650
+ because: (decidedBy.get([...decided][0]) ?? []).sort(),
651
+ };
652
+ const claimed = new Set();
653
+ for (const claimants of shared.values())
654
+ for (const product of claimants)
655
+ claimed.add(product);
656
+ return { products: [...claimed].sort(), terms: [...shared.keys()].sort() };
657
+ }
658
+ /**
659
+ * The product's vocabulary, for a caller whose words are not the product's words.
660
+ *
661
+ * THE ONE FAILURE LEXICAL SEARCH CANNOT FIX. "make a new bucket for my tests" wants the
662
+ * folder-create capability, and `bucket` appears nowhere in the index — no scoring change
663
+ * reaches it, because the word is simply absent. What CAN reach it is the caller: it is a
664
+ * language model, and given tm's entity list it maps bucket -> folder without effort. It
665
+ * just cannot guess the list unprompted.
666
+ *
667
+ * Aliases and a one-line `description`; `title` stays dropped as a near-duplicate of
668
+ * `entity` ("Test run" next to `test_run` buys nothing). Aliases alone route but do not
669
+ * DEFINE — `result -> outcome, execution-result, test-result` never says whether that is
670
+ * the per-case verdict inside a run or a run-level rollup — so an agent holding names and
671
+ * aliases has exactly one way to find out, which is describeEntity once per entity. For
672
+ * tm that is 19 calls at ~1.4KB each, ~27KB, to answer what ~1.6KB of description answers
673
+ * here for every entity at once. The `key_facts` remain out: ten times the size, and the
674
+ * caller can ask describeEntity for the one entity it settles on.
675
+ *
676
+ * Its share of the response grew when search became a shortlist: 3.2KB against a 38KB full
677
+ * search was 8%, against a 6.8KB shortlist it is nearly half. The absolute cost did not
678
+ * change and is still under a thousand tokens — far less than one wrong invoke — so this
679
+ * is budgeted in bytes rather than as a fraction of a baseline that now moves.
680
+ */
681
+ export function vocabularyOf(products, only) {
682
+ const out = {};
683
+ for (const [name, bundle] of Object.entries(products)) {
684
+ if (only && name !== only)
685
+ continue;
686
+ const entries = [];
687
+ for (const [entity, doc] of Object.entries(bundle.entities || {})) {
688
+ const aliases = (doc.aliases || []);
689
+ const description = doc.description;
690
+ entries.push({
691
+ entity,
692
+ // Before the aliases, because it is what the reader needs first: what the thing
693
+ // is, then what else it is called.
694
+ ...(description ? { description } : {}),
695
+ ...(aliases.length ? { aliases } : {}),
696
+ });
697
+ }
698
+ if (entries.length)
699
+ out[name] = entries;
700
+ }
701
+ return out;
702
+ }
703
+ export function searchCapabilities(products, query, options = {}) {
704
+ const limit = options.limit && options.limit > 0 ? options.limit : 8;
705
+ const offset = options.offset && options.offset > 0 ? Math.floor(options.offset) : 0;
706
+ // BROWSE MODE: `*` lists the product instead of searching it.
707
+ //
708
+ // Scoring keeps only capabilities with matched > 0, so there was no query that returned
709
+ // everything — an agent that wanted to see what exists had to guess words until the
710
+ // shortlist looked complete, and could never tell when it was. `*` skips scoring
711
+ // entirely and pages the filtered set in a STABLE order (name, then path), because a
712
+ // ranked order is not a safe thing to paginate: ranking is query-dependent and ties
713
+ // break arbitrarily, so page 2 of a ranked list can repeat or skip rows. Browse is
714
+ // ordered by identity, so offset means the same thing on every call.
715
+ if (isBrowseQuery(query)) {
716
+ const all = Object.entries(products)
717
+ .filter(([name]) => !options.product || name === options.product)
718
+ .flatMap(([name, bundle]) => bundle.capabilities
719
+ .filter((capability) => (!options.entity || capability.entity === options.entity) &&
720
+ (!options.mode || capability.mode === options.mode))
721
+ .map((capability) => ({ product: name, capability })))
722
+ .sort((a, b) => (a.capability.name || "").localeCompare(b.capability.name || "") ||
723
+ a.capability.path.localeCompare(b.capability.path));
724
+ const page = all.slice(offset, offset + limit);
725
+ const end = offset + page.length;
726
+ return {
727
+ hits: page,
728
+ truncated: end < all.length,
729
+ total_matched: all.length,
730
+ top_matched: 0,
731
+ coverage: 1,
732
+ weak: false,
733
+ offset,
734
+ ...(end < all.length ? { next_offset: end } : {}),
735
+ browse: true,
736
+ };
737
+ }
738
+ // Forms are computed once per query, not per capability: 173 capabilities x 6 haystacks
739
+ // would otherwise rebuild the same handful of strings a thousand times.
740
+ const wanted = terms(query).map(termForms);
741
+ const hint = options.mode ? "" : modeHint(query);
742
+ const plural = wantsCollection(query);
743
+ const scored = [];
744
+ const searched = Object.entries(products).filter(([name]) => !options.product || name === options.product);
745
+ const aliasesByProduct = {};
746
+ for (const [name, bundle] of searched) {
747
+ const aliases = {};
748
+ for (const [entity, doc] of Object.entries(bundle.entities)) {
749
+ aliases[entity] = (doc.aliases || []);
750
+ }
751
+ aliasesByProduct[name] = aliases;
752
+ }
753
+ // ONE CORPUS ACROSS EVERY SEARCHED PRODUCT, not one per product.
754
+ //
755
+ // Measured per product, a term's rarity inverts across them: `load` appears in nearly
756
+ // every Load Testing capability, so it scored as noise there, while it appears in 16 of
757
+ // tm's 173 (`upload`, `download`, reached by containment), so it scored as gold there.
758
+ // The word that identifies a product was worth least inside it, and "list load tests"
759
+ // returned five tm results and no Load Testing ones at all.
760
+ //
761
+ // It is also measured against EVERY capability, not the entity- or mode-filtered subset:
762
+ // narrowing a search must not make a common word look rare.
763
+ const corpus = searched.flatMap(([name, bundle]) => bundle.capabilities.map((capability) => [
764
+ identityText(capability),
765
+ capability.entity,
766
+ (aliasesByProduct[name][capability.entity] || []).join(" "),
767
+ capability.intent || "",
768
+ (capability.returns || []).join(" "),
769
+ parameterText(capability),
770
+ ]
771
+ .map(haystack)
772
+ .join(" ")));
773
+ const weights = wanted.map((forms) => rarity(corpus, forms));
774
+ for (const [name, bundle] of searched) {
775
+ const aliases = aliasesByProduct[name];
776
+ for (const capability of bundle.capabilities) {
777
+ if (options.entity && capability.entity !== options.entity)
778
+ continue;
779
+ if (options.mode && capability.mode !== options.mode)
780
+ continue;
781
+ const { matched, ranked } = score(capability, wanted, weights, aliases, hint, plural);
782
+ if (matched > 0)
783
+ scored.push({ matched, ranked, product: name, capability });
784
+ }
785
+ }
786
+ scored.sort((a, b) => b.ranked - a.ranked || a.capability.path.localeCompare(b.capability.path));
787
+ // Ranked order decides the page; the best TERM score decides confidence. They are
788
+ // different questions: the mode and cardinality constants can lift a weakly-matched
789
+ // capability to the top of a page that is entirely wrong.
790
+ const topMatched = scored.reduce((best, s) => Math.max(best, s.matched), 0);
791
+ // What a perfect identity match would have scored for THIS query in THIS corpus. Both
792
+ // this and `topMatched` scale with the corpus, so their ratio is comparable across
793
+ // products and across `product`/`entity` narrowing — which the raw score is not.
794
+ const perfect = weights.reduce((sum, w) => sum + IDENTITY_WEIGHT * w, 0);
795
+ const coverage = perfect > 0 ? topMatched / perfect : 1;
796
+ const page = scored.slice(offset, offset + limit);
797
+ const end = offset + page.length;
798
+ return {
799
+ hits: page.map(({ product, capability }) => ({ product, capability })),
800
+ truncated: end < scored.length,
801
+ total_matched: scored.length,
802
+ offset,
803
+ ...(end < scored.length ? { next_offset: end } : {}),
804
+ top_matched: topMatched,
805
+ coverage,
806
+ weak: wanted.length > 0 && perfect > 0 && coverage < WEAK_COVERAGE,
807
+ };
808
+ }
809
+ /**
810
+ * Which products the USER'S OWN WORDS name, directly or through vocabulary only one
811
+ * product claims.
812
+ *
813
+ * This is the other half of `ambiguousProducts`. That one asks "is the query a word both
814
+ * products use?"; this asks "did the user say anything that settles it?" — and it reads
815
+ * the user's text, not a flag the caller sets about itself. A boolean saying the user
816
+ * confirmed is answered by whoever wants to proceed; a quote has to come from the
817
+ * conversation, and the words in it are checked against the index here.
818
+ *
819
+ * Anchors are of two kinds and both are needed. A product NAME ("test management",
820
+ * "observability") is the explicit case. A word only one product's vocabulary claims
821
+ * ("quality gate", "test plan") is the implicit one, and it is the common one: users say
822
+ * what they want, not which product owns it.
823
+ */
824
+ export function productAnchors(products, text) {
825
+ const none = { products: [], terms: [] };
826
+ if (terms(text).length === 0)
827
+ return none;
828
+ const fold = (s) => terms(s).map(singular).join(" ");
829
+ const said = ` ${fold(text || "")} `;
830
+ const found = new Map();
831
+ const claim = (product, term) => {
832
+ if (!found.has(product))
833
+ found.set(product, new Set());
834
+ found.get(product).add(term);
835
+ };
836
+ // The product's own name and key, as a user would type them. `summary` is prose, not a
837
+ // name, so only the leading clause before ':' is used — "BrowserStack Test Management",
838
+ // "BrowserStack Test Reporting & Analytics (TRA, aka Test Observability)".
839
+ for (const [product, index] of Object.entries(products)) {
840
+ const names = [
841
+ product,
842
+ ...(index.summary ?? "").split(":")[0].split(/[(),]/),
843
+ ];
844
+ for (const name of names) {
845
+ // "aka Test Observability" is the product's other name with a connective in front
846
+ // of it; the user types the name, not the connective.
847
+ const key = fold(name.replace(/^\s*(browserstack|aka|formerly)\s+/i, ""));
848
+ if (key.length < 2)
849
+ continue;
850
+ if (said.includes(` ${key} `))
851
+ claim(product, key);
852
+ }
853
+ }
854
+ // Vocabulary only one product claims. Shared words settle nothing and are skipped —
855
+ // that is exactly what `ambiguousProducts` flagged in the first place.
856
+ const owners = new Map();
857
+ for (const [product, entries] of Object.entries(vocabularyOf(products))) {
858
+ for (const entry of entries) {
859
+ for (const word of [entry.entity, ...(entry.aliases ?? [])]) {
860
+ const key = fold(String(word));
861
+ if (!key)
862
+ continue;
863
+ if (!owners.has(key))
864
+ owners.set(key, new Set());
865
+ owners.get(key).add(product);
866
+ }
867
+ }
868
+ }
869
+ // The same words `ambiguousProducts` routes on, so a term that settles the question
870
+ // there also anchors the product here — otherwise `flaky` names a product in one half
871
+ // of the check and nothing at all in the other.
872
+ for (const [product, index] of Object.entries(products)) {
873
+ for (const word of index.routing_terms ?? []) {
874
+ const key = fold(String(word));
875
+ if (!key)
876
+ continue;
877
+ if (!owners.has(key))
878
+ owners.set(key, new Set());
879
+ owners.get(key).add(product);
880
+ }
881
+ }
882
+ const matched = [];
883
+ for (const [key, claimants] of owners) {
884
+ if (claimants.size !== 1)
885
+ continue;
886
+ if (!said.includes(` ${key} `))
887
+ continue;
888
+ matched.push({ key, product: [...claimants][0] });
889
+ }
890
+ for (const { key, product } of matched) {
891
+ // A word carried by nearly every request in this domain anchors nothing: tra's
892
+ // sp_test lists the bare word `test` as an alias, which would make "what test plans
893
+ // are active" read as tra on the strength of one noun that means nothing on its own.
894
+ if (GENERIC_ANCHORS.has(key))
895
+ continue;
896
+ // Longest match wins. "test management" and "test" both match the same sentence, and
897
+ // only the longer one carries the user's meaning — counting both makes a settled
898
+ // question look ambiguous again.
899
+ if (matched.some((other) => other.key !== key && ` ${other.key} `.includes(` ${key} `)))
900
+ continue;
901
+ claim(product, key);
902
+ }
903
+ return {
904
+ products: [...found.keys()].sort(),
905
+ terms: [...new Set([...found.values()].flatMap((s) => [...s]))].sort(),
906
+ };
907
+ }