@browserstack/mcp-server 1.5.1-beta.2 → 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,38 @@
1
+ /**
2
+ * Invoke one endpoint and hand back what the product said.
3
+ *
4
+ * NO POST-PROCESSING, BY DECISION. There used to be row extraction by shape, a `returns`
5
+ * allowlist, a scalars-only filter for undeclared schemas, item counting, ordering, trimming,
6
+ * and guards that reported an empty projection as a registration defect. Every one of them
7
+ * was a place where we could be wrong ABOUT a correct answer — and each time we were, the
8
+ * caller saw a confident empty result rather than an error. The product's response is the
9
+ * answer; this module's job is to get it and return it.
10
+ *
11
+ * ONE REQUEST, ONE RESPONSE. Paging is therefore the caller's, which is why `p` and the
12
+ * page-size parameter are published for paginated endpoints (see `project.py::_is_public`).
13
+ * Hiding them made sense only while this module walked the pages itself.
14
+ */
15
+ import { GroupedArguments } from "./bind.js";
16
+ import { Credentials, Transport } from "./egress.js";
17
+ import { AuthScheme } from "./types.js";
18
+ import { Capability } from "./types.js";
19
+ export interface InvokeResult {
20
+ /** The product answered 2xx. Nothing else decides this. */
21
+ ok: boolean;
22
+ /**
23
+ * Whether this response is the whole answer.
24
+ *
25
+ * False when the envelope itself says there is another page (`info.next`), so a caller
26
+ * knows to ask for one rather than assuming it has everything. This is a peek at one
27
+ * declared field, not a reshaping of the body.
28
+ */
29
+ completed: boolean;
30
+ /** The product's status, and its body exactly as sent. */
31
+ http_response: {
32
+ status: number;
33
+ body: unknown;
34
+ /** Only when there was no response at all to speak for itself. */
35
+ error?: string;
36
+ };
37
+ }
38
+ export declare function invoke(capability: Capability, args: GroupedArguments, baseUrl: string, credentials: Credentials, transport: Transport, auth?: AuthScheme, product?: string): Promise<InvokeResult>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Invoke one endpoint and hand back what the product said.
3
+ *
4
+ * NO POST-PROCESSING, BY DECISION. There used to be row extraction by shape, a `returns`
5
+ * allowlist, a scalars-only filter for undeclared schemas, item counting, ordering, trimming,
6
+ * and guards that reported an empty projection as a registration defect. Every one of them
7
+ * was a place where we could be wrong ABOUT a correct answer — and each time we were, the
8
+ * caller saw a confident empty result rather than an error. The product's response is the
9
+ * answer; this module's job is to get it and return it.
10
+ *
11
+ * ONE REQUEST, ONE RESPONSE. Paging is therefore the caller's, which is why `p` and the
12
+ * page-size parameter are published for paginated endpoints (see `project.py::_is_public`).
13
+ * Hiding them made sense only while this module walked the pages itself.
14
+ */
15
+ import { invalidateToken } from "../../lib/central-oauth.js";
16
+ import { bind } from "./bind.js";
17
+ import { authHeaders } from "./egress.js";
18
+ import { resolveScope, resolveTokenUrl } from "./oauth.js";
19
+ import { InvocationError } from "./index-loader.js";
20
+ function hasNextPage(body) {
21
+ if (typeof body !== "object" || body === null || Array.isArray(body))
22
+ return false;
23
+ const info = body.info;
24
+ if (typeof info !== "object" || info === null)
25
+ return false;
26
+ const next = info.next;
27
+ return next !== null && next !== undefined && next !== false;
28
+ }
29
+ /**
30
+ * Refuse to send credentials anywhere but over TLS.
31
+ *
32
+ * The very next thing `invoke` does is attach the caller's long-lived
33
+ * `username:access_key` to the request, and the host it attaches them to comes from an
34
+ * environment variable, account discovery or the index's own `base_url` — none of which
35
+ * was checked for a scheme. An operator typo of `http://` put those credentials on the
36
+ * wire in clear text, and nothing anywhere said no.
37
+ *
38
+ * Not attacker-controlled today: the index ships inside the package and the environment
39
+ * variables are operator-set. This is the cheap guard on the one path that forwards a
40
+ * credential, not a response to a known attack. `egress.authHeaders` already refuses
41
+ * `auth.in: "query"` so credentials never land in a URL; this is the same concern one
42
+ * layer up.
43
+ *
44
+ * `localhost` and `127.0.0.1` are exempt so a local product server can still be driven
45
+ * without a certificate.
46
+ */
47
+ function assertTransportSafe(baseUrl) {
48
+ let url;
49
+ try {
50
+ url = new URL(baseUrl);
51
+ }
52
+ catch {
53
+ throw new InvocationError(`the configured base URL is not a valid URL: ${JSON.stringify(baseUrl)}`);
54
+ }
55
+ if (url.protocol === "https:")
56
+ return;
57
+ const local = url.hostname === "localhost" ||
58
+ url.hostname === "127.0.0.1" ||
59
+ url.hostname === "[::1]";
60
+ if (url.protocol === "http:" && local)
61
+ return;
62
+ throw new InvocationError(`refusing to send credentials over ${url.protocol}//${url.host}: this request ` +
63
+ `carries your BrowserStack access key, so the base URL must be https (or a local ` +
64
+ `host for development). Check CAPABILITY_REGISTRY_BASE_URL_* for a typo.`);
65
+ }
66
+ export async function invoke(capability, args, baseUrl, credentials, transport, auth, product) {
67
+ if (!baseUrl)
68
+ throw new InvocationError("no base URL is configured for that product");
69
+ assertTransportSafe(baseUrl);
70
+ const bound = bind(capability, args);
71
+ // Before binding would be wrong and after sending is too late: a scheme that mints a
72
+ // token must not spend a round trip on a call that `bind` was going to reject anyway.
73
+ const url = `${baseUrl.replace(/\/$/, "")}${bound.path}`;
74
+ const send = async () => transport(capability.method, url, await authHeaders(credentials, auth, product), bound.query, bound.body);
75
+ let response = await send();
76
+ /**
77
+ * One replay, for a minted token the product rejected.
78
+ *
79
+ * The expiry skirt covers clock skew; it does not cover a token revoked mid-life, and a
80
+ * cached one can outlive its welcome by up to its whole lifetime. Discarding it and
81
+ * retrying once turns that from a failed call into a slow one.
82
+ *
83
+ * SAFE TO REPLAY EVEN FOR A WRITE: a 401 means the request was never authorised, so
84
+ * nothing was changed. Once only — with genuinely bad credentials the second attempt
85
+ * fails identically, and a loop here would hammer the token endpoint on every wrong
86
+ * password.
87
+ */
88
+ if (response.status === 401 && auth?.type === "oauth2") {
89
+ invalidateToken(resolveTokenUrl(product ?? "this product", auth), resolveScope(auth), credentials);
90
+ response = await send();
91
+ }
92
+ const ok = response.status >= 200 && response.status < 300;
93
+ return {
94
+ ok,
95
+ completed: ok && !hasNextPage(response.body),
96
+ http_response: {
97
+ status: response.status,
98
+ body: response.body,
99
+ ...(response.status === 0
100
+ ? { error: response.error || "the product could not be reached" }
101
+ : {}),
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,25 @@
1
+ /** What a routing call concluded about the user's request. */
2
+ export interface RoutingVerdict {
3
+ /** `clash`: several products claim it. `blank`: no product's words at all. `settled`: one. */
4
+ verdict: "clash" | "blank" | "settled";
5
+ /** The product it settled on, for `settled`. */
6
+ product?: string;
7
+ /** The shared words, for `clash` — what the user has to be asked about. */
8
+ terms?: string[];
9
+ /** Issued-at, epoch seconds. */
10
+ iat: number;
11
+ }
12
+ /** Tokens outlive a turn, not a sitting. Long enough to answer a question, not to bank. */
13
+ export declare const TOKEN_TTL_SECONDS: number;
14
+ /** Sign a verdict into an opaque token the caller hands back. */
15
+ export declare function signRouting(verdict: Omit<RoutingVerdict, "iat"> & {
16
+ iat?: number;
17
+ }): string;
18
+ /**
19
+ * Read a token back, or `undefined` if it was not issued here, was edited, or has expired.
20
+ *
21
+ * Every failure returns the same nothing on purpose: a caller learning WHY its token was
22
+ * rejected learns how to build a better one, and there is no legitimate caller that needs
23
+ * to know the difference.
24
+ */
25
+ export declare function readRouting(token: string | undefined, now?: number): RoutingVerdict | undefined;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The routing step's verdict, signed so it can be handed back instead of remembered.
3
+ *
4
+ * The gate needs to know what the LAST routing call concluded — whether the request
5
+ * clashed, or settled, and on what. That is a fact about the conversation, and a tool call
6
+ * carries no conversation, so the server kept it: a set of unspent tokens and the listing's
7
+ * last verdict, both living for the life of the connection. Under stdio that is one process
8
+ * per client and harmless; it stops being harmless the moment one process serves several
9
+ * sessions, where a single shared `settledByListing` lets one client's listing satisfy
10
+ * another client's gate.
11
+ *
12
+ * So the verdict travels with the caller instead. It is signed with a per-process secret,
13
+ * which is what keeps it honest: the caller cannot mint one, cannot edit the product inside
14
+ * it, and cannot produce one at all without having made the routing call that issues it.
15
+ * Verification is recomputation — there is nothing to store, nothing to grow, and nothing
16
+ * to leak between sessions.
17
+ *
18
+ * WHAT THIS CANNOT DO, stated plainly: it proves a routing call happened and what it said.
19
+ * It cannot prove a human answered. Nothing a caller transmits can. A caller that reworded
20
+ * its request and ran discovery again on the new words holds a genuine token for a verdict
21
+ * it manufactured — closing THAT needs memory of the first question, which is the thing
22
+ * being removed here. `user_words`, checked against the index, remains the part that
23
+ * carries what the user actually said.
24
+ */
25
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
26
+ /**
27
+ * Per process, and deliberately not persisted.
28
+ *
29
+ * A restart invalidates every outstanding token, which is the right behaviour: a new
30
+ * process is a new conversation, and a verdict from the last one says nothing about this
31
+ * one. It also means nothing has to be configured to run the server.
32
+ */
33
+ const SECRET = randomBytes(32);
34
+ /** Tokens outlive a turn, not a sitting. Long enough to answer a question, not to bank. */
35
+ export const TOKEN_TTL_SECONDS = 60 * 30;
36
+ const b64 = (b) => b.toString("base64url");
37
+ function seal(payload) {
38
+ return createHmac("sha256", SECRET).update(payload).digest("base64url");
39
+ }
40
+ /** Sign a verdict into an opaque token the caller hands back. */
41
+ export function signRouting(verdict) {
42
+ const body = {
43
+ ...verdict,
44
+ iat: verdict.iat ?? Math.floor(Date.now() / 1000),
45
+ };
46
+ const payload = b64(Buffer.from(JSON.stringify(body)));
47
+ return `${payload}.${seal(payload)}`;
48
+ }
49
+ /**
50
+ * Read a token back, or `undefined` if it was not issued here, was edited, or has expired.
51
+ *
52
+ * Every failure returns the same nothing on purpose: a caller learning WHY its token was
53
+ * rejected learns how to build a better one, and there is no legitimate caller that needs
54
+ * to know the difference.
55
+ */
56
+ export function readRouting(token, now = Math.floor(Date.now() / 1000)) {
57
+ if (!token)
58
+ return undefined;
59
+ const cut = token.lastIndexOf(".");
60
+ if (cut <= 0)
61
+ return undefined;
62
+ const payload = token.slice(0, cut);
63
+ const signature = token.slice(cut + 1);
64
+ const expected = Buffer.from(seal(payload));
65
+ const given = Buffer.from(signature);
66
+ // Length has to match before timingSafeEqual will look at the bytes, and comparing
67
+ // lengths first leaks only the length, which the caller already knows.
68
+ if (expected.length !== given.length)
69
+ return undefined;
70
+ if (!timingSafeEqual(expected, given))
71
+ return undefined;
72
+ try {
73
+ const body = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
74
+ if (typeof body?.iat !== "number")
75
+ return undefined;
76
+ if (now - body.iat > TOKEN_TTL_SECONDS)
77
+ return undefined;
78
+ if (body.verdict !== "clash" &&
79
+ body.verdict !== "blank" &&
80
+ body.verdict !== "settled")
81
+ return undefined;
82
+ return body;
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
@@ -0,0 +1,197 @@
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
+ import { Capability, Mode, ProductIndex } from "./types.js";
15
+ /**
16
+ * Query/haystack terms. Verbs are deliberately NOT stopwords — they carry the intent.
17
+ *
18
+ * camelCase is split before lowercasing, so `testRunId`, `test_run_id` and `test run id`
19
+ * all tokenize alike.
20
+ */
21
+ /**
22
+ * Is this the browse wildcard rather than a search?
23
+ *
24
+ * Accepts `*` and `all` — both are what a caller reaches for when the intent is "show me
25
+ * everything", and treating only one of them as the wildcard means the other silently
26
+ * becomes a bad search. Whitespace is tolerated so a pasted value still works.
27
+ */
28
+ export declare function isBrowseQuery(query?: string): boolean;
29
+ export declare function terms(text: string | undefined): string[];
30
+ /**
31
+ * A term plus its naive singular variants.
32
+ *
33
+ * QUERY SIDE ONLY, which is what makes this cheap and safe. Matching is one-directional
34
+ * substring containment, so indexed `attachments` already contains a query of `attachment`;
35
+ * only the reverse — a plural query against singular text — needs help. Stemming the
36
+ * indexed side too would mean rewriting the product's own vocabulary to guess at English,
37
+ * for no additional match.
38
+ */
39
+ export declare function termForms(term: string): string[];
40
+ /**
41
+ * ONE canonical spelling of a word, so two spellings of a noun cannot look like two terms.
42
+ *
43
+ * `termForms` is deliberately generous — it offers every candidate and lets substring
44
+ * containment sort them out. Canonicalising needs the opposite: exactly one answer, and
45
+ * the right one. Taking the SHORTEST candidate is what a first cut did, and it folded
46
+ * `cases` to `cas`, so the vocabulary entry `test case` matched no query containing "test
47
+ * cases" — the phrase it exists for. The ambiguity check then fell through to `test`
48
+ * alone, reaching the right verdict by the wrong route.
49
+ *
50
+ * Strip `es` only after a sibilant, where English actually inserts it (`boxes`,
51
+ * `batches`, `statuses`). Otherwise strip the single `s`, which is right for the `-e`
52
+ * plurals this vocabulary is full of: case, suite, phase, template.
53
+ */
54
+ export declare function singular(word: string): string;
55
+ export declare function modeHint(query: string | undefined): "" | Mode;
56
+ export declare function wantsCollection(query: string | undefined): boolean;
57
+ /**
58
+ * True when a capability answers with many records rather than one.
59
+ *
60
+ * Pagination is the reliable signal — a paged operation is a listing by construction. The
61
+ * plural terminal path segment is a weaker fallback for unpaged collections. (The Python
62
+ * side used the capability NAME here. tm now publishes one, but its verbs are not
63
+ * consistent — the endpoint that lists folders is `list_root_folders` — so the path's
64
+ * terminal noun remains the better signal.)
65
+ */
66
+ export declare function isCollection(capability: Capability): boolean;
67
+ /**
68
+ * One ranked match, WITH the product it belongs to.
69
+ *
70
+ * Attribution is not decoration: the response tables are per product, so dereferencing a
71
+ * hit's schemas needs to know whose tables to read. It is also what lets a caller pass
72
+ * `product` to invokeCapability when two products share an endpoint — until now search
73
+ * ranked across products and then threw away the only thing that could disambiguate them.
74
+ */
75
+ export interface SearchHit {
76
+ product: string;
77
+ capability: Capability;
78
+ }
79
+ export interface SearchResult {
80
+ hits: SearchHit[];
81
+ truncated: boolean;
82
+ total_matched: number;
83
+ /**
84
+ * Where this page started, and where the next one begins.
85
+ *
86
+ * `truncated` has always said more matched than fit; nothing said how to reach it, so
87
+ * the only way past the first page was to raise `limit` and re-read everything already
88
+ * seen. `next_offset` is absent on the last page, which is what makes "walk until it
89
+ * stops" terminate without the caller comparing counts.
90
+ */
91
+ offset: number;
92
+ next_offset?: number;
93
+ /** True when the query was the browse wildcard rather than a search. */
94
+ browse?: boolean;
95
+ /**
96
+ * The best pre-penalty term score, and whether it is weak enough to doubt.
97
+ *
98
+ * A vocabulary miss does NOT look like a miss from the outside: "make a new bucket for my
99
+ * tests" returns a full page of eight confident hits, exactly like a query that worked,
100
+ * because containment finds *something* for `make`, `new` and `tests`. The only thing that
101
+ * separates them is how little the match is worth.
102
+ */
103
+ top_matched: number;
104
+ /** `top_matched` as a fraction of a perfect match — the corpus-independent form. */
105
+ coverage: number;
106
+ weak: boolean;
107
+ }
108
+ export interface ProductAmbiguity {
109
+ /** The products that claim the query's words. Empty when the query settles itself. */
110
+ products: string[];
111
+ /** The shared vocabulary that caused it — what to put in front of the user. */
112
+ terms: string[];
113
+ /** The one product the query's own words point at, when they point at exactly one. */
114
+ settled?: string;
115
+ /** The words that pointed there — the evidence for `settled`. */
116
+ because?: string[];
117
+ /** No word in the request belongs to ANY product: there is nothing to route on. */
118
+ unknown?: boolean;
119
+ }
120
+ /**
121
+ * Products whose OWN vocabulary the query hits, when no word in the query settles it.
122
+ *
123
+ * Requiring `product` made every CALL unambiguous and did nothing about the agent making
124
+ * two of them: "list all projects" was answered by searching tm, then Load Testing, then
125
+ * merging — a question about which product the user meant, answered by guessing both.
126
+ * Tool-description prose asking the agent to check with the user is a suggestion it is
127
+ * free to decline, and declining is cheaper than interrupting someone.
128
+ *
129
+ * WHOLE VOCABULARY ENTRIES, never their constituent words. Splitting them makes almost
130
+ * everything look shared: Load Testing's "load test" and tm's "test case" both yield
131
+ * `test`, which flagged 135 of the 198 eval queries. Matching entries whole flags 27.
132
+ *
133
+ * A UNIQUE TERM SETTLES IT. "create a test case in a folder" contains `folder`, which
134
+ * both products claim, and `test case`, which only tm does — so the query has already
135
+ * answered the question and there is nothing to ask. Only queries whose every recognised
136
+ * word is shared are genuinely undecided.
137
+ */
138
+ export declare function ambiguousProducts(products: Record<string, ProductIndex>, query: string | undefined): ProductAmbiguity;
139
+ /** One entity's caller-facing vocabulary: what it is called, and what else it is called. */
140
+ export interface VocabularyEntry {
141
+ entity: string;
142
+ description?: string;
143
+ aliases?: string[];
144
+ }
145
+ /**
146
+ * The product's vocabulary, for a caller whose words are not the product's words.
147
+ *
148
+ * THE ONE FAILURE LEXICAL SEARCH CANNOT FIX. "make a new bucket for my tests" wants the
149
+ * folder-create capability, and `bucket` appears nowhere in the index — no scoring change
150
+ * reaches it, because the word is simply absent. What CAN reach it is the caller: it is a
151
+ * language model, and given tm's entity list it maps bucket -> folder without effort. It
152
+ * just cannot guess the list unprompted.
153
+ *
154
+ * Aliases and a one-line `description`; `title` stays dropped as a near-duplicate of
155
+ * `entity` ("Test run" next to `test_run` buys nothing). Aliases alone route but do not
156
+ * DEFINE — `result -> outcome, execution-result, test-result` never says whether that is
157
+ * the per-case verdict inside a run or a run-level rollup — so an agent holding names and
158
+ * aliases has exactly one way to find out, which is describeEntity once per entity. For
159
+ * tm that is 19 calls at ~1.4KB each, ~27KB, to answer what ~1.6KB of description answers
160
+ * here for every entity at once. The `key_facts` remain out: ten times the size, and the
161
+ * caller can ask describeEntity for the one entity it settles on.
162
+ *
163
+ * Its share of the response grew when search became a shortlist: 3.2KB against a 38KB full
164
+ * search was 8%, against a 6.8KB shortlist it is nearly half. The absolute cost did not
165
+ * change and is still under a thousand tokens — far less than one wrong invoke — so this
166
+ * is budgeted in bytes rather than as a fraction of a baseline that now moves.
167
+ */
168
+ export declare function vocabularyOf(products: Record<string, ProductIndex>, only?: string): Record<string, VocabularyEntry[]>;
169
+ export declare function searchCapabilities(products: Record<string, ProductIndex>, query?: string, options?: {
170
+ entity?: string;
171
+ product?: string;
172
+ mode?: Mode;
173
+ limit?: number;
174
+ offset?: number;
175
+ }): SearchResult;
176
+ export interface ProductAnchor {
177
+ /** Products the quoted words point at, through a word only that product claims. */
178
+ products: string[];
179
+ /** The words that did the anchoring, so a refusal can quote them back. */
180
+ terms: string[];
181
+ }
182
+ /**
183
+ * Which products the USER'S OWN WORDS name, directly or through vocabulary only one
184
+ * product claims.
185
+ *
186
+ * This is the other half of `ambiguousProducts`. That one asks "is the query a word both
187
+ * products use?"; this asks "did the user say anything that settles it?" — and it reads
188
+ * the user's text, not a flag the caller sets about itself. A boolean saying the user
189
+ * confirmed is answered by whoever wants to proceed; a quote has to come from the
190
+ * conversation, and the words in it are checked against the index here.
191
+ *
192
+ * Anchors are of two kinds and both are needed. A product NAME ("test management",
193
+ * "observability") is the explicit case. A word only one product's vocabulary claims
194
+ * ("quality gate", "test plan") is the implicit one, and it is the common one: users say
195
+ * what they want, not which product owns it.
196
+ */
197
+ export declare function productAnchors(products: Record<string, ProductIndex>, text: string | undefined): ProductAnchor;