@workos/quickstudy 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/quickstudy",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "An open-source, company-agnostic eval harness for measuring whether coding agents can complete real integration tasks, across an agent x feature x framework x surface matrix.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,7 +60,11 @@ export interface AttemptContext {
60
60
  * it into the artifacts store afterwards.
61
61
  */
62
62
  workspaceDir: string;
63
- /** The eval's task prompt, byte-identical across experiments. */
63
+ /**
64
+ * The prompt the agent receives: the eval's task body (byte-identical
65
+ * across experiments), preceded by the runtime's `promptPreamble` when the
66
+ * treatment declares one (recorded per pair in the manifest).
67
+ */
64
68
  prompt: string;
65
69
  trialIndex: number;
66
70
  }
@@ -112,7 +116,11 @@ export interface AgentAdapter {
112
116
  export interface ContainerAttemptContext {
113
117
  runId: string;
114
118
  attemptId: string;
115
- /** The eval's task prompt, byte-identical across experiments. */
119
+ /**
120
+ * The prompt the agent receives: the eval's task body (byte-identical
121
+ * across experiments), preceded by the runtime's `promptPreamble` when the
122
+ * treatment declares one (recorded per pair in the manifest).
123
+ */
116
124
  prompt: string;
117
125
  trialIndex: number;
118
126
  /**
@@ -9,7 +9,7 @@ export const EMBEDDED_BUILD_INFO: {
9
9
  // (the `x-release-please-version` marker anchors the replacement). It is
10
10
  // restored as-is after a binary build (scripts/build-binary.ts), so the
11
11
  // committed source `--version` stays in lockstep with package.json.
12
- version: "0.5.0", // x-release-please-version
12
+ version: "0.6.0", // x-release-please-version
13
13
  revision: null,
14
14
  dirty: null,
15
15
  sourceHash: null,
@@ -3,7 +3,7 @@ export function validEvalResult(value: unknown): value is EvalResult {
3
3
  if (!value || typeof value !== "object") return false;
4
4
  const result = value as EvalResult;
5
5
  if (typeof result.passed !== "boolean" || !Array.isArray(result.checks)) return false;
6
- if (result.checks.some((c) => !c || typeof c.name !== "string" || c.name === "" || typeof c.passed !== "boolean" || (c.graded !== undefined && typeof c.graded !== "boolean"))) return false;
6
+ if (result.checks.some((c) => !c || typeof c.name !== "string" || c.name === "" || typeof c.passed !== "boolean" || (c.graded !== undefined && typeof c.graded !== "boolean") || (c.kind !== undefined && (typeof c.kind !== "string" || c.kind === "")))) return false;
7
7
  if (new Set(result.checks.map((c) => c.name)).size !== result.checks.length) return false;
8
8
  return result.judgments === undefined || (Array.isArray(result.judgments) && result.judgments.every((j) => j && typeof j === "object" && j.provenance === "semantic-judge" &&
9
9
  typeof j.provider === "string" && typeof j.model === "string" && [j.tokensIn, j.tokensOut, j.costUsd].every((v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0))));
@@ -56,6 +56,15 @@ export interface CheckResult {
56
56
  * recall from it, and the scorer must leave it out of `passed`. Absent = true.
57
57
  */
58
58
  graded?: boolean;
59
+ /**
60
+ * What a recorded check is about, so recorded checks of different natures
61
+ * never pool: `"sdk"` (did the agent use the product SDK) feeds SDK recall;
62
+ * any other tag (`"wiring"` for shape-of-code signals, …) is tallied and
63
+ * shown but never enters SDK recall. Absent on an ungraded check means
64
+ * `"sdk"` (the pre-tag convention). Free-form string; graded checks may
65
+ * carry one too, purely as a label.
66
+ */
67
+ kind?: string;
59
68
  notes?: string;
60
69
  evidence?: EvidenceReference[];
61
70
  }
@@ -24,8 +24,16 @@ export interface PairEnvironment {
24
24
  webPolicy: WebPolicy;
25
25
  pathTreatment: string | null;
26
26
  mcp: string | null;
27
+ /** Runtime text prepended to the eval prompt for this pair (null = prompt verbatim). */
28
+ promptPreamble: string | null;
27
29
  budgets: LifecycleBudgets;
28
30
  }
31
+
32
+ /** The prompt an agent receives: the runtime's preamble (if any), a blank line, the eval's verbatim body. */
33
+ export function composePrompt(preamble: string | null | undefined, body: string): string {
34
+ const text = preamble?.trim() ?? "";
35
+ return text === "" ? body : `${text}\n\n${body}`;
36
+ }
29
37
  export function pairKey(evalId: string, experimentId: string): string {
30
38
  return JSON.stringify([evalId, experimentId]);
31
39
  }
@@ -64,6 +72,10 @@ export function resolvePairEnvironments(options: {
64
72
  webPolicy: runtime.webPolicy?.() ?? "native-web-blocked",
65
73
  pathTreatment: runtime.pathTreatment?.() ?? null,
66
74
  mcp: options.mcpHashes?.[experiment.id] ?? null,
75
+ promptPreamble: (() => {
76
+ const text = runtime.promptPreamble?.(loaded.metadata)?.trim() ?? "";
77
+ return text === "" ? null : text;
78
+ })(),
67
79
  budgets: { ...budgets },
68
80
  };
69
81
  }
@@ -41,7 +41,7 @@ export interface Runtime extends RuntimeCapabilities {
41
41
  * Images and lifecycle budgets can never be exempted. All group members must
42
42
  * declare the same fields. Source/config identity still changes per attempt.
43
43
  */
44
- treatmentPolicyFields?: Array<"egress" | "webPolicy" | "pathTreatment" | "mcp">;
44
+ treatmentPolicyFields?: Array<"egress" | "webPolicy" | "pathTreatment" | "mcp" | "promptPreamble">;
45
45
  /** Container image tag (container runtimes; alternative to `containerImage`). */
46
46
  image?: string;
47
47
  /** Scripted command run in /workspace (container runtimes, this phase). */
@@ -53,7 +53,7 @@ export class ReportError extends Error {
53
53
  }
54
54
  }
55
55
 
56
- export const REPORT_SCHEMA_VERSION = 10;
56
+ export const REPORT_SCHEMA_VERSION = 11;
57
57
 
58
58
  /**
59
59
  * Substrings that must never appear in generated report prose: at n≤5 trials
@@ -82,7 +82,7 @@ export function methodologyNote(trials: number): string {
82
82
  `those trials. Both describe this run's trials only — no wider claim. Telemetry (cost, tokens, surface ` +
83
83
  `usage) is summed only over attempts that reported it and shown as unavailable — never zero — when none did. ` +
84
84
  `SDK recall is reported per experiment as recalled / attempts, where attempts are the scored attempts that ` +
85
- `recorded at least one ungraded check and recalled are those whose ungraded checks all passed. Ungraded ` +
85
+ `recorded at least one ungraded check of kind "sdk" (an ungraded check with no kind counts as "sdk") and recalled are those whose ungraded "sdk" checks all passed. Ungraded checks of any other kind are tallied and shown but never enter SDK recall. Ungraded ` +
86
86
  `checks are recorded and tallied but never enter pass/fail. Scorer health lists checks that failed in every ` +
87
87
  `scored attempt of an eval under every arm while another check in the same attempts passed: a statement ` +
88
88
  `about the instrument, not about any treatment. Control experiments — an oracle that applies each eval's ` +
@@ -150,6 +150,12 @@ export interface ReportCheck {
150
150
  * schema_version < 7 artifacts; readers treat absence as graded.
151
151
  */
152
152
  graded: boolean;
153
+ /**
154
+ * The `kind` the scorer recorded for this check (`"sdk"`, `"wiring"`, …),
155
+ * or null when no attempt tagged it. Absent in schema_version < 11
156
+ * artifacts; readers treat absence as null.
157
+ */
158
+ kind: string | null;
153
159
  }
