imprint-mcp 0.2.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -0
  4. package/examples/discoverandgo/README.md +57 -0
  5. package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
  6. package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
  7. package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
  8. package/examples/echo/README.md +37 -0
  9. package/examples/echo/echo_test/index.ts +31 -0
  10. package/examples/google-flights/search_google_flights/index.ts +101 -0
  11. package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
  12. package/examples/google-flights/search_google_flights/parser.ts +189 -0
  13. package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
  14. package/examples/google-flights/search_google_flights/workflow.json +48 -0
  15. package/examples/google-hotels/search_google_hotels/index.ts +194 -0
  16. package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
  17. package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
  18. package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
  19. package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
  20. package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
  21. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
  22. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
  23. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
  24. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
  25. package/examples/southwest/README.md +81 -0
  26. package/examples/southwest/search_southwest_flights/backends.json +23 -0
  27. package/examples/southwest/search_southwest_flights/cron.json +19 -0
  28. package/examples/southwest/search_southwest_flights/index.ts +110 -0
  29. package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
  30. package/examples/southwest/search_southwest_flights/workflow.json +54 -0
  31. package/package.json +78 -0
  32. package/prompts/compile-agent.md +580 -0
  33. package/prompts/intent-detection.md +198 -0
  34. package/prompts/playbook-compilation.md +279 -0
  35. package/prompts/request-triage.md +74 -0
  36. package/prompts/tool-candidate-detection.md +104 -0
  37. package/src/cli.ts +1287 -0
  38. package/src/imprint/agent.ts +468 -0
  39. package/src/imprint/app-api-hosts.ts +53 -0
  40. package/src/imprint/backend-ladder.ts +568 -0
  41. package/src/imprint/check.ts +136 -0
  42. package/src/imprint/chromium.ts +211 -0
  43. package/src/imprint/claude-cli-compile.ts +640 -0
  44. package/src/imprint/cli-credential.ts +394 -0
  45. package/src/imprint/codex-cli-compile.ts +712 -0
  46. package/src/imprint/compile-agent-types.ts +40 -0
  47. package/src/imprint/compile-agent.ts +404 -0
  48. package/src/imprint/compile-tools.ts +1389 -0
  49. package/src/imprint/compile.ts +720 -0
  50. package/src/imprint/cookie-jar.ts +246 -0
  51. package/src/imprint/credential-bundle.ts +195 -0
  52. package/src/imprint/credential-extract.ts +290 -0
  53. package/src/imprint/credential-store.ts +707 -0
  54. package/src/imprint/cron.ts +312 -0
  55. package/src/imprint/doctor.ts +223 -0
  56. package/src/imprint/emit.ts +154 -0
  57. package/src/imprint/etld.ts +134 -0
  58. package/src/imprint/freeform-redact.ts +216 -0
  59. package/src/imprint/inject-listener.ts +137 -0
  60. package/src/imprint/install.ts +795 -0
  61. package/src/imprint/integrations.ts +385 -0
  62. package/src/imprint/is-compiled.ts +2 -0
  63. package/src/imprint/json-path.ts +100 -0
  64. package/src/imprint/llm.ts +998 -0
  65. package/src/imprint/load-json.ts +54 -0
  66. package/src/imprint/log.ts +33 -0
  67. package/src/imprint/login.ts +166 -0
  68. package/src/imprint/mcp-compile-server.ts +282 -0
  69. package/src/imprint/mcp-maintenance.ts +1790 -0
  70. package/src/imprint/mcp-server.ts +350 -0
  71. package/src/imprint/multi-progress.ts +69 -0
  72. package/src/imprint/notify.ts +155 -0
  73. package/src/imprint/paths.ts +64 -0
  74. package/src/imprint/playbook-parser.ts +21 -0
  75. package/src/imprint/playbook-runner.ts +465 -0
  76. package/src/imprint/probe-backends.ts +251 -0
  77. package/src/imprint/progress.ts +28 -0
  78. package/src/imprint/record.ts +470 -0
  79. package/src/imprint/redact.ts +550 -0
  80. package/src/imprint/replay-capture.ts +387 -0
  81. package/src/imprint/request-context.ts +66 -0
  82. package/src/imprint/runtime-link.ts +73 -0
  83. package/src/imprint/runtime.ts +942 -0
  84. package/src/imprint/sensitive-keys.ts +156 -0
  85. package/src/imprint/session-diff.ts +409 -0
  86. package/src/imprint/session-merge.ts +198 -0
  87. package/src/imprint/session-writer.ts +149 -0
  88. package/src/imprint/sites.ts +27 -0
  89. package/src/imprint/stealth-fetch.ts +434 -0
  90. package/src/imprint/teach-state.ts +235 -0
  91. package/src/imprint/teach.ts +2120 -0
  92. package/src/imprint/tool-candidates.ts +423 -0
  93. package/src/imprint/tool-loader.ts +186 -0
  94. package/src/imprint/tool-selection.ts +70 -0
  95. package/src/imprint/tracing.ts +508 -0
  96. package/src/imprint/types.ts +472 -0
  97. package/src/imprint/version.ts +21 -0
