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,156 @@
1
+ /**
2
+ * Shared list of sensitive credential key names. Used by `redact.ts` to scrub
3
+ * values, and by `credential-extract.ts` to detect login pairs.
4
+ *
5
+ * Case-insensitive; underscores and hyphens are stripped before matching, so
6
+ * `password`, `Pass_Word`, `PASS-WORD`, `pwd` all match.
7
+ */
8
+
9
+ const SENSITIVE_KEYS = [
10
+ // Credentials — login identifiers
11
+ 'user',
12
+ 'username',
13
+ 'user_name',
14
+ 'userid',
15
+ 'user_id',
16
+ 'login',
17
+ 'loginid',
18
+ 'login_id',
19
+ // Credentials — passwords & secrets
20
+ 'pass',
21
+ 'password',
22
+ 'passwd',
23
+ 'pwd',
24
+ 'pin',
25
+ 'secret',
26
+ 'credential',
27
+ 'credentials',
28
+ // Tokens & session identifiers
29
+ 'token',
30
+ 'auth',
31
+ 'authcode',
32
+ 'auth_code',
33
+ 'apikey',
34
+ 'api_key',
35
+ 'apitoken',
36
+ 'api_token',
37
+ 'accesstoken',
38
+ 'access_token',
39
+ 'refreshtoken',
40
+ 'refresh_token',
41
+ 'idtoken',
42
+ 'id_token',
43
+ 'sessionid',
44
+ 'session_id',
45
+ 'sessiontoken',
46
+ 'session_token',
47
+ 'authorization',
48
+ 'authentication',
49
+ 'bearer',
50
+ // CSRF / XSRF
51
+ 'csrf',
52
+ 'csrf_token',
53
+ 'csrftoken',
54
+ 'xsrf',
55
+ 'xsrf_token',
56
+ 'xsrftoken',
57
+ // MFA / OTP
58
+ 'otp',
59
+ 'totp',
60
+ 'mfa_code',
61
+ 'mfacode',
62
+ 'verification_code',
63
+ 'verificationcode',
64
+ 'oktaemail',
65
+ 'okta_email',
66
+ // Device / browser fingerprinting
67
+ 'fingerprint',
68
+ // Site-specific (Discover & Go uses these)
69
+ 'patronpassword',
70
+ 'patron_password',
71
+ 'patronnumber',
72
+ 'patron_number',
73
+ 'cardnumber',
74
+ 'card_number',
75
+ 'librarycard',
76
+ 'library_card',
77
+ // Stripe / payments
78
+ 'cvc',
79
+ 'cvv',
80
+ 'cardnum',
81
+ 'card_num',
82
+ 'creditcard',
83
+ 'credit_card',
84
+ 'cc_number',
85
+ // PII — contact
86
+ 'email',
87
+ 'emailaddress',
88
+ 'email_address',
89
+ 'phone',
90
+ 'phonenumber',
91
+ 'phone_number',
92
+ 'mobile',
93
+ 'cell',
94
+ 'sms',
95
+ 'smsnumber',
96
+ 'sms_number',
97
+ // PII — names
98
+ 'firstname',
99
+ 'first_name',
100
+ 'lastname',
101
+ 'last_name',
102
+ 'fullname',
103
+ 'full_name',
104
+ 'nameoncard',
105
+ 'name_on_card',
106
+ // PII — government / identity
107
+ 'ssn',
108
+ 'socialsecurity',
109
+ 'social_security',
110
+ 'dateofbirth',
111
+ 'date_of_birth',
112
+ 'dob',
113
+ ];
114
+
115
+ const SENSITIVE_KEY_SET = new Set(SENSITIVE_KEYS.map((k) => k.toLowerCase()));
116
+
117
+ /** Subset of SENSITIVE_KEYS that specifically denote a credential (not PII).
118
+ * Used by credential-extract.ts when looking for the password half of a
119
+ * login form pair — we don't want to treat e.g. `dob` as a password. */
120
+ const PASSWORD_LIKE_KEYS = new Set(
121
+ ['password', 'passwd', 'pwd', 'pin', 'patronpassword', 'patron_password'].map((k) =>
122
+ k.toLowerCase(),
123
+ ),
124
+ );
125
+
126
+ const SENSITIVE_HEADERS = [
127
+ 'authorization',
128
+ 'cookie',
129
+ 'set-cookie',
130
+ 'x-auth-token',
131
+ 'x-api-key',
132
+ 'x-apikey',
133
+ 'x-csrf-token',
134
+ 'x-xsrf-token',
135
+ 'x-session-token',
136
+ 'proxy-authorization',
137
+ ];
138
+
139
+ const SENSITIVE_HEADER_SET = new Set(SENSITIVE_HEADERS.map((h) => h.toLowerCase()));
140
+
141
+ export const normalizeKey = (s: string): string => s.toLowerCase().replace(/[-_]/g, '');
142
+
143
+ /** True if the key name suggests a sensitive value (auth, payment, PII). */
144
+ export function isSensitiveKey(key: string): boolean {
145
+ return SENSITIVE_KEY_SET.has(normalizeKey(key));
146
+ }
147
+
148
+ /** True if the key name suggests a *password* specifically (not arbitrary
149
+ * PII). Used when pairing a username + password in extraction. */
150
+ export function isSensitiveCredentialKey(key: string): boolean {
151
+ return PASSWORD_LIKE_KEYS.has(normalizeKey(key));
152
+ }
153
+
154
+ export function isSensitiveHeader(header: string): boolean {
155
+ return SENSITIVE_HEADER_SET.has(header.toLowerCase());
156
+ }
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Dual-pass session diff: aligns requests from two independent executions
3
+ * and classifies values as constant, server-derived, or browser-minted.
4
+ */
5
+
6
+ import type { CapturedRequest, Session } from './types.ts';
7
+
8
+ // ─── Types ──────────────────────────────────────────────────────────────────
9
+
10
+ export type ValueClassification = 'constant' | 'browser_minted' | 'server_derived';
11
+
12
+ export interface ClassifiedValue {
13
+ classification: ValueClassification;
14
+ /** e.g. "url_param:correlationId", "header:x-csrf-token", "body:$.transaction.id" */
15
+ location: string;
16
+ originalSeq: number;
17
+ value1: string;
18
+ value2: string;
19
+ /** For server_derived: seq of the response that produced this value in run 2. */
20
+ producerSeq?: number;
21
+ /** For server_derived: where in the producer response the value was found. */
22
+ producerPath?: string;
23
+ suggestedStateName?: string;
24
+ }
25
+
26
+ interface AlignedRequestPair {
27
+ originalSeq: number;
28
+ replaySeq: number;
29
+ /** 0–1 based on URL path, method, body structure similarity. */
30
+ confidence: number;
31
+ }
32
+
33
+ interface DiffResult {
34
+ classifications: ClassifiedValue[];
35
+ alignedPairs: AlignedRequestPair[];
36
+ unmatchedOriginal: number[];
37
+ unmatchedReplay: number[];
38
+ }
39
+
40
+ export interface CapturedReplayRequest {
41
+ seq: number;
42
+ timestamp: number;
43
+ method: string;
44
+ url: string;
45
+ headers: Record<string, string>;
46
+ body?: string;
47
+ resourceType: string;
48
+ response?: {
49
+ status: number;
50
+ headers: Record<string, string>;
51
+ body?: string;
52
+ mimeType?: string;
53
+ };
54
+ }
55
+
56
+ // ─── Alignment ──────────────────────────────────────────────────────────────
57
+
58
+ interface RequestLike {
59
+ seq: number;
60
+ method: string;
61
+ url: string;
62
+ headers: Record<string, string>;
63
+ body?: string;
64
+ response?: { status: number; headers: Record<string, string>; body?: string };
65
+ }
66
+
67
+ function urlPathname(raw: string): string {
68
+ try {
69
+ const u = new URL(raw);
70
+ return `${u.hostname}${u.pathname}`;
71
+ } catch {
72
+ return raw;
73
+ }
74
+ }
75
+
76
+ function alignmentKey(req: RequestLike): string {
77
+ return `${req.method}\t${urlPathname(req.url)}`;
78
+ }
79
+
80
+ function jsonKeySet(body: string | undefined): Set<string> | null {
81
+ if (!body) return null;
82
+ try {
83
+ const obj = JSON.parse(body);
84
+ if (typeof obj !== 'object' || obj === null) return null;
85
+ return new Set(Object.keys(obj));
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function keySetSimilarity(a: Set<string> | null, b: Set<string> | null): number {
92
+ if (!a || !b || a.size === 0 || b.size === 0) return 0;
93
+ let intersection = 0;
94
+ for (const k of a) if (b.has(k)) intersection++;
95
+ return intersection / Math.max(a.size, b.size);
96
+ }
97
+
98
+ /**
99
+ * Align requests from two runs by (method, URL_pathname, relative_sequence).
100
+ * Returns pairs with confidence scores. Low-confidence pairs (< 0.3) excluded.
101
+ */
102
+ export function alignRequests(run1: RequestLike[], run2: RequestLike[]): AlignedRequestPair[] {
103
+ const groups1 = new Map<string, RequestLike[]>();
104
+ const groups2 = new Map<string, RequestLike[]>();
105
+
106
+ for (const r of run1) {
107
+ const key = alignmentKey(r);
108
+ const arr = groups1.get(key) ?? [];
109
+ arr.push(r);
110
+ groups1.set(key, arr);
111
+ }
112
+ for (const r of run2) {
113
+ const key = alignmentKey(r);
114
+ const arr = groups2.get(key) ?? [];
115
+ arr.push(r);
116
+ groups2.set(key, arr);
117
+ }
118
+
119
+ const pairs: AlignedRequestPair[] = [];
120
+ const usedRun2Seqs = new Set<number>();
121
+
122
+ for (const [key, g1] of groups1) {
123
+ const g2 = groups2.get(key);
124
+ if (!g2) continue;
125
+
126
+ // Match by relative position within group
127
+ for (let i = 0; i < g1.length; i++) {
128
+ // biome-ignore lint/style/noNonNullAssertion: bounded by loop condition
129
+ const r1 = g1[i]!;
130
+ if (i < g2.length) {
131
+ // biome-ignore lint/style/noNonNullAssertion: bounded by loop condition
132
+ const r2 = g2[i]!;
133
+ if (usedRun2Seqs.has(r2.seq)) continue;
134
+
135
+ let confidence = 0.7; // base for method+path match at same position
136
+ const bodySim = keySetSimilarity(jsonKeySet(r1.body), jsonKeySet(r2.body));
137
+ if (bodySim > 0) confidence = Math.min(1.0, confidence + bodySim * 0.3);
138
+
139
+ if (confidence >= 0.3) {
140
+ pairs.push({ originalSeq: r1.seq, replaySeq: r2.seq, confidence });
141
+ usedRun2Seqs.add(r2.seq);
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ return pairs;
148
+ }
149
+
150
+ // ─── Value extraction ───────────────────────────────────────────────────────
151
+
152
+ const SKIP_HEADERS = new Set([
153
+ 'user-agent',
154
+ 'accept',
155
+ 'accept-language',
156
+ 'accept-encoding',
157
+ 'connection',
158
+ 'host',
159
+ 'sec-fetch-dest',
160
+ 'sec-fetch-mode',
161
+ 'sec-fetch-site',
162
+ 'sec-ch-ua',
163
+ 'sec-ch-ua-mobile',
164
+ 'sec-ch-ua-platform',
165
+ 'upgrade-insecure-requests',
166
+ 'cache-control',
167
+ 'pragma',
168
+ 'content-length',
169
+ 'content-type',
170
+ 'origin',
171
+ 'referer',
172
+ ]);
173
+
174
+ interface ExtractedValue {
175
+ location: string;
176
+ value: string;
177
+ }
178
+
179
+ function extractUrlParams(url: string): ExtractedValue[] {
180
+ try {
181
+ const u = new URL(url);
182
+ const result: ExtractedValue[] = [];
183
+ for (const [key, val] of u.searchParams) {
184
+ result.push({ location: `url_param:${key}`, value: val });
185
+ }
186
+ return result;
187
+ } catch {
188
+ return [];
189
+ }
190
+ }
191
+
192
+ function extractHeaderValues(headers: Record<string, string>): ExtractedValue[] {
193
+ const result: ExtractedValue[] = [];
194
+ for (const [name, value] of Object.entries(headers)) {
195
+ if (!SKIP_HEADERS.has(name.toLowerCase())) {
196
+ result.push({ location: `header:${name}`, value });
197
+ }
198
+ }
199
+ return result;
200
+ }
201
+
202
+ function extractJsonBodyValues(body: string | undefined, prefix = 'body'): ExtractedValue[] {
203
+ if (!body) return [];
204
+ try {
205
+ const obj = JSON.parse(body);
206
+ return flattenObject(obj, prefix);
207
+ } catch {
208
+ return [{ location: prefix, value: body }];
209
+ }
210
+ }
211
+
212
+ function flattenObject(obj: unknown, prefix: string): ExtractedValue[] {
213
+ if (obj === null || obj === undefined) return [];
214
+ if (typeof obj !== 'object') {
215
+ return [{ location: prefix, value: String(obj) }];
216
+ }
217
+ const result: ExtractedValue[] = [];
218
+ if (Array.isArray(obj)) {
219
+ for (let i = 0; i < obj.length; i++) {
220
+ result.push(...flattenObject(obj[i], `${prefix}[${i}]`));
221
+ }
222
+ } else {
223
+ for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
224
+ result.push(...flattenObject(val, `${prefix}.${key}`));
225
+ }
226
+ }
227
+ return result;
228
+ }
229
+
230
+ function extractRequestValues(req: RequestLike): ExtractedValue[] {
231
+ return [
232
+ ...extractUrlParams(req.url),
233
+ ...extractHeaderValues(req.headers),
234
+ ...extractJsonBodyValues(req.body),
235
+ ];
236
+ }
237
+
238
+ // ─── Producer search ────────────────────────────────────────────────────────
239
+
240
+ interface ProducerMatch {
241
+ seq: number;
242
+ path: string;
243
+ }
244
+
245
+ function searchPriorResponses(
246
+ value: string,
247
+ requests: RequestLike[],
248
+ beforeSeq: number,
249
+ ): ProducerMatch | null {
250
+ if (value.length < 4) return null; // skip trivially short values
251
+
252
+ for (let i = requests.length - 1; i >= 0; i--) {
253
+ // biome-ignore lint/style/noNonNullAssertion: bounded by loop condition
254
+ const req = requests[i]!;
255
+ if (req.seq >= beforeSeq) continue;
256
+ const resp = req.response;
257
+ if (!resp) continue;
258
+
259
+ // Search response body
260
+ if (resp.body?.includes(value)) {
261
+ const path = findJsonPath(resp.body, value);
262
+ return { seq: req.seq, path: path ?? 'body(substring)' };
263
+ }
264
+
265
+ // Search response headers
266
+ for (const [hName, hVal] of Object.entries(resp.headers)) {
267
+ if (hVal.includes(value)) {
268
+ return { seq: req.seq, path: `response_header:${hName}` };
269
+ }
270
+ }
271
+ }
272
+ return null;
273
+ }
274
+
275
+ function findJsonPath(body: string, value: string): string | null {
276
+ try {
277
+ const obj = JSON.parse(body);
278
+ return findInObject(obj, value, '$');
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+
284
+ function findInObject(obj: unknown, target: string, path: string): string | null {
285
+ if (obj === null || obj === undefined) return null;
286
+ if (typeof obj === 'string' && obj === target) return path;
287
+ if (typeof obj === 'number' && String(obj) === target) return path;
288
+ if (typeof obj !== 'object') return null;
289
+
290
+ if (Array.isArray(obj)) {
291
+ for (let i = 0; i < obj.length; i++) {
292
+ const found = findInObject(obj[i], target, `${path}[${i}]`);
293
+ if (found) return found;
294
+ }
295
+ } else {
296
+ for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
297
+ const found = findInObject(val, target, `${path}.${key}`);
298
+ if (found) return found;
299
+ }
300
+ }
301
+ return null;
302
+ }
303
+
304
+ // ─── State name suggestion ──────────────────────────────────────────────────
305
+
306
+ function suggestStateName(location: string): string {
307
+ // url_param:context.correlationIdentifier → correlation_identifier
308
+ // header:x-csrf-token → csrf_token
309
+ // body.transaction.id → transaction_id
310
+ const raw = location.replace(/^(url_param|header|body):?/, '').replace(/^x-/, '');
311
+
312
+ return raw
313
+ .replace(/[.\[\]]/g, '_')
314
+ .replace(/([a-z])([A-Z])/g, '$1_$2')
315
+ .replace(/[^a-zA-Z0-9_]/g, '_')
316
+ .replace(/_+/g, '_')
317
+ .replace(/^_|_$/g, '')
318
+ .toLowerCase();
319
+ }
320
+
321
+ // ─── Main diff ──────────────────────────────────────────────────────────────
322
+
323
+ export function diffTriagedSessions(
324
+ original: Session,
325
+ replay: { requests: CapturedReplayRequest[] },
326
+ ): DiffResult {
327
+ const pairs = alignRequests(original.requests, replay.requests);
328
+ const pairedOrigSeqs = new Set(pairs.map((p) => p.originalSeq));
329
+ const pairedReplaySeqs = new Set(pairs.map((p) => p.replaySeq));
330
+
331
+ const classifications: ClassifiedValue[] = [];
332
+
333
+ for (const pair of pairs) {
334
+ if (pair.confidence < 0.5) continue;
335
+
336
+ const r1 = original.requests.find((r) => r.seq === pair.originalSeq);
337
+ const r2 = replay.requests.find((r) => r.seq === pair.replaySeq);
338
+ if (!r1 || !r2) continue;
339
+
340
+ const vals1 = extractRequestValues(r1);
341
+ const vals2 = extractRequestValues(r2);
342
+
343
+ const map2 = new Map(vals2.map((v) => [v.location, v.value]));
344
+
345
+ for (const v1 of vals1) {
346
+ const v2Value = map2.get(v1.location);
347
+ if (v2Value === undefined) continue; // field only in run 1
348
+
349
+ if (v1.value === v2Value) {
350
+ classifications.push({
351
+ classification: 'constant',
352
+ location: v1.location,
353
+ originalSeq: pair.originalSeq,
354
+ value1: v1.value,
355
+ value2: v2Value,
356
+ });
357
+ continue;
358
+ }
359
+
360
+ // Value differs — check if it came from a prior response in run 2
361
+ const producer = searchPriorResponses(v2Value, replay.requests, pair.replaySeq);
362
+
363
+ if (producer) {
364
+ const name = suggestStateName(v1.location);
365
+ classifications.push({
366
+ classification: 'server_derived',
367
+ location: v1.location,
368
+ originalSeq: pair.originalSeq,
369
+ value1: v1.value,
370
+ value2: v2Value,
371
+ producerSeq: producer.seq,
372
+ producerPath: producer.path,
373
+ suggestedStateName: name || undefined,
374
+ });
375
+ } else {
376
+ const name = suggestStateName(v1.location);
377
+ classifications.push({
378
+ classification: 'browser_minted',
379
+ location: v1.location,
380
+ originalSeq: pair.originalSeq,
381
+ value1: v1.value,
382
+ value2: v2Value,
383
+ suggestedStateName: name || undefined,
384
+ });
385
+ }
386
+ }
387
+ }
388
+
389
+ return {
390
+ classifications,
391
+ alignedPairs: pairs,
392
+ unmatchedOriginal: original.requests
393
+ .filter((r) => !pairedOrigSeqs.has(r.seq))
394
+ .map((r) => r.seq),
395
+ unmatchedReplay: replay.requests.filter((r) => !pairedReplaySeqs.has(r.seq)).map((r) => r.seq),
396
+ };
397
+ }
398
+
399
+ /**
400
+ * Triage run-2 requests by aligning them against run-1's already-triaged set.
401
+ * No narration or LLM call needed — the first triage acts as the oracle.
402
+ */
403
+ export function triageByAlignment(
404
+ run1TriagedRequests: CapturedRequest[],
405
+ run2AllRequests: CapturedReplayRequest[],
406
+ ): number[] {
407
+ const aligned = alignRequests(run1TriagedRequests, run2AllRequests);
408
+ return aligned.filter((pair) => pair.confidence >= 0.5).map((pair) => pair.replaySeq);
409
+ }