154
160
 
155
161
  /** One eval × experiment aggregation. Pairs with zero attempts are ABSENT, never 0%. */
@@ -313,7 +319,7 @@ export interface ReportJson {
313
319
  const USAGE_VALUE_CAP = 20;
314
320
 
315
321
  export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
316
- const pairs = new Map<string, { pair: ReportPair; checks: Map<string, { passed: number; total: number; graded: boolean }>; outcomes: Set<boolean> }>();
322
+ const pairs = new Map<string, { pair: ReportPair; checks: Map<string, { passed: number; total: number; graded: boolean; kind: string | null }>; outcomes: Set<boolean> }>();
317
323
  for (const attempt of attempts) {
318
324
  const key = `${attempt.evalId}|${attempt.experimentId}`;
319
325
  let entry = pairs.get(key);
@@ -350,10 +356,11 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
350
356
  entry.outcomes.add(false);
351
357
  }
352
358
  for (const check of attempt.result?.checks ?? []) {
353
- const tally = entry.checks.get(check.name) ?? { passed: 0, total: 0, graded: true };
359
+ const tally = entry.checks.get(check.name) ?? { passed: 0, total: 0, graded: true, kind: null };
354
360
  tally.total += 1;
355
361
  if (check.passed) tally.passed += 1;
356
362
  if (check.graded === false) tally.graded = false;
363
+ if (tally.kind === null && typeof check.kind === "string" && check.kind !== "") tally.kind = check.kind;
357
364
  entry.checks.set(check.name, tally);
358
365
  }
359
366
  }