@@ -0,0 +1,468 @@
1
+ /**
2
+ * General-purpose tool-using agent loop.
3
+ *
4
+ * Implements the standard Anthropic tool-use pattern:
5
+ * 1. Model returns tool_use blocks → dispatch tools → append tool_result
6
+ * 2. Loop until done() / give_up() or timeout / soft cap
7
+ * 3. Return conversation log + outcome + token stats
8
+ */
9
+
10
+ import type { Anthropic } from '@anthropic-ai/sdk';
11
+ import type { ToolUseProvider } from './llm.ts';
12
+ import { setSpanAttributes, traceToolIoEnabled, traced } from './tracing.ts';
13
+
14
+ export interface AgentTool {
15
+ name: string;
16
+ description: string;
17
+ input_schema: {
18
+ type: 'object';
19
+ properties?: Record<string, unknown>;
20
+ required?: string[];
21
+ [k: string]: unknown;
22
+ };
23
+ handler: (input: unknown) => Promise<{ result: string; isError?: boolean }>;
24
+ }
25
+
26
+ export interface AgentProgress {
27
+ /** 1-based turn number (turn 1 is the first LLM call). */
28
+ turn: number;
29
+ /** What the agent is doing right now. */
30
+ phase: 'thinking' | 'tool';
31
+ /** When phase is 'tool': the name of the tool being dispatched. */
32
+ toolName?: string;
33
+ /** Wall-clock time since the loop started, in ms. */
34
+ elapsedMs: number;
35
+ /** Total wall-clock budget (deadlineMs - startMs at loop start), in ms. */
36
+ budgetMs: number;
37
+ /** Cumulative input tokens across all turns so far. */
38
+ inputTokens: number;
39
+ /** Cumulative output tokens across all turns so far. */
40
+ outputTokens: number;
41
+ }
42
+
43
+ /**
44
+ * Called when the wall-clock deadline is reached. Return a positive number of
45
+ * milliseconds to extend the deadline, or null/undefined to let it time out.
46
+ */
47
+ export type OnDeadlineReached = () => Promise<number | null>;
48
+
49
+ export interface AgentResult {
50
+ outcome: 'done' | 'give_up' | 'timeout' | 'soft_cap' | 'error';
51
+ doneSummary?: string;
52
+ giveUpReason?: string;
53
+ giveUpDetail?: string;
54
+ errorMessage?: string;
55
+ turns: number;
56
+ durationMs: number;
57
+ inputTokens: number;
58
+ outputTokens: number;
59
+ conversationLog: ConversationLogEntry[];
60
+ }
61
+
62
+ export interface ConversationLogEntry {
63
+ turn: number;
64
+ role: 'user' | 'assistant';
65
+ // Mirrors Anthropic.MessageParam.content — string OR array of content blocks
66
+ content: unknown;
67
+ }
68
+
69
+ interface AgentLoopOptions {
70
+ systemPrompt: string;
71
+ initialUserMessage: string;
72
+ tools: AgentTool[];
73
+ /** wall-clock deadline in ms since epoch (Date.now()) */
74
+ deadlineMs: number;
75
+ /** soft cap on number of LLM turns; default 100 */
76
+ softTurnCap?: number;
77
+ llm: ToolUseProvider;
78
+ /** called before each LLM call and tool dispatch with structured progress */
79
+ onProgress?: (p: AgentProgress) => void;
80
+ /** called when the wall-clock deadline is reached; return ms to extend or null to time out */
81
+ onDeadlineReached?: OnDeadlineReached;
82
+ }
83
+
84
+ /** Helper: creates the standard 'done' tool */
85
+ export function doneTool(): AgentTool {
86
+ return {
87
+ name: 'done',
88
+ description: 'Call this when you have successfully completed the task.',
89
+ input_schema: {
90
+ type: 'object',
91
+ properties: {
92
+ summary: {
93
+ type: 'string',
94
+ description: 'Brief summary of what was accomplished',
95
+ },
96
+ },
97
+ required: ['summary'],
98
+ },
99
+ handler: async () => {
100
+ throw new Error('reserved tool — should not be invoked');
101
+ },
102
+ };
103
+ }
104
+
105
+ /** Helper: creates the standard 'give_up' tool */
106
+ export function giveUpTool(): AgentTool {
107
+ return {
108
+ name: 'give_up',
109
+ description:
110
+ 'Call this when you have encountered a categorical impossibility and cannot proceed.',
111
+ input_schema: {
112
+ type: 'object',
113
+ properties: {
114
+ reason: {
115
+ type: 'string',
116
+ description: 'Why you cannot complete the task',
117
+ },
118
+ what_was_tried: {
119
+ type: 'string',
120
+ description: 'Summary of approaches you tried before giving up',
121
+ },
122
+ },
123
+ required: ['reason', 'what_was_tried'],
124
+ },
125
+ handler: async () => {
126
+ throw new Error('reserved tool — should not be invoked');
127
+ },
128
+ };
129
+ }
130
+
131
+ const TOOL_RESULT_TRUNCATE_LIMIT = 32 * 1024; // 32KB
132
+
133
+ type TurnOutcome = { action: 'continue' } | { action: 'return'; result: AgentResult };
134
+
135
+ /**
136
+ * Run an agent loop with tool-use.
137
+ *
138
+ * Continues until:
139
+ * - Model calls done() or give_up()
140
+ * - Wall-clock deadline exceeded
141
+ * - Turn count exceeds soft cap
142
+ * - Unexpected error
143
+ */
144
+ export async function runAgentLoop(opts: AgentLoopOptions): Promise<AgentResult> {
145
+ const startTime = Date.now();
146
+ const softTurnCap = opts.softTurnCap ?? 100;
147
+ const startMs = Date.now();
148
+ let deadlineMs = opts.deadlineMs;
149
+ let budgetMs = Math.max(0, deadlineMs - startMs);
150
+
151
+ // Convert AgentTools to Anthropic.Tool format (strip handlers)
152
+ const anthropicTools: Anthropic.Tool[] = opts.tools.map((t) => ({
153
+ name: t.name,
154
+ description: t.description,
155
+ input_schema: t.input_schema,
156
+ }));
157
+
158
+ // Build messages array starting with initial user message
159
+ const messages: Anthropic.MessageParam[] = [{ role: 'user', content: opts.initialUserMessage }];
160
+
161
+ let turn = 0;
162
+ let inputTokens = 0;
163
+ let outputTokens = 0;
164
+ let budgetNudgeSent = false;
165
+
166
+ const conversationLog: ConversationLogEntry[] = [];
167
+
168
+ // Add initial user message to log
169
+ conversationLog.push({
170
+ turn: 0,
171
+ role: 'user',
172
+ content: opts.initialUserMessage,
173
+ });
174
+
175
+ while (true) {
176
+ // Check wall-clock deadline
177
+ if (Date.now() > deadlineMs) {
178
+ if (opts.onDeadlineReached) {
179
+ const extensionMs = await opts.onDeadlineReached();
180
+ if (extensionMs != null && extensionMs > 0) {
181
+ deadlineMs += extensionMs;
182
+ budgetMs += extensionMs;
183
+ } else {
184
+ return {
185
+ outcome: 'timeout',
186
+ turns: turn,
187
+ durationMs: Date.now() - startTime,
188
+ inputTokens,
189
+ outputTokens,
190
+ conversationLog,
191
+ };
192
+ }
193
+ } else {
194
+ return {
195
+ outcome: 'timeout',
196
+ turns: turn,
197
+ durationMs: Date.now() - startTime,
198
+ inputTokens,
199
+ outputTokens,
200
+ conversationLog,
201
+ };
202
+ }
203
+ }
204
+
205
+ // Check soft turn cap
206
+ if (turn > softTurnCap) {
207
+ return {
208
+ outcome: 'soft_cap',
209
+ turns: turn,
210
+ durationMs: Date.now() - startTime,
211
+ inputTokens,
212
+ outputTokens,
213
+ conversationLog,
214
+ };
215
+ }
216
+
217
+ turn++;
218
+ opts.onProgress?.({
219
+ turn,
220
+ phase: 'thinking',
221
+ elapsedMs: Date.now() - startMs,
222
+ budgetMs,
223
+ inputTokens,
224
+ outputTokens,
225
+ });
226
+
227
+ const turnOutcome = await traced(
228
+ `agent.turn.${turn}`,
229
+ 'CHAIN',
230
+ {
231
+ 'imprint.agent.turn': turn,
232
+ 'imprint.agent.cumulative_input_tokens': inputTokens,
233
+ 'imprint.agent.cumulative_output_tokens': outputTokens,
234
+ },
235
+ async (turnSpan): Promise<TurnOutcome> => {
236
+ // Call LLM with tools — llm.message_with_tools span nests as child
237
+ let response: Anthropic.Message;
238
+ try {
239
+ response = await opts.llm.messageWithTools({
240
+ system: opts.systemPrompt,
241
+ messages,
242
+ tools: anthropicTools,
243
+ });
244
+ } catch (err) {
245
+ return {
246
+ action: 'return',
247
+ result: {
248
+ outcome: 'error',
249
+ errorMessage: `LLM call failed: ${err instanceof Error ? err.message : String(err)}`,
250
+ turns: turn,
251
+ durationMs: Date.now() - startTime,
252
+ inputTokens,
253
+ outputTokens,
254
+ conversationLog,
255
+ },
256
+ };
257
+ }
258
+
259
+ // Update token counts
260
+ inputTokens += response.usage.input_tokens;
261
+ outputTokens += response.usage.output_tokens;
262
+
263
+ setSpanAttributes(turnSpan, {
264
+ 'imprint.agent.turn_input_tokens': response.usage.input_tokens,
265
+ 'imprint.agent.turn_output_tokens': response.usage.output_tokens,
266
+ 'imprint.agent.stop_reason': response.stop_reason ?? 'unknown',
267
+ });
268
+
269
+ // Append assistant response to messages
270
+ messages.push({ role: 'assistant', content: response.content });
271
+
272
+ // Add to conversation log
273
+ conversationLog.push({
274
+ turn,
275
+ role: 'assistant',
276
+ content: response.content,
277
+ });
278
+
279
+ // Extract tool_use blocks regardless of stop_reason — a max_tokens or
280
+ // end_turn response can still contain completed tool_use blocks that
281
+ // need matching tool_result blocks in the next user message.
282
+ const toolUseBlocks = response.content.filter(
283
+ (block): block is Anthropic.ToolUseBlock => block.type === 'tool_use',
284
+ );
285
+
286
+ if (toolUseBlocks.length > 0) {
287
+ setSpanAttributes(turnSpan, {
288
+ 'imprint.agent.tools_requested': toolUseBlocks.map((b) => b.name).join(', '),
289
+ });
290
+
291
+ // Check for done() or give_up() first
292
+ for (const block of toolUseBlocks) {
293
+ if (block.name === 'done') {
294
+ const input = block.input as { summary?: string };
295
+ return {
296
+ action: 'return',
297
+ result: {
298
+ outcome: 'done',
299
+ doneSummary: input.summary ?? 'Task completed',
300
+ turns: turn,
301
+ durationMs: Date.now() - startTime,
302
+ inputTokens,
303
+ outputTokens,
304
+ conversationLog,
305
+ },
306
+ };
307
+ }
308
+ if (block.name === 'give_up') {
309
+ const input = block.input as { reason?: string; what_was_tried?: string };
310
+ return {
311
+ action: 'return',
312
+ result: {
313
+ outcome: 'give_up',
314
+ giveUpReason: input.reason ?? 'Cannot proceed',
315
+ giveUpDetail: input.what_was_tried,
316
+ turns: turn,
317
+ durationMs: Date.now() - startTime,
318
+ inputTokens,
319
+ outputTokens,
320
+ conversationLog,
321
+ },
322
+ };
323
+ }
324
+ }
325
+
326
+ // Dispatch all other tools and collect results
327
+ const toolResults: Anthropic.ToolResultBlockParam[] = [];
328
+ for (const block of toolUseBlocks) {
329
+ const tool = opts.tools.find((t) => t.name === block.name);
330
+ if (!tool) {
331
+ toolResults.push({
332
+ type: 'tool_result',
333
+ tool_use_id: block.id,
334
+ content: `Error: unknown tool "${block.name}"`,
335
+ is_error: true,
336
+ });
337
+ continue;
338
+ }
339
+
340
+ // Fire progress before tool execution
341
+ opts.onProgress?.({
342
+ turn,
343
+ phase: 'tool',
344
+ toolName: tool.name,
345
+ elapsedMs: Date.now() - startMs,
346
+ budgetMs,
347
+ inputTokens,
348
+ outputTokens,
349
+ });
350
+
351
+ // Call the tool handler — wrapped in a trace span
352
+ const toolResult = await traced(
353
+ `agent.tool.${tool.name}`,
354
+ 'TOOL',
355
+ {
356
+ 'imprint.agent.tool_name': tool.name,
357
+ 'imprint.agent.turn': turn,
358
+ ...(traceToolIoEnabled()
359
+ ? { 'imprint.agent.tool_input': JSON.stringify(block.input).slice(0, 2000) }
360
+ : {}),
361
+ },
362
+ async (toolSpan): Promise<{ result: string; isError?: boolean }> => {
363
+ let result: { result: string; isError?: boolean };
364
+ try {
365
+ result = await tool.handler(block.input);
366
+ } catch (err) {
367
+ result = {
368
+ result: err instanceof Error ? err.message : String(err),
369
+ isError: true,
370
+ };
371
+ }
372
+ setSpanAttributes(toolSpan, {
373
+ 'imprint.agent.tool_is_error': result.isError ?? false,
374
+ 'imprint.agent.tool_result_chars': result.result.length,
375
+ ...(traceToolIoEnabled()
376
+ ? { 'imprint.agent.tool_output': result.result.slice(0, 2000) }
377
+ : {}),
378
+ });
379
+ return result;
380
+ },
381
+ );
382
+
383
+ // Truncate large results
384
+ let content = toolResult.result;
385
+ if (content.length > TOOL_RESULT_TRUNCATE_LIMIT) {
386
+ const originalLength = content.length;
387
+ content = `${content.slice(0, TOOL_RESULT_TRUNCATE_LIMIT)}\n[…truncated, original length ${originalLength}…]`;
388
+ }
389
+
390
+ toolResults.push({
391
+ type: 'tool_result',
392
+ tool_use_id: block.id,
393
+ content,
394
+ is_error: toolResult.isError ?? false,
395
+ });
396
+ }
397
+
398
+ // Build the user response: tool results first, plus an optional
399
+ // continuation nudge if the model was cut off mid-output.
400
+ const userContent: (Anthropic.ToolResultBlockParam | Anthropic.TextBlockParam)[] = [
401
+ ...toolResults,
402
+ ];
403
+ if (response.stop_reason === 'max_tokens') {
404
+ userContent.push({
405
+ type: 'text',
406
+ text: 'You hit max_tokens. Continue from where you stopped.',
407
+ });
408
+ }
409
+
410
+ // Budget nudge: fire once when 70% of time or 60% of turns are consumed
411
+ if (!budgetNudgeSent) {
412
+ const elapsedFraction = (Date.now() - startMs) / budgetMs;
413
+ const turnFraction = turn / softTurnCap;
414
+ if (elapsedFraction > 0.7 || turnFraction > 0.6) {
415
+ budgetNudgeSent = true;
416
+ userContent.push({
417
+ type: 'text',
418
+ text: `Budget check: you have used ${turn} turns and ${Math.round(elapsedFraction * 100)}% of your time. If your parser tests pass, call done now. Do not spend remaining turns debugging integration test failures — the verification harness retries automatically.`,
419
+ });
420
+ }
421
+ }
422
+
423
+ messages.push({ role: 'user', content: userContent });
424
+ conversationLog.push({ turn, role: 'user', content: userContent });
425
+ } else if (response.stop_reason === 'end_turn') {
426
+ // Model stopped without calling any tools or done()/give_up()
427
+ const nudgeMessage =
428
+ 'You stopped without calling done() or give_up(). If you are finished, call done. If you encountered a categorical impossibility, call give_up. Otherwise continue working.';
429
+ messages.push({ role: 'user', content: nudgeMessage });
430
+ conversationLog.push({
431
+ turn,
432
+ role: 'user',
433
+ content: nudgeMessage,
434
+ });
435
+ } else if (response.stop_reason === 'max_tokens') {
436
+ // Model hit max_tokens with no tool calls
437
+ const continueMessage = 'You hit max_tokens. Continue from where you stopped.';
438
+ messages.push({ role: 'user', content: continueMessage });
439
+ conversationLog.push({
440
+ turn,
441
+ role: 'user',
442
+ content: continueMessage,
443
+ });
444
+ } else if (response.stop_reason !== 'tool_use') {
445
+ // Unexpected stop reason (tool_use with zero blocks would be odd but harmless)
446
+ return {
447
+ action: 'return',
448
+ result: {
449
+ outcome: 'error',
450
+ errorMessage: `unexpected stop_reason: ${response.stop_reason}`,
451
+ turns: turn,
452
+ durationMs: Date.now() - startTime,
453
+ inputTokens,
454
+ outputTokens,
455
+ conversationLog,
456
+ },
457
+ };
458
+ }
459
+
460
+ return { action: 'continue' };
461
+ },
462
+ );
463
+
464
+ if (turnOutcome.action === 'return') return turnOutcome.result;
465
+
466
+ // Loop continues...
467
+ }
468
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Infer which cross-origin hostnames belong to the application's API surface.
3
+ *
4
+ * Many web apps serve their frontend and API from different registrable domains
5
+ * (e.g. frontend on `app.example.com`, API on `api.backend.net`). The rest of
6
+ * the pipeline filters by registrable domain, which would silently drop every
7
+ * API request. This module scans the session once and returns the set of
8
+ * cross-origin hostnames that carry authentication signals — meaning the
9
+ * browser sent credentials to them, so they're part of the application.
10
+ */
11
+
12
+ import { isSameRegistrableDomain } from './etld.ts';
13
+ import { isSensitiveHeader } from './sensitive-keys.ts';
14
+ import type { CapturedRequest, Session } from './types.ts';
15
+
16
+ const CREDENTIAL_PLACEHOLDER_RE = /\$\{credential\.[^}]+\}/;
17
+ const REDACTED_MARKER_RE = /\[REDACTED:v3:id=\d+:len=\d+\]/;
18
+
19
+ function hasAuthSignals(request: CapturedRequest): boolean {
20
+ for (const [name, value] of Object.entries(request.headers)) {
21
+ if (isSensitiveHeader(name) && value.length > 0) return true;
22
+ }
23
+
24
+ const text = `${request.url}\n${JSON.stringify(request.headers)}\n${request.body ?? ''}`;
25
+ if (CREDENTIAL_PLACEHOLDER_RE.test(text)) return true;
26
+ if (REDACTED_MARKER_RE.test(text)) return true;
27
+
28
+ return false;
29
+ }
30
+
31
+ export function inferAppApiHosts(session: Session, startRoot: string | null): Set<string> {
32
+ const hosts = new Set<string>();
33
+ if (!startRoot) return hosts;
34
+
35
+ for (const request of session.requests) {
36
+ if (request.resourceType !== 'XHR' && request.resourceType !== 'Fetch') continue;
37
+
38
+ let url: URL;
39
+ try {
40
+ url = new URL(request.url);
41
+ } catch {
42
+ continue;
43
+ }
44
+
45
+ if (startRoot && isSameRegistrableDomain(url.hostname, startRoot)) continue;
46
+
47
+ if (hasAuthSignals(request)) {
48
+ hosts.add(url.hostname);
49
+ }
50
+ }
51
+
52
+ return hosts;
53
+ }