@cendor/cassette 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.
package/dist/index.js ADDED
@@ -0,0 +1,739 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ /**
3
+ * `@cendor/cassette` — record an agent run once, replay it forever. Offline, deterministic, free.
4
+ * The byte-conformant TypeScript port of `cendor.cassette`.
5
+ *
6
+ * The `vcrpy` of the agent era, except it captures the *whole* run: every LLM call and tool call,
7
+ * in order. It cooperates through `@cendor/core` — it never patches a client itself:
8
+ *
9
+ * - **record** — subscribes to the bus, capturing each `LLMCall`/`ToolCall` (request + the raw
10
+ * response core attaches) keyed by a normalized request hash, then writes a JSON cassette.
11
+ * - **replay** — registers a core *interceptor* that returns the recorded response by hash before
12
+ * the real call runs. Unknown call -> clear failure.
13
+ *
14
+ * Secrets/PII are redacted on record (cassettes get committed). `semanticMatch` asserts *meaning*
15
+ * for output that won't be byte-identical: a lexical default (offline, zero-dep), or a bring-your-own
16
+ * `embedFn` (`embeddingScorer`) that wraps any provider's embeddings.
17
+ *
18
+ * Cross-language conformance rules (mirrors the Python package byte-for-byte):
19
+ * - the hash is `sha256Hex(canonical(request))` over the **un-redacted** normalized request;
20
+ * - wire keys are snake_case (`request_hash`, `response_type`, `input_tokens`, ...);
21
+ * - the file is `json.dumps(indent=2, ensure_ascii=False)` (via {@link dumpsIndent2}) with no
22
+ * trailing newline, insertion-order keys, and the 6-field entry order.
23
+ */
24
+ import { LLMCall, MISS, ToolCall, addInterceptor, bus, removeInterceptor } from '@cendor/core';
25
+ import { sha256Hex } from './hash.js';
26
+ import { PyFloat, canonical, dumpsIndent2, parsePreserving } from './pyjson.js';
27
+ import { resolveStorage } from './storage.js';
28
+ // --------------------------------------------------------------------------- version + globals
29
+ /** Current cassette format. v2 folds `stream` into the request hash and records a `response_type`
30
+ * marker; v1 (no `stream` in the hash, no marker) is still readable on replay. */
31
+ const FORMAT_VERSION = 2;
32
+ const SUPPORTED_VERSIONS = [1, 2];
33
+ /** Divergences found by the most recent `mode: 'rerecord'` run. Mutated in place (never reassigned)
34
+ * so external references (and {@link drift}) stay live — mirrors Python's module-global `_drift`. */
35
+ export const _drift = [];
36
+ /** Marks which record/replay context an event belongs to, so concurrent `using()` blocks on the
37
+ * process-global bus don't capture each other's events (Python's `_active_session` ContextVar). */
38
+ const activeSession = new AsyncLocalStorage();
39
+ function uuidHex() {
40
+ return globalThis.crypto.randomUUID().replace(/-/g, '');
41
+ }
42
+ /** Raised on replay when a call has no matching recorded entry, or on an unreadable cassette. */
43
+ export class CassetteError extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = 'CassetteError';
47
+ }
48
+ }
49
+ /** One recorded interaction in a run. Field order is load-bearing — it is the on-disk key order. */
50
+ export class CassetteEntry {
51
+ seq;
52
+ kind;
53
+ request_hash;
54
+ request;
55
+ response;
56
+ response_type;
57
+ constructor(seq, kind, request_hash, request, response, response_type = 'object') {
58
+ this.seq = seq;
59
+ this.kind = kind;
60
+ this.request_hash = request_hash;
61
+ this.request = request;
62
+ this.response = response;
63
+ this.response_type = response_type;
64
+ }
65
+ }
66
+ function entryToJson(e) {
67
+ // Explicit insertion order = the six-field on-disk order (seq, kind, request_hash, request,
68
+ // response, response_type). dumpsIndent2 preserves it.
69
+ return {
70
+ seq: e.seq,
71
+ kind: e.kind,
72
+ request_hash: e.request_hash,
73
+ request: e.request,
74
+ response: e.response,
75
+ response_type: e.response_type,
76
+ };
77
+ }
78
+ // --------------------------------------------------------------------------- redaction
79
+ /** Ordered redaction patterns. Ported verbatim from Python's `_REDACTIONS`; each `sub()` runs on
80
+ * the prior result. `g` = replace-all (Python `re.sub`); NO `u` flag so `\b`/`\w` keep ASCII
81
+ * semantics matching Python's on these patterns. `[Bb]earer` only (all-caps BEARER survives). */
82
+ const REDACTIONS = [
83
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, // email
84
+ /\bsk-[A-Za-z0-9_-]{8,}/g, // openai keys: sk-, sk-ant-…, sk-proj-…, legacy
85
+ /\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
86
+ /\bAIza[0-9A-Za-z_-]{35}\b/g, // Google API key
87
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, // bare JWT
88
+ /\b[Bb]earer\s+[A-Za-z0-9._-]+\b/g, // bearer tokens
89
+ /\b[A-Za-z0-9_-]{32,}\b/g, // long opaque tokens
90
+ ];
91
+ /** The built-in scrubber. Strings run through every pattern in order; dict *keys* are never
92
+ * scrubbed (only values); lists recurse; all other leaves (numbers/bool/null) pass through. */
93
+ function builtinRedact(obj) {
94
+ if (typeof obj === 'string') {
95
+ let out = obj;
96
+ for (const pat of REDACTIONS)
97
+ out = out.replace(pat, '<redacted>');
98
+ return out;
99
+ }
100
+ if (Array.isArray(obj))
101
+ return obj.map(builtinRedact);
102
+ if (obj !== null && typeof obj === 'object' && !(obj instanceof PyFloat)) {
103
+ const o = {};
104
+ for (const [k, v] of Object.entries(obj))
105
+ o[k] = builtinRedact(v);
106
+ return o;
107
+ }
108
+ return obj;
109
+ }
110
+ function resolveRedactor(redact) {
111
+ if (redact === true)
112
+ return builtinRedact;
113
+ if (redact === false)
114
+ return (obj) => obj;
115
+ if (typeof redact === 'function')
116
+ return redact;
117
+ throw new TypeError('redact must be true, false, or a callable (obj -> obj)');
118
+ }
119
+ /** The built-in scrubber (Python's `_redact`), exported for conformance vectors. */
120
+ export function _redact(obj) {
121
+ return builtinRedact(obj);
122
+ }
123
+ // --------------------------------------------------------------------------- serialization
124
+ function pyStrKey(k) {
125
+ if (typeof k === 'string')
126
+ return k;
127
+ if (typeof k === 'boolean')
128
+ return k ? 'True' : 'False';
129
+ if (k === null || k === undefined)
130
+ return 'None';
131
+ if (typeof k === 'bigint')
132
+ return k.toString();
133
+ if (k instanceof PyFloat)
134
+ return String(k.value);
135
+ if (typeof k === 'number')
136
+ return Number.isInteger(k) ? String(k) : String(k);
137
+ return String(k);
138
+ }
139
+ function isPlainObject(obj) {
140
+ const proto = Object.getPrototypeOf(obj);
141
+ return proto === Object.prototype || proto === null;
142
+ }
143
+ /** Coerce any value to a JSON-serializable {@link PyValue}, mirroring Python's `_to_jsonable`:
144
+ * None/bool/number/str pass through; dict-like (plain object / Map) recurses with stringified keys;
145
+ * arrays recurse; SDK-like objects try `model_dump`/`dict`/`to_dict` then own properties; else str. */
146
+ function toJsonable(obj) {
147
+ if (obj === null || obj === undefined)
148
+ return null;
149
+ const t = typeof obj;
150
+ if (t === 'boolean' || t === 'string' || t === 'number' || t === 'bigint')
151
+ return obj;
152
+ if (obj instanceof PyFloat)
153
+ return obj;
154
+ if (Array.isArray(obj))
155
+ return obj.map(toJsonable);
156
+ if (obj instanceof Map) {
157
+ const o = {};
158
+ for (const [k, v] of obj)
159
+ o[pyStrKey(k)] = toJsonable(v);
160
+ return o;
161
+ }
162
+ if (t === 'object') {
163
+ // dict-like (plain object) — keys already strings, recurse values (Python's `dict` branch).
164
+ if (isPlainObject(obj)) {
165
+ const o = {};
166
+ for (const [k, v] of Object.entries(obj))
167
+ o[k] = toJsonable(v);
168
+ return o;
169
+ }
170
+ // SDK-like object: try dump methods in order, swallowing failures, then own enumerable props.
171
+ for (const attr of ['model_dump', 'dict', 'to_dict']) {
172
+ const method = obj[attr];
173
+ if (typeof method === 'function') {
174
+ try {
175
+ return toJsonable(method.call(obj));
176
+ }
177
+ catch {
178
+ // best-effort serialization; fall through to the next candidate
179
+ }
180
+ }
181
+ }
182
+ const o = {};
183
+ for (const [k, v] of Object.entries(obj))
184
+ o[k] = toJsonable(v);
185
+ return o;
186
+ }
187
+ return pyStr(obj);
188
+ }
189
+ function pyStr(obj) {
190
+ return String(obj);
191
+ }
192
+ /** Convert a parsed {@link PyValue} back into plain JS (bigint/PyFloat -> number) for hand-off to
193
+ * the caller on replay. In JS a plain object is both attribute- and index-accessible, so the
194
+ * `object`/`mapping` reconstruction distinction is a no-op (JS3 brief rule 4); the marker is still
195
+ * recorded for fidelity. */
196
+ function fromPyValue(v) {
197
+ if (v === null)
198
+ return null;
199
+ if (typeof v === 'bigint')
200
+ return Number(v);
201
+ if (v instanceof PyFloat)
202
+ return v.value;
203
+ if (Array.isArray(v))
204
+ return v.map(fromPyValue);
205
+ if (typeof v === 'object') {
206
+ const o = {};
207
+ for (const [k, val] of Object.entries(v))
208
+ o[k] = fromPyValue(val);
209
+ return o;
210
+ }
211
+ return v;
212
+ }
213
+ /** `"mapping"` if the raw response is dict-like (plain object / Map), else `"object"` (SDK object,
214
+ * array/stream, primitive). Inspected on the **raw** object before coercion. */
215
+ function responseMarker(raw) {
216
+ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
217
+ if (raw instanceof Map)
218
+ return 'mapping';
219
+ if (isPlainObject(raw))
220
+ return 'mapping';
221
+ }
222
+ return 'object';
223
+ }
224
+ // --------------------------------------------------------------------------- hashing
225
+ /** Canonicalize tool arguments to the `{args, kwargs}` shape core's tool wrapper produces live, so a
226
+ * promoted/hand-written trace hashes identically to the live call. */
227
+ function canonicalToolArguments(args) {
228
+ if (args !== null &&
229
+ typeof args === 'object' &&
230
+ !Array.isArray(args) &&
231
+ !(args instanceof PyFloat)) {
232
+ const rec = args;
233
+ if ('args' in rec || 'kwargs' in rec) {
234
+ return {
235
+ args: toJsonable('args' in rec ? rec.args : []),
236
+ kwargs: toJsonable('kwargs' in rec ? rec.kwargs : {}),
237
+ };
238
+ }
239
+ return { args: [], kwargs: toJsonable(rec) };
240
+ }
241
+ if (Array.isArray(args))
242
+ return { args: toJsonable(args), kwargs: {} };
243
+ return { args: [toJsonable(args)], kwargs: {} };
244
+ }
245
+ /** Build the normalized request that the hash is taken over. Un-redacted and jsonable so hashing
246
+ * never trips on SDK objects. `includeStream` is v2 behavior (v1 omits `stream`). */
247
+ export function _normalizedRequest(event, includeStream = true) {
248
+ if (event instanceof LLMCall) {
249
+ const req = {
250
+ kind: 'llm',
251
+ provider: event.provider,
252
+ model: event.model,
253
+ messages: toJsonable(event.messages),
254
+ };
255
+ if (includeStream) {
256
+ const kwargs = event.metadata?.request_kwargs ?? {};
257
+ req.stream = Boolean(kwargs.stream);
258
+ }
259
+ return req;
260
+ }
261
+ if (event instanceof ToolCall) {
262
+ return {
263
+ kind: 'tool',
264
+ name: event.name,
265
+ arguments: canonicalToolArguments(event.arguments),
266
+ };
267
+ }
268
+ // Non LLM/Tool events are filtered before norm() is ever called (record/replay both guard).
269
+ throw new CassetteError('cannot normalize a non-LLM/Tool event');
270
+ }
271
+ /** `sha256Hex(canonical(request))` — the exact bytes Python hashes. */
272
+ export function _hash(request) {
273
+ return sha256Hex(canonical(request));
274
+ }
275
+ function defaultNormalizer(version) {
276
+ return (event) => _normalizedRequest(event, version >= 2);
277
+ }
278
+ function loadCassette(storage, name) {
279
+ const text = storage.read();
280
+ if (text === null) {
281
+ throw new CassetteError(`cannot read cassette ${name}: not found`);
282
+ }
283
+ let payload;
284
+ try {
285
+ payload = parsePreserving(text);
286
+ }
287
+ catch (e) {
288
+ throw new CassetteError(`cannot read cassette ${name}: ${e.message}`);
289
+ }
290
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
291
+ throw new CassetteError(`cannot read cassette ${name}: not a cassette object`);
292
+ }
293
+ const obj = payload;
294
+ const version = 'version' in obj ? Number(obj.version) : 1;
295
+ if (version !== 1 && version !== 2) {
296
+ throw new CassetteError(`unsupported cassette format version ${version} in ${name}; this cendor-cassette supports ` +
297
+ `versions (${SUPPORTED_VERSIONS.join(', ')}) — upgrade the package or re-record the cassette`);
298
+ }
299
+ const rawEntries = obj.entries;
300
+ const entries = Array.isArray(rawEntries) ? rawEntries : [];
301
+ return { version, entries };
302
+ }
303
+ function buildByHash(entries) {
304
+ const byHash = new Map();
305
+ for (const entry of entries) {
306
+ const h = String(entry.request_hash);
307
+ const bucket = byHash.get(h);
308
+ if (bucket)
309
+ bucket.push(entry);
310
+ else
311
+ byHash.set(h, [entry]);
312
+ }
313
+ return byHash;
314
+ }
315
+ // --------------------------------------------------------------------------- record / replay / rerecord
316
+ async function recording(storage, normalizer, redactor, body) {
317
+ const norm = normalizer ?? ((event) => _normalizedRequest(event, true));
318
+ const entries = [];
319
+ const session = uuidHex();
320
+ const recorder = (event) => {
321
+ if (activeSession.getStore() !== session)
322
+ return;
323
+ let response;
324
+ let marker;
325
+ let kind;
326
+ if (event instanceof LLMCall) {
327
+ const raw = event.metadata.response;
328
+ response = redactor(toJsonable(raw));
329
+ marker = responseMarker(raw);
330
+ kind = 'llm';
331
+ }
332
+ else if (event instanceof ToolCall) {
333
+ response = redactor(toJsonable(event.result));
334
+ marker = responseMarker(event.result);
335
+ kind = 'tool';
336
+ }
337
+ else {
338
+ return;
339
+ }
340
+ const request = norm(event); // un-redacted: this is the matching key
341
+ entries.push(new CassetteEntry(entries.length, kind, _hash(request), redactor(request), response, marker));
342
+ };
343
+ bus.subscribe(recorder);
344
+ try {
345
+ return await activeSession.run(session, body);
346
+ }
347
+ finally {
348
+ bus.unsubscribe(recorder);
349
+ const payload = { version: FORMAT_VERSION, entries: entries.map(entryToJson) };
350
+ storage.write(dumpsIndent2(payload));
351
+ }
352
+ }
353
+ async function replaying(storage, normalizer, name, body) {
354
+ const { version, entries } = loadCassette(storage, name);
355
+ const norm = normalizer ?? defaultNormalizer(version);
356
+ const byHash = buildByHash(entries);
357
+ const cursor = new Map(); // per-replay context, keyed by hash
358
+ const session = uuidHex();
359
+ const interceptor = (event) => {
360
+ if (activeSession.getStore() !== session)
361
+ return MISS; // another replay context — decline
362
+ const request = norm(event);
363
+ const h = _hash(request);
364
+ const queue = byHash.get(h) ?? [];
365
+ const i = cursor.get(h) ?? 0;
366
+ if (i >= queue.length) {
367
+ const kind = request !== null && typeof request === 'object' && !Array.isArray(request)
368
+ ? request.kind
369
+ : undefined;
370
+ throw new CassetteError(`no recorded response for ${String(kind)} request (hash ${h.slice(0, 12)}…) in ${name}; re-record the cassette`);
371
+ }
372
+ cursor.set(h, i + 1); // FIFO per hash
373
+ const entry = queue[i];
374
+ // In JS a plain object is both attribute- and index-accessible, so LLM ("object"/"mapping") and
375
+ // tool responses all reconstruct to the same deep plain value (JS3 brief rule 4).
376
+ return fromPyValue(entry.response ?? null);
377
+ };
378
+ addInterceptor(interceptor);
379
+ try {
380
+ return await activeSession.run(session, body);
381
+ }
382
+ finally {
383
+ removeInterceptor(interceptor);
384
+ }
385
+ }
386
+ async function rerecording(storage, normalizer, redactor, name, body) {
387
+ _drift.length = 0;
388
+ const loaded = storage.exists()
389
+ ? loadCassette(storage, name)
390
+ : { version: FORMAT_VERSION, entries: [] };
391
+ const norm = normalizer ?? defaultNormalizer(loaded.version);
392
+ const byHash = buildByHash(loaded.entries);
393
+ const cursor = new Map();
394
+ const session = uuidHex();
395
+ const recorder = (event) => {
396
+ if (activeSession.getStore() !== session)
397
+ return;
398
+ let live;
399
+ let kind;
400
+ if (event instanceof LLMCall) {
401
+ live = redactor(toJsonable(event.metadata.response));
402
+ kind = 'llm';
403
+ }
404
+ else if (event instanceof ToolCall) {
405
+ live = redactor(toJsonable(event.result));
406
+ kind = 'tool';
407
+ }
408
+ else {
409
+ return;
410
+ }
411
+ const h = _hash(norm(event));
412
+ const queue = byHash.get(h) ?? [];
413
+ const i = cursor.get(h) ?? 0;
414
+ cursor.set(h, i + 1);
415
+ const recorded = i < queue.length ? fromPyValue(queue[i].response ?? null) : null;
416
+ const livePlain = fromPyValue(live);
417
+ if (!deepEqual(recorded, livePlain)) {
418
+ _drift.push({ request_hash: h, kind, recorded, live: livePlain });
419
+ }
420
+ };
421
+ bus.subscribe(recorder);
422
+ try {
423
+ return await activeSession.run(session, body);
424
+ }
425
+ finally {
426
+ bus.unsubscribe(recorder);
427
+ // rerecord never overwrites the cassette.
428
+ }
429
+ }
430
+ /** Structural deep-equal on jsonable values (Python's `!=` on the coerced structures). */
431
+ function deepEqual(a, b) {
432
+ if (a === b)
433
+ return true;
434
+ if (a === null || b === null || typeof a !== typeof b)
435
+ return false;
436
+ if (Array.isArray(a) || Array.isArray(b)) {
437
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
438
+ return false;
439
+ for (let i = 0; i < a.length; i++)
440
+ if (!deepEqual(a[i], b[i]))
441
+ return false;
442
+ return true;
443
+ }
444
+ if (typeof a === 'object' && typeof b === 'object') {
445
+ const ka = Object.keys(a);
446
+ const kb = Object.keys(b);
447
+ if (ka.length !== kb.length)
448
+ return false;
449
+ for (const k of ka) {
450
+ if (!Object.hasOwn(b, k))
451
+ return false;
452
+ if (!deepEqual(a[k], b[k])) {
453
+ return false;
454
+ }
455
+ }
456
+ return true;
457
+ }
458
+ return false;
459
+ }
460
+ function targetName(target) {
461
+ return typeof target === 'string' ? target.replace(/^.*[\\/]/, '') : 'cassette';
462
+ }
463
+ async function runManaged(target, options, body) {
464
+ const storage = resolveStorage(target);
465
+ const name = targetName(target);
466
+ const mode = options.mode ?? 'auto';
467
+ let effective = mode;
468
+ if (mode === 'auto')
469
+ effective = storage.exists() ? 'replay' : 'record';
470
+ const normalizer = options.normalizer ?? null;
471
+ if (effective === 'replay')
472
+ return replaying(storage, normalizer, name, body);
473
+ if (effective === 'rerecord') {
474
+ return rerecording(storage, normalizer, resolveRedactor(options.redact ?? true), name, body);
475
+ }
476
+ return recording(storage, normalizer, resolveRedactor(options.redact ?? true), body);
477
+ }
478
+ export function using(target, optionsOrBody, maybeBody) {
479
+ const options = typeof optionsOrBody === 'function' ? {} : optionsOrBody;
480
+ const body = typeof optionsOrBody === 'function' ? optionsOrBody : maybeBody;
481
+ if (typeof body !== 'function')
482
+ throw new TypeError('using() requires a body callback');
483
+ return runManaged(target, options, body);
484
+ }
485
+ /**
486
+ * Decorator form: record the wrapped run on first use, replay it thereafter. The returned wrapper
487
+ * is async (the instrumented client calls are async in TS).
488
+ *
489
+ * ```ts
490
+ * const runAgent = use('run.json')(async () => { ... });
491
+ * await runAgent();
492
+ * ```
493
+ */
494
+ export function use(target, options = {}) {
495
+ return (fn) => (...args) => runManaged(target, options, () => fn(...args));
496
+ }
497
+ // --------------------------------------------------------------------------- promote
498
+ /** Build the same un-redacted normalized request `_normalizedRequest` derives live — including the
499
+ * v2 `stream` flag and the canonical `{args, kwargs}` tool shape. */
500
+ function normalizedRequestFrom(kind, request) {
501
+ if (kind === 'llm') {
502
+ return {
503
+ kind: 'llm',
504
+ provider: 'provider' in request ? request.provider : null,
505
+ model: 'model' in request ? request.model : null,
506
+ messages: toJsonable('messages' in request ? request.messages : []),
507
+ stream: pyTruthy('stream' in request ? request.stream : false),
508
+ };
509
+ }
510
+ return {
511
+ kind: 'tool',
512
+ name: 'name' in request ? request.name : null,
513
+ arguments: canonicalToolArguments('arguments' in request ? request.arguments : {}),
514
+ };
515
+ }
516
+ function pyTruthy(v) {
517
+ if (v === null || v === false)
518
+ return false;
519
+ if (typeof v === 'bigint')
520
+ return v !== 0n;
521
+ if (v instanceof PyFloat)
522
+ return v.value !== 0;
523
+ if (typeof v === 'number')
524
+ return v !== 0;
525
+ if (typeof v === 'string')
526
+ return v.length > 0;
527
+ if (Array.isArray(v))
528
+ return v.length > 0;
529
+ if (typeof v === 'object')
530
+ return Object.keys(v).length > 0;
531
+ return Boolean(v);
532
+ }
533
+ /**
534
+ * Convert a JSONL call trace into a replayable cassette. Each line is
535
+ * `{kind: 'llm'|'tool', request: {...}, response|result: ...}`; `_meta` and unrecognized lines are
536
+ * skipped. Returns the number of entries written. Always writes v2; `response_type` is `"object"`
537
+ * for every promoted entry (a promote asymmetry vs. live recording — parity with Python).
538
+ */
539
+ export function promote(tracePath, to, redact = true) {
540
+ const redactor = resolveRedactor(redact);
541
+ const src = resolveStorage(tracePath);
542
+ const text = src.read();
543
+ const entries = [];
544
+ if (text !== null) {
545
+ for (const rawLine of text.split(/\r?\n/)) {
546
+ const line = rawLine.trim();
547
+ if (!line)
548
+ continue;
549
+ let row;
550
+ try {
551
+ row = parsePreserving(line);
552
+ }
553
+ catch {
554
+ continue;
555
+ }
556
+ if (row === null || typeof row !== 'object' || Array.isArray(row))
557
+ continue;
558
+ const obj = row;
559
+ if ('_meta' in obj)
560
+ continue;
561
+ const kind = obj.kind;
562
+ const request = 'request' in obj ? obj.request : undefined;
563
+ if ((kind !== 'llm' && kind !== 'tool') ||
564
+ request === null ||
565
+ request === undefined ||
566
+ typeof request !== 'object' ||
567
+ Array.isArray(request)) {
568
+ continue;
569
+ }
570
+ let response;
571
+ if ('response' in obj)
572
+ response = obj.response;
573
+ else if ('result' in obj)
574
+ response = obj.result;
575
+ else
576
+ response = null;
577
+ const norm = normalizedRequestFrom(kind, request);
578
+ entries.push(new CassetteEntry(entries.length, kind, _hash(norm), redactor(norm), redactor(response)));
579
+ }
580
+ }
581
+ const payload = { version: FORMAT_VERSION, entries: entries.map(entryToJson) };
582
+ resolveStorage(to).write(dumpsIndent2(payload));
583
+ return entries.length;
584
+ }
585
+ // --------------------------------------------------------------------------- drift
586
+ /** Divergences from the most recent `rerecord` run (a copy). */
587
+ export function drift() {
588
+ return [..._drift];
589
+ }
590
+ /** Filter the last `rerecord` run's byte-level {@link drift} to *meaningful* divergences: keep only
591
+ * those scoring **below** `threshold` (strict `<`), attaching the `score`. */
592
+ export function semanticDrift(threshold = 0.8, scorer) {
593
+ const scoreFn = scorer ?? lexicalScore;
594
+ const out = [];
595
+ for (const d of _drift) {
596
+ const score = scoreFn(driftText(d.recorded), driftText(d.live));
597
+ if (score < threshold)
598
+ out.push({ ...d, score });
599
+ }
600
+ return out;
601
+ }
602
+ function driftText(obj) {
603
+ if (obj === null || obj === undefined)
604
+ return '';
605
+ if (typeof obj === 'string')
606
+ return obj;
607
+ // Python: json.dumps(_to_jsonable(obj), sort_keys=True, ensure_ascii=False) — DEFAULT separators.
608
+ return dumpsSortedDefault(toJsonable(obj));
609
+ }
610
+ /** `json.dumps(sort_keys=True, ensure_ascii=False)` — sorted keys, `", "`/`": "` separators. Used
611
+ * only to build the text fed to the scorer (never serialized to disk). */
612
+ function dumpsSortedDefault(v) {
613
+ if (v === null)
614
+ return 'null';
615
+ if (typeof v === 'boolean')
616
+ return v ? 'true' : 'false';
617
+ if (typeof v === 'string')
618
+ return JSON.stringify(v);
619
+ if (typeof v === 'bigint')
620
+ return v.toString();
621
+ if (v instanceof PyFloat)
622
+ return String(v.value);
623
+ if (typeof v === 'number')
624
+ return Number.isInteger(v) ? String(v) : String(v);
625
+ if (Array.isArray(v))
626
+ return `[${v.map(dumpsSortedDefault).join(', ')}]`;
627
+ const keys = Object.keys(v).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
628
+ return `{${keys.map((k) => `${JSON.stringify(k)}: ${dumpsSortedDefault(v[k])}`).join(', ')}}`;
629
+ }
630
+ // --------------------------------------------------------------------------- semantic match
631
+ const WORD = /[a-z0-9']+/g;
632
+ function normText(text) {
633
+ const matches = text.toLowerCase().match(WORD);
634
+ return matches ? matches.join(' ') : '';
635
+ }
636
+ /** The default offline similarity score in [0, 1]: max(sequence ratio, keyword containment). */
637
+ export function lexicalScore(actual, expected) {
638
+ const a = normText(actual);
639
+ const e = normText(expected);
640
+ if (!e)
641
+ return 1.0;
642
+ const ratio = sequenceRatio(a, e);
643
+ const aTokens = new Set(a.split(' ').filter(Boolean));
644
+ const eTokens = new Set(e.split(' ').filter(Boolean));
645
+ let shared = 0;
646
+ for (const tok of eTokens)
647
+ if (aTokens.has(tok))
648
+ shared++;
649
+ const containment = eTokens.size > 0 ? shared / eTokens.size : 1.0;
650
+ return Math.max(ratio, containment);
651
+ }
652
+ /** Ratcliff/Obershelp similarity `2*M/T` (difflib `SequenceMatcher.ratio()`). Behavioral parity for
653
+ * `semanticMatch` only — never serialized, so byte-exact difflib autojunk parity is not required. */
654
+ function sequenceRatio(a, b) {
655
+ const total = a.length + b.length;
656
+ if (total === 0)
657
+ return 1.0;
658
+ return (2 * matchingBlocks(a, b)) / total;
659
+ }
660
+ function matchingBlocks(a, b) {
661
+ if (a.length === 0 || b.length === 0)
662
+ return 0;
663
+ // Longest common contiguous substring, then recurse on the left and right remainders.
664
+ let bestI = 0;
665
+ let bestJ = 0;
666
+ let bestLen = 0;
667
+ let prev = new Array(b.length + 1).fill(0);
668
+ for (let i = 1; i <= a.length; i++) {
669
+ const curr = new Array(b.length + 1).fill(0);
670
+ for (let j = 1; j <= b.length; j++) {
671
+ if (a[i - 1] === b[j - 1]) {
672
+ const len = prev[j - 1] + 1;
673
+ curr[j] = len;
674
+ if (len > bestLen) {
675
+ bestLen = len;
676
+ bestI = i - len;
677
+ bestJ = j - len;
678
+ }
679
+ }
680
+ }
681
+ prev = curr;
682
+ }
683
+ if (bestLen === 0)
684
+ return 0;
685
+ return (bestLen +
686
+ matchingBlocks(a.slice(0, bestI), b.slice(0, bestJ)) +
687
+ matchingBlocks(a.slice(bestI + bestLen), b.slice(bestJ + bestLen)));
688
+ }
689
+ /** Assert `actual` means roughly `expected`. Lexical default (offline, deterministic). Inclusive
690
+ * `>=` against `threshold`. */
691
+ export function semanticMatch(actual, expected, threshold = 0.6, scorer) {
692
+ const score = (scorer ?? lexicalScore)(actual, expected);
693
+ return score >= threshold;
694
+ }
695
+ // --------------------------------------------------------------------------- embedding scorers
696
+ /** Cosine similarity of two equal-length vectors, in [-1, 1] (0 for empty/degenerate input). */
697
+ export function cosine(a, b) {
698
+ if (a.length === 0 || b.length === 0 || a.length !== b.length)
699
+ return 0.0;
700
+ let dot = 0;
701
+ let na = 0;
702
+ let nb = 0;
703
+ for (let i = 0; i < a.length; i++) {
704
+ const x = a[i];
705
+ const y = b[i];
706
+ dot += x * y;
707
+ na += x * x;
708
+ nb += y * y;
709
+ }
710
+ na = Math.sqrt(na);
711
+ nb = Math.sqrt(nb);
712
+ if (na === 0.0 || nb === 0.0)
713
+ return 0.0;
714
+ return dot / (na * nb);
715
+ }
716
+ /** Build a {@link semanticMatch} scorer from any embedder — the bring-your-own-model path. */
717
+ export function embeddingScorer(embedFn) {
718
+ return (actual, expected) => {
719
+ const vecs = embedFn([actual, expected]);
720
+ if (!vecs || vecs.length < 2)
721
+ return 0.0;
722
+ return Math.max(0.0, cosine([...vecs[0]], [...vecs[1]]));
723
+ };
724
+ }
725
+ /** Placeholder for the model2vec-backed scorer. No pure-JS static-embedding model ships here; wire
726
+ * your own via {@link embeddingScorer}. */
727
+ export function localEmbeddingScorer(_model = 'minishlab/potion-base-8M') {
728
+ throw new Error('localEmbeddingScorer needs a static-embedding model that is not bundled in JS. ' +
729
+ 'Pass your own embedFn to embeddingScorer() (e.g. wrapping a local or hosted model).');
730
+ }
731
+ /** An embedding scorer over an already-constructed OpenAI-shaped `client` (no SDK import). */
732
+ export function openaiEmbeddingScorer(client, model = 'text-embedding-3-small') {
733
+ const embedFn = (texts) => {
734
+ const resp = client.embeddings.create({ model, input: [...texts] });
735
+ return resp.data.map((item) => [...item.embedding]);
736
+ };
737
+ return embeddingScorer(embedFn);
738
+ }
739
+ //# sourceMappingURL=index.js.map