@workos/quickstudy 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/adapters/echo.ts +2 -0
- package/src/adapters/oracle.ts +2 -0
- package/src/adapters/types.ts +24 -2
- package/src/build-info.generated.ts +1 -1
- package/src/evals/result.ts +1 -1
- package/src/evals/types.ts +9 -0
- package/src/execution-policy.ts +12 -0
- package/src/experiments/types.ts +1 -1
- package/src/report/aggregate.ts +4 -1
- package/src/report/report.ts +69 -18
- package/src/report/scorer-health.ts +35 -10
- package/src/runner/execute.ts +18 -5
- package/src/runtime/types.ts +13 -0
- package/src/store/db.ts +7 -0
- package/src/surface-usage.ts +246 -42
- package/ui/dist/index.html +8 -8
package/src/surface-usage.ts
CHANGED
|
@@ -3,10 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Availability is recorded in the experiment manifest; this module answers a
|
|
5
5
|
* different question: did the agent actually call an MCP tool, invoke the
|
|
6
|
-
* product CLI, or
|
|
7
|
-
* conservative and stores only redacted identifiers. Full shell
|
|
8
|
-
* URL query strings can contain credentials and must remain in
|
|
9
|
-
* raw transcript, never in aggregate telemetry.
|
|
6
|
+
* product CLI, or reach for the product documentation? Extraction is
|
|
7
|
+
* deliberately conservative and stores only redacted identifiers. Full shell
|
|
8
|
+
* commands and URL query strings can contain credentials and must remain in
|
|
9
|
+
* the protected raw transcript, never in aggregate telemetry.
|
|
10
|
+
*
|
|
11
|
+
* Docs evidence comes only from fetches (native web tools, shell fetchers
|
|
12
|
+
* such as curl) and from web searches that name the product. A docs URL
|
|
13
|
+
* inside a file the agent WROTE is not a page it opened and is never
|
|
14
|
+
* counted. `docs.count` is the number of docs events (every fetch and every
|
|
15
|
+
* product-naming search); `docs.urls` lists each fetched page once, so a
|
|
16
|
+
* count above the URL total comes from repeated fetches, searches, or both.
|
|
17
|
+
* A docs host may carry a path prefix (`acme.com/docs`) so API calls to the
|
|
18
|
+
* same domain do not count as reading.
|
|
10
19
|
*/
|
|
11
20
|
|
|
12
21
|
import { parseJsonlLines } from "./adapters/parse.ts";
|
|
@@ -20,8 +29,15 @@ export interface SurfaceUsage {
|
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
export interface SurfaceUsageConfig {
|
|
23
|
-
/** Exact host or parent domain
|
|
32
|
+
/** Exact host or parent domain (subdomains also match), optionally with a path prefix. */
|
|
24
33
|
docsHosts?: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* Words a web search must mention to count as a docs search. Optional:
|
|
36
|
+
* derived from the docs hosts' product labels when absent — set it when
|
|
37
|
+
* the docs live on a hosting domain (`acme.readthedocs.io`) or the product
|
|
38
|
+
* name is not in the hostname.
|
|
39
|
+
*/
|
|
40
|
+
searchTokens?: readonly string[];
|
|
25
41
|
/** Product CLI executable names, without arguments. */
|
|
26
42
|
cliCommands?: readonly string[];
|
|
27
43
|
/** MCP server names as configured for the attempt. */
|
|
@@ -57,6 +73,8 @@ function claudeInvocations(event: Record<string, unknown>): ToolInvocation[] {
|
|
|
57
73
|
...(typeof item["id"] === "string" ? { id: item["id"] } : {}),
|
|
58
74
|
name: item["name"],
|
|
59
75
|
input: item["input"],
|
|
76
|
+
...(/^web(fetch|search)$/i.test(item["name"]) ? { kind: "web" as const } : {}),
|
|
77
|
+
...(/^bash$/i.test(item["name"]) ? { kind: "command" as const } : {}),
|
|
60
78
|
}];
|
|
61
79
|
});
|
|
62
80
|
}
|
|
@@ -75,7 +93,10 @@ function codexInvocations(event: Record<string, unknown>): ToolInvocation[] {
|
|
|
75
93
|
return [{ ...identity, name: server === "" ? tool : `${server}__${tool}`, input: item["arguments"] ?? item["input"], kind: "mcp" }];
|
|
76
94
|
}
|
|
77
95
|
if (type === "web_search") {
|
|
78
|
-
|
|
96
|
+
// The started phase carries an empty query; only the completed item says
|
|
97
|
+
// what was searched. Keyed by the same id, so it still counts once.
|
|
98
|
+
if (event["type"] !== "item.completed") return [];
|
|
99
|
+
return [{ ...identity, name: "web_search", input: { query: item["query"], action: item["action"] }, kind: "web" }];
|
|
79
100
|
}
|
|
80
101
|
return [];
|
|
81
102
|
}
|
|
@@ -122,57 +143,227 @@ function allStrings(value: unknown, out: string[] = []): string[] {
|
|
|
122
143
|
|
|
123
144
|
const URL_PATTERN = /https?:\/\/[^\s"'<>)}\]]+/giu;
|
|
124
145
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
146
|
+
/**
|
|
147
|
+
* A configured documentation target: a host (subdomains match) with an
|
|
148
|
+
* optional path prefix — `docs.acme.test`, or `acme.com/docs` so that API
|
|
149
|
+
* traffic to `api.acme.com` is never mistaken for reading the docs.
|
|
150
|
+
*/
|
|
151
|
+
interface DocsTarget {
|
|
152
|
+
host: string;
|
|
153
|
+
pathPrefix: string | null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function docsTargets(hosts: readonly string[]): DocsTarget[] {
|
|
157
|
+
return hosts
|
|
158
|
+
.map((entry) => entry.trim().replace(/^https?:\/\//iu, "").replace(/^\.+/u, ""))
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.map((entry) => {
|
|
161
|
+
// Hostnames are case-insensitive; paths are not, so only the host is lowercased.
|
|
162
|
+
const slash = entry.indexOf("/");
|
|
163
|
+
const host = (slash === -1 ? entry : entry.slice(0, slash)).toLowerCase().replace(/\.+$/u, "");
|
|
164
|
+
const prefix = slash === -1 ? "" : entry.slice(slash).replace(/\/+$/u, "");
|
|
165
|
+
return { host, pathPrefix: prefix === "" ? null : prefix };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function matchesDocsTarget(url: URL, targets: readonly DocsTarget[]): boolean {
|
|
170
|
+
const host = url.hostname.toLowerCase();
|
|
171
|
+
return targets.some(
|
|
172
|
+
(target) =>
|
|
173
|
+
(host === target.host || host.endsWith(`.${target.host}`)) &&
|
|
174
|
+
(target.pathPrefix === null || url.pathname === target.pathPrefix || url.pathname.startsWith(`${target.pathPrefix}/`)),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Subdomain labels that name a kind of site, never the product. */
|
|
179
|
+
const SITE_LABELS = new Set(["www", "docs", "doc", "developer", "developers", "dev", "api", "app", "apps", "help", "support"]);
|
|
180
|
+
|
|
181
|
+
/** Second-level labels of multi-part public suffixes (`co.uk`, `com.au`, `ac.jp`). */
|
|
182
|
+
const SECOND_LEVEL_SUFFIXES = new Set(["co", "com", "org", "net", "ac", "gov", "edu", "ne", "or"]);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The product tokens a web-search query must mention to count as a docs
|
|
186
|
+
* search. Explicit `searchTokens` win; otherwise every host label that is
|
|
187
|
+
* neither a site label (`docs`, `www`), the TLD, nor a second-level suffix:
|
|
188
|
+
* `acme` from `docs.acme.com`, `acme.cloud` and `docs.acme.co.uk`; `acme`
|
|
189
|
+
* and `readthedocs` from `acme.readthedocs.io`. A TLD such as `cloud` is
|
|
190
|
+
* never a product name.
|
|
191
|
+
*/
|
|
192
|
+
function productTokens(targets: readonly DocsTarget[], explicit: readonly string[]): string[] {
|
|
193
|
+
const configured = explicit.map((token) => token.trim().toLowerCase()).filter((token) => token !== "");
|
|
194
|
+
if (configured.length > 0) return [...new Set(configured)];
|
|
195
|
+
const tokens = new Set<string>();
|
|
196
|
+
for (const target of targets) {
|
|
197
|
+
const labels = target.host.split(".").filter(Boolean);
|
|
198
|
+
while (labels.length > 1 && SITE_LABELS.has(labels[0]!)) labels.shift();
|
|
199
|
+
if (labels.length >= 2) labels.pop(); // the TLD
|
|
200
|
+
if (labels.length >= 2 && SECOND_LEVEL_SUFFIXES.has(labels[labels.length - 1]!)) labels.pop();
|
|
201
|
+
for (const label of labels) if (label.length >= 3) tokens.add(label);
|
|
202
|
+
}
|
|
203
|
+
return [...tokens];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Programs whose arguments are pages the agent opened. */
|
|
207
|
+
const FETCHERS = new Set(["curl", "wget", "xh", "http", "https", "httpie", "aria2c", "lynx", "w3m"]);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* URLs a shell command actually fetched: the arguments of a fetching program
|
|
211
|
+
* that is the leading executable of its pipeline segment. `echo "curl …"`,
|
|
212
|
+
* a heredoc body, or a file written through the shell is not a page the
|
|
213
|
+
* agent opened (heredoc bodies are dropped by shellSegments).
|
|
214
|
+
*/
|
|
215
|
+
function fetchedUrlsInCommand(command: string): string[] {
|
|
128
216
|
const urls: string[] = [];
|
|
129
|
-
for (const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (!configured.some((allowed) => host === allowed || host.endsWith(`.${allowed}`))) continue;
|
|
135
|
-
url.username = "";
|
|
136
|
-
url.password = "";
|
|
137
|
-
url.search = "";
|
|
138
|
-
url.hash = "";
|
|
139
|
-
const safe = url.toString();
|
|
140
|
-
if (!urls.includes(safe)) urls.push(safe);
|
|
141
|
-
} catch {
|
|
142
|
-
// A transcript fragment that merely resembles a URL is not evidence.
|
|
143
|
-
}
|
|
217
|
+
for (const segment of shellSegments(command)) {
|
|
218
|
+
const lead = leadingExecutable(segment);
|
|
219
|
+
if (lead === null || !FETCHERS.has(lead.name)) continue;
|
|
220
|
+
for (const token of lead.rest) {
|
|
221
|
+
for (const url of token.replace(/^['"]|['"]$/g, "").match(URL_PATTERN) ?? []) urls.push(url);
|
|
144
222
|
}
|
|
145
223
|
}
|
|
146
224
|
return urls;
|
|
147
225
|
}
|
|
148
226
|
|
|
227
|
+
function sanitizeUrl(raw: string, targets: readonly DocsTarget[]): string | null {
|
|
228
|
+
try {
|
|
229
|
+
const url = new URL(raw);
|
|
230
|
+
if (!matchesDocsTarget(url, targets)) return null;
|
|
231
|
+
url.username = "";
|
|
232
|
+
url.password = "";
|
|
233
|
+
url.search = "";
|
|
234
|
+
url.hash = "";
|
|
235
|
+
return url.toString();
|
|
236
|
+
} catch {
|
|
237
|
+
// A transcript fragment that merely resembles a URL is not evidence.
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The vendors' native web tools, by exact name: a search is an event, a fetch is a page. */
|
|
243
|
+
const WEB_SEARCH_TOOL = /^(?:web_?search|google_web_search)$/i;
|
|
244
|
+
const WEB_FETCH_TOOL = /^(?:web_?fetch)$/i;
|
|
245
|
+
|
|
246
|
+
/** The page a fetch tool was asked for: its URL field, never a URL mentioned in a prompt or query. */
|
|
247
|
+
function fetchUrlOf(input: unknown): string | null {
|
|
248
|
+
const fields = record(input);
|
|
249
|
+
for (const key of ["url", "URL", "uri", "href"]) {
|
|
250
|
+
const value = fields[key];
|
|
251
|
+
if (typeof value === "string" && value !== "") return value;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A search tool's query text (Claude `query`, Codex `query`/`action.query`), for the product-token test. */
|
|
257
|
+
function searchQueryOf(input: unknown): string {
|
|
258
|
+
const fields = record(input);
|
|
259
|
+
const action = record(fields["action"]);
|
|
260
|
+
return [fields["query"], action["query"], ...(Array.isArray(action["queries"]) ? action["queries"] : [])]
|
|
261
|
+
.filter((value): value is string => typeof value === "string")
|
|
262
|
+
.join(" ");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Documentation evidence from ONE invocation: the page a web-fetch tool or
|
|
267
|
+
* a shell fetcher opened, or a web search naming the product (a docs event,
|
|
268
|
+
* never a URL: a query is not a page). MCP tools never count here — an
|
|
269
|
+
* MCP `search_docs` is MCP use, tallied on its own channel. A URL that only
|
|
270
|
+
* appears in a prompt, a query, or a file the agent wrote is not evidence.
|
|
271
|
+
*/
|
|
272
|
+
function docsEvidence(
|
|
273
|
+
invocation: ToolInvocation,
|
|
274
|
+
targets: readonly DocsTarget[],
|
|
275
|
+
searchTokens: readonly string[],
|
|
276
|
+
): { urls: string[]; search: boolean } {
|
|
277
|
+
const none = { urls: [] as string[], search: false };
|
|
278
|
+
if (targets.length === 0 || invocation.kind === "mcp" || /^mcp__/i.test(invocation.name)) return none;
|
|
279
|
+
const urls: string[] = [];
|
|
280
|
+
const push = (raw: string): void => {
|
|
281
|
+
const safe = sanitizeUrl(raw, targets);
|
|
282
|
+
if (safe !== null && !urls.includes(safe)) urls.push(safe);
|
|
283
|
+
};
|
|
284
|
+
if (WEB_FETCH_TOOL.test(invocation.name)) {
|
|
285
|
+
const url = fetchUrlOf(invocation.input);
|
|
286
|
+
if (url !== null) push(url);
|
|
287
|
+
return { urls, search: false };
|
|
288
|
+
}
|
|
289
|
+
if (WEB_SEARCH_TOOL.test(invocation.name)) {
|
|
290
|
+
// Codex reports an opened page as a search item with `action.url`.
|
|
291
|
+
const opened = fetchUrlOf(record(invocation.input)["action"]);
|
|
292
|
+
if (opened !== null) {
|
|
293
|
+
push(opened);
|
|
294
|
+
return { urls, search: false };
|
|
295
|
+
}
|
|
296
|
+
const query = searchQueryOf(invocation.input).toLowerCase();
|
|
297
|
+
const tokens = productTokens(targets, searchTokens);
|
|
298
|
+
return { urls, search: query.trim() !== "" && tokens.some((token) => query.includes(token)) };
|
|
299
|
+
}
|
|
300
|
+
if (invocation.kind === "command" || /bash|shell|terminal|command/i.test(invocation.name)) {
|
|
301
|
+
const command = typeof invocation.input === "string" ? invocation.input : allStrings(invocation.input).join("\n");
|
|
302
|
+
for (const raw of fetchedUrlsInCommand(command)) push(raw);
|
|
303
|
+
}
|
|
304
|
+
return { urls, search: false };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Heredoc bodies (`cat <<EOF … EOF`) are file contents, not commands. Only
|
|
309
|
+
* the body goes; the opener line keeps whatever follows the redirection
|
|
310
|
+
* (`cat <<EOF > README.md && acme --help` still runs `acme --help`).
|
|
311
|
+
*/
|
|
312
|
+
const HEREDOC = /(<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2[^\n]*)\n[\s\S]*?\n[ \t]*\3[ \t]*(?=\n|$)/gu;
|
|
313
|
+
|
|
314
|
+
/** A backslash-newline continues the same command. */
|
|
315
|
+
const LINE_CONTINUATION = /\\\n/gu;
|
|
316
|
+
|
|
149
317
|
function shellSegments(command: string): string[] {
|
|
150
318
|
// Unwrap the common structured-stream representation `bash -lc '<body>'`.
|
|
151
319
|
// This is intentionally not a full shell parser; it only finds the leading
|
|
152
320
|
// executable of each simple pipeline/list segment and never executes text.
|
|
153
321
|
const wrapped = /^\s*(?:\/bin\/)?(?:ba|z|da)?sh\s+-[a-z]*c\s+(['"])([\s\S]*)\1\s*$/u.exec(command);
|
|
154
|
-
const body = wrapped?.[2] ?? command;
|
|
322
|
+
const body = (wrapped?.[2] ?? command).replace(LINE_CONTINUATION, " ").replace(HEREDOC, "$1");
|
|
155
323
|
return body.split(/(?:&&|\|\||[;|\n])/u).map((segment) => segment.trim().replace(/^['"(]+|['")]+$/g, ""));
|
|
156
324
|
}
|
|
157
325
|
|
|
326
|
+
/** Words that precede the real executable of a segment without changing what runs. */
|
|
327
|
+
const COMMAND_PREFIXES = new Set([
|
|
328
|
+
"sudo", "env", "command", "exec", "nohup", "time", "timeout", "nice", "xargs",
|
|
329
|
+
"if", "elif", "while", "until", "then", "do", "else", "!", "{", "(",
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The executable a segment runs (after prefixes such as `sudo`, `command`,
|
|
334
|
+
* `if`, and VAR=value) and its arguments. `command -v x` / `-V x` only look
|
|
335
|
+
* `x` up and run nothing, so they yield no executable.
|
|
336
|
+
*/
|
|
337
|
+
function leadingExecutable(segment: string): { name: string; rest: string[] } | null {
|
|
338
|
+
const tokens = segment.split(/\s+/u).filter(Boolean);
|
|
339
|
+
let index = 0;
|
|
340
|
+
while (
|
|
341
|
+
index < tokens.length &&
|
|
342
|
+
(COMMAND_PREFIXES.has(tokens[index]!) ||
|
|
343
|
+
/^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(tokens[index]!) ||
|
|
344
|
+
(tokens[index]!.startsWith("-") && index > 0 && COMMAND_PREFIXES.has(tokens[index - 1]!)))
|
|
345
|
+
) {
|
|
346
|
+
// `command -v x` / `-V x` (after any prefix: `if command -v x`) looks x up and runs nothing.
|
|
347
|
+
if (tokens[index] === "command" && (tokens[index + 1] === "-v" || tokens[index + 1] === "-V")) return null;
|
|
348
|
+
index += 1;
|
|
349
|
+
}
|
|
350
|
+
const invoked = (tokens[index] ?? "").replace(/^['"]|['"]$/g, "");
|
|
351
|
+
if (invoked === "") return null;
|
|
352
|
+
return { name: invoked.split("/").at(-1) ?? invoked, rest: tokens.slice(index + 1) };
|
|
353
|
+
}
|
|
354
|
+
|
|
158
355
|
/** Return a non-secret command label such as `product widgets`, or null. */
|
|
159
356
|
function cliLabel(input: unknown, executables: readonly string[]): string | null {
|
|
160
357
|
const command = typeof input === "string" ? input : allStrings(input).join("\n");
|
|
161
358
|
for (const segment of shellSegments(command)) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const tokens = segment.split(/\s+/u).filter(Boolean);
|
|
165
|
-
let index = 0;
|
|
166
|
-
while (tokens[index] === "sudo" || tokens[index] === "env" || /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(tokens[index] ?? "")) {
|
|
167
|
-
index += 1;
|
|
168
|
-
}
|
|
169
|
-
const invoked = (tokens[index] ?? "").replace(/^['"]|['"]$/g, "");
|
|
359
|
+
const lead = leadingExecutable(segment);
|
|
360
|
+
if (lead === null) continue;
|
|
170
361
|
for (const executable of executables) {
|
|
171
362
|
const trimmed = executable.trim();
|
|
172
|
-
if (trimmed === "") continue;
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
363
|
+
if (trimmed === "" || lead.name !== trimmed) continue;
|
|
364
|
+
const rawSubcommand = lead.rest[0]?.replace(/^['"]|['"]$/g, "");
|
|
365
|
+
// `acme = Client()` is a line of source, not a CLI call.
|
|
366
|
+
if (rawSubcommand !== undefined && /^[=:]/u.test(rawSubcommand)) continue;
|
|
176
367
|
const subcommand = rawSubcommand && !rawSubcommand.startsWith("-") ? rawSubcommand : undefined;
|
|
177
368
|
return subcommand ? `${trimmed} ${subcommand}` : trimmed;
|
|
178
369
|
}
|
|
@@ -218,10 +409,13 @@ export function surfaceUsageConfigFromRuntime(
|
|
|
218
409
|
): SurfaceUsageConfig {
|
|
219
410
|
const docsHosts: string[] = [];
|
|
220
411
|
const cliCommands: string[] = [];
|
|
412
|
+
const searchTokens: string[] = [];
|
|
221
413
|
const visit = (value: unknown): void => {
|
|
222
414
|
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
223
415
|
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
224
|
-
if (
|
|
416
|
+
if (key === "searchTokens" && Array.isArray(item)) {
|
|
417
|
+
for (const token of item) if (typeof token === "string") searchTokens.push(token);
|
|
418
|
+
} else if ((key === "hosts" || key === "docsHosts") && Array.isArray(item)) {
|
|
225
419
|
// Wildcard entries ("*.example.com") reduce to their parent domain —
|
|
226
420
|
// the matcher already covers subdomains of a configured parent.
|
|
227
421
|
for (const host of item) if (typeof host === "string") docsHosts.push(host.replace(/^\*\./u, ""));
|
|
@@ -237,6 +431,7 @@ export function surfaceUsageConfigFromRuntime(
|
|
|
237
431
|
docsHosts: [...new Set(docsHosts)],
|
|
238
432
|
cliCommands: [...new Set(cliCommands)],
|
|
239
433
|
mcpServers: [...new Set(mcpServerNames)],
|
|
434
|
+
...(searchTokens.length > 0 ? { searchTokens: [...new Set(searchTokens)] } : {}),
|
|
240
435
|
};
|
|
241
436
|
}
|
|
242
437
|
|
|
@@ -257,7 +452,7 @@ export function extractSurfaceUsage(
|
|
|
257
452
|
mcp: { count: 0, tools: [] },
|
|
258
453
|
cli: { count: 0, commands: [] },
|
|
259
454
|
};
|
|
260
|
-
const
|
|
455
|
+
const targets = docsTargets(config.docsHosts ?? []);
|
|
261
456
|
const cliCommands = config.cliCommands ?? [];
|
|
262
457
|
const mcpServers = config.mcpServers ?? [];
|
|
263
458
|
const seenInvocations = new Set<string>();
|
|
@@ -278,9 +473,9 @@ export function extractSurfaceUsage(
|
|
|
278
473
|
: null;
|
|
279
474
|
if (command) pushUnique(usage.cli, usage.cli.commands, command);
|
|
280
475
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
476
|
+
const docs = docsEvidence(invocation, targets, config.searchTokens ?? []);
|
|
477
|
+
for (const url of docs.urls) pushUnique(usage.docs, usage.docs.urls, url);
|
|
478
|
+
if (docs.search) usage.docs.count += 1;
|
|
284
479
|
}
|
|
285
480
|
}
|
|
286
481
|
|
|
@@ -288,10 +483,19 @@ export function extractSurfaceUsage(
|
|
|
288
483
|
}
|
|
289
484
|
|
|
290
485
|
/** Observe offered surfaces plus explicitly shared targets, without changing offered state. */
|
|
486
|
+
/** The words a web search must mention for THIS config: its explicit tokens, else those derived from its own docs hosts. */
|
|
487
|
+
export function resolveSearchTokens(config: SurfaceUsageConfig): string[] {
|
|
488
|
+
return productTokens(docsTargets(config.docsHosts ?? []), config.searchTokens ?? []);
|
|
489
|
+
}
|
|
490
|
+
|
|
291
491
|
export function observationConfig(offered: SurfaceUsageConfig, targets?: SurfaceUsageConfig): SurfaceUsageConfig {
|
|
492
|
+
// Resolve each side on its own first: explicit tokens for one target must
|
|
493
|
+
// never replace the words derived for another target's hosts.
|
|
494
|
+
const searchTokens = [...new Set([...resolveSearchTokens(offered), ...(targets ? resolveSearchTokens(targets) : [])])].sort();
|
|
292
495
|
return {
|
|
293
496
|
docsHosts: [...new Set([...(offered.docsHosts ?? []), ...(targets?.docsHosts ?? [])])].sort(),
|
|
294
497
|
cliCommands: [...new Set([...(offered.cliCommands ?? []), ...(targets?.cliCommands ?? [])])].sort(),
|
|
295
498
|
mcpServers: [...new Set([...(offered.mcpServers ?? []), ...(targets?.mcpServers ?? [])])].sort(),
|
|
499
|
+
...(searchTokens.length > 0 ? { searchTokens } : {}),
|
|
296
500
|
};
|
|
297
501
|
}
|