@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.
@@ -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
  }