@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.
- package/README.md +54 -1
- package/capability/tm.capability-index.json +50420 -0
- package/capability/tra.capability-index.json +52096 -0
- package/dist/lib/central-oauth.d.ts +133 -0
- package/dist/lib/central-oauth.js +246 -0
- package/dist/lib/instrumentation.d.ts +14 -1
- package/dist/lib/instrumentation.js +8 -1
- package/dist/server-factory.js +5 -0
- package/dist/tools/ask-browserstack/central-oauth.d.ts +25 -46
- package/dist/tools/ask-browserstack/central-oauth.js +55 -177
- package/dist/tools/capability-registry/bind.d.ts +28 -0
- package/dist/tools/capability-registry/bind.js +407 -0
- package/dist/tools/capability-registry/config.d.ts +62 -0
- package/dist/tools/capability-registry/config.js +218 -0
- package/dist/tools/capability-registry/discovery.d.ts +44 -0
- package/dist/tools/capability-registry/discovery.js +101 -0
- package/dist/tools/capability-registry/egress.d.ts +51 -0
- package/dist/tools/capability-registry/egress.js +197 -0
- package/dist/tools/capability-registry/index-loader.d.ts +162 -0
- package/dist/tools/capability-registry/index-loader.js +523 -0
- package/dist/tools/capability-registry/oauth.d.ts +63 -0
- package/dist/tools/capability-registry/oauth.js +176 -0
- package/dist/tools/capability-registry/redact.d.ts +34 -0
- package/dist/tools/capability-registry/redact.js +98 -0
- package/dist/tools/capability-registry/register.d.ts +48 -0
- package/dist/tools/capability-registry/register.js +956 -0
- package/dist/tools/capability-registry/resolve.d.ts +38 -0
- package/dist/tools/capability-registry/resolve.js +104 -0
- package/dist/tools/capability-registry/routing-token.d.ts +25 -0
- package/dist/tools/capability-registry/routing-token.js +87 -0
- package/dist/tools/capability-registry/search.d.ts +197 -0
- package/dist/tools/capability-registry/search.js +907 -0
- package/dist/tools/capability-registry/types.d.ts +341 -0
- package/dist/tools/capability-registry/types.js +33 -0
- package/dist/tools/testmanagement.js +5 -5
- package/dist/tools/tool-handoff.d.ts +15 -12
- package/dist/tools/tool-handoff.js +21 -18
- 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;
|