@@ -365,7 +372,7 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
365
372
  pair.pass_at_k = collectPassAtK(pair.passed, pair.scored);
366
373
  pair.flaky = outcomes.size > 1;
367
374
  pair.checks = [...checks.entries()]
368
- .map(([name, tally]) => ({ name, passed: tally.passed, total: tally.total, graded: tally.graded }))
375
+ .map(([name, tally]) => ({ name, passed: tally.passed, total: tally.total, graded: tally.graded, kind: tally.kind }))
369
376
  .sort((a, b) => a.name.localeCompare(b.name));
370
377
  result.push(pair);
371
378
  }
@@ -411,17 +418,24 @@ function treatmentOf(run: RunRecord, experimentId: string): string | null {
411
418
  * experiment; "unspecified" is its own explicit missing-value sentinel, so
412
419
  * this never invents a value the run did not pin.
413
420
  */
421
+ /** An ungraded check that feeds SDK recall: kind "sdk", or no kind (the pre-tag convention). */
422
+ export function isSdkRecallCheck(check: { graded?: boolean | undefined; kind?: string | undefined }): boolean {
423
+ return check.graded === false && (check.kind === undefined || check.kind === "sdk");
424
+ }
425
+
414
426
  /**
415
427
  * SDK recall over one experiment's scored attempts: the denominator is every
416
- * completed attempt that recorded at least one `graded: false` check; an
417
- * attempt is recalled when all of those checks passed. Null rate at zero.
428
+ * completed attempt that recorded at least one `graded: false` check of kind
429
+ * "sdk" (or untagged); an attempt is recalled when all of those checks
430
+ * passed. Ungraded checks of any other kind (wiring, …) never enter. Null
431
+ * rate at zero.
418
432
  */
419
433
  export function collectRecall(attempts: AttemptRecord[], experimentId: string): ReportRecall {
420
434
  let total = 0;
421
435
  let recalled = 0;
422
436
  for (const attempt of attempts) {
423
437
  if (attempt.experimentId !== experimentId || attempt.status !== "completed") continue;
424
- const ungraded = (attempt.result?.checks ?? []).filter((check) => check.graded === false);
438
+ const ungraded = (attempt.result?.checks ?? []).filter(isSdkRecallCheck);
425
439
  if (ungraded.length === 0) continue;
426
440
  total += 1;
427
441
  if (ungraded.every((check) => check.passed)) recalled += 1;
@@ -64,7 +64,7 @@ import {
64
64
  type McpServerConfig,
65
65
  } from "../isolation/mcp.ts";
66
66
  import { hashString } from "../hash.ts";
67
- import { DEFAULT_BUDGETS, pairKey, resolvePairEnvironments, type LifecycleBudgets, type PairEnvironment } from "../execution-policy.ts";
67
+ import { DEFAULT_BUDGETS, pairKey, resolvePairEnvironments, type LifecycleBudgets, type PairEnvironment, composePrompt } from "../execution-policy.ts";
68
68
  import { ulid } from "../ids.ts";
69
69
  import { attemptIdentityHash, buildRunManifest, type RunManifest, type RuntimeImageIdentity } from "../manifest.ts";
70
70
  import { isMovingModelAlias } from "../model-identity.ts";
@@ -562,7 +562,7 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
562
562
  try {
563
563
  disposition =
564
564
  experiment.runtime.kind === "host"
565
- ? await runHostAttemptV3(options, hostAdapters, loadedEval, experiment, entry, { runId, attemptId, attemptTimeoutMs, persisted })
565
+ ? await runHostAttemptV3(options, hostAdapters, loadedEval, experiment, entry, { runId, attemptId, attemptTimeoutMs, persisted, environment: manifest.pairs?.[pairKey(entry.evalId, entry.experimentId)] })
566
566
  : await runContainerAttemptV3(
567
567
  options,
568
568
  loadedEval,
@@ -661,7 +661,7 @@ async function runHostAttemptV3(
661
661
  attemptId,
662
662
  fixtureDir: sourceDir,
663
663
  workspaceDir: scratchDir,
664
- prompt: loadedEval.promptBody,
664
+ prompt: composePrompt(args.environment?.promptPreamble, loadedEval.promptBody),
665
665
  trialIndex: entry.trialIndex,
666
666
  }));
667
667
  } catch (err) {
@@ -868,13 +868,14 @@ async function runContainerAttemptV3(
868
868
  const promptCtx: ContainerAttemptContext = {
869
869
  runId,
870
870
  attemptId,
871
- prompt: loadedEval.promptBody,
871
+ // The eval body is byte-identical across experiments; a runtime's
872
+ // promptPreamble (recorded per pair in the manifest) may precede it.
873
+ prompt: composePrompt(environment?.promptPreamble, loadedEval.promptBody),
872
874
  trialIndex: entry.trialIndex,
873
875
  ...(rewrittenServers !== undefined ? { mcpServers: rewrittenServers } : {}),
874
876
  webPolicy: environment?.webPolicy ?? "native-web-blocked",
875
877
  };
876
878
  // Treatment config files (e.g. the MCP treatment's vendor config).
877
- // The prompt itself is byte-identical across experiments.
878
879
  await inStage("environment", async () => {
879
880
  for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
880
881
  await container.writeFile(path, content);
@@ -84,6 +84,19 @@ export interface RuntimeCapabilities {
84
84
  webPolicy?(): WebPolicy;
85
85
  /** Directory prepended to the container PATH (the CLI treatment), if any. */
86
86
  pathTreatment?(): string | undefined;
87
+ /**
88
+ * Text placed BEFORE the eval's prompt for every attempt under this
89
+ * runtime — the one sanctioned way to tell the agent what the treatment
90
+ * offers ("a `acme` CLI is on PATH; run `acme --help`"). An offered surface
91
+ * nobody mentions is rarely discovered, and an undiscovered surface
92
+ * measures nothing. Identity-bearing: the resolved text is recorded per
93
+ * pair in the run manifest (`pairs[].promptPreamble`), so group members
94
+ * that differ here must list `"promptPreamble"` in `treatmentPolicyFields`
95
+ * (and mirror the text under `config[treatment]`) or comparison is
96
+ * withheld. `undefined` or an empty string means the prompt is used
97
+ * verbatim.
98
+ */
99
+ promptPreamble?(metadata: EvalMetadata): string | undefined;
87
100
  /** Capabilities (`query`, `getClient`) exposed to the eval's scorer. */
88
101
  scorerCapabilities?(ctx: ScorerCapabilityContext): ScorerCapabilities;
89
102
  }
@@ -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 open a product documentation URL? Extraction is deliberately
7
- * conservative and stores only redacted identifiers. Full shell commands and
8
- * URL query strings can contain credentials and must remain in the protected
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; subdomains also match. */
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
- return [{ ...identity, name: "web_search", input: item, kind: "web" }];
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
- function sanitizedDocsUrls(input: unknown, hosts: readonly string[]): string[] {
126
- if (hosts.length === 0) return [];
127
- const configured = hosts.map((host) => host.trim().toLowerCase().replace(/^\.+|\.+$/g, "")).filter(Boolean);
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 text of allStrings(input)) {
130
- for (const raw of text.match(URL_PATTERN) ?? []) {
131
- try {
132
- const url = new URL(raw);
133
- const host = url.hostname.toLowerCase();
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
- // Remove command prefixes without retaining their values. Environment
163
- // assignments, `env`, and `sudo` commonly precede the real executable.
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 basename = invoked.split("/").at(-1);
174
- if (invoked !== trimmed && basename !== trimmed) continue;
175
- const rawSubcommand = tokens[index + 1]?.replace(/^['"]|['"]$/g, "");
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 ((key === "hosts" || key === "docsHosts") && Array.isArray(item)) {
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 docsHosts = config.docsHosts ?? [];
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
- for (const url of sanitizedDocsUrls(invocation.input, docsHosts)) {
282
- pushUnique(usage.docs, usage.docs.urls, url);
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
  }