{"version":3,"file":"vcr.mjs","names":[],"sources":["../../../../../../../ai/src/vcr/vcr.ts"],"sourcesContent":["import type { Message } from \"../contracts/conversation-message.type\";\nimport type {\n  ModelCallOptions,\n  ModelCapabilities,\n  ModelContract,\n  ModelResponse,\n  ModelStreamChunk,\n} from \"../contracts/model.contract\";\nimport type { ModelPricing } from \"../contracts/result/model-pricing.type\";\nimport { redact } from \"../security/redact\";\nimport { emptyCassette, loadCassette, saveCassette } from \"./cassette-io\";\nimport { VcrCassetteMissError } from \"./errors\";\nimport { DEFAULT_HASH_OPTIONS, hashRequest } from \"./hash-request\";\nimport type { Cassette, CassetteEntry, VcrMode, VcrModel, VcrOptions } from \"./vcr.type\";\n\n/**\n * Internal decorator that wraps an inner `ModelContract`, intercepting only\n * `complete()`/`stream()` and delegating every identity getter to the inner\n * model. Drives the record/replay state machine over a single in-memory\n * {@link Cassette}.\n *\n * **Why a class.** It holds mutable per-instance state (the loaded cassette,\n * the dirty flag, the load promise) behind a stable `ModelContract` surface;\n * the public API is the `vcr()` factory, never `new`.\n */\nclass Vcr implements VcrModel {\n  private readonly mode: VcrMode;\n  private readonly path: string;\n  private readonly hashOptions: readonly string[];\n\n  /** Loaded + newly recorded entries. Mutated in place as we record. */\n  private loadedCassette: Cassette;\n\n  /** Set when an entry is recorded so `save()` knows there's work to flush. */\n  private dirty = false;\n\n  /** One-shot lazy load of the on-disk cassette, shared across calls. */\n  private loadPromise: Promise<void> | undefined;\n\n  /** Persisted-body privacy controls (S2). */\n  private readonly recordRequest: NonNullable<VcrOptions[\"recordRequest\"]>;\n  private readonly redactRequestHook: VcrOptions[\"redactRequest\"];\n  private readonly redactResponseHook: VcrOptions[\"redactResponse\"];\n  private readonly redactErrorHook: VcrOptions[\"redactError\"];\n\n  /** Verbatim-recording warning fires at most once per instance. */\n  private warnedVerbatim = false;\n\n  public constructor(\n    private readonly inner: ModelContract,\n    options: VcrOptions,\n  ) {\n    this.path = options.path;\n    this.mode = options.mode ?? \"auto\";\n    this.hashOptions = options.hashOptions ?? DEFAULT_HASH_OPTIONS;\n    this.recordRequest = options.recordRequest ?? \"verbatim\";\n    this.redactRequestHook = options.redactRequest;\n    this.redactResponseHook = options.redactResponse;\n    this.redactErrorHook = options.redactError;\n    this.loadedCassette = emptyCassette(inner.name, inner.provider);\n  }\n\n  /** Inner model identifier — delegated verbatim. */\n  public get name(): string {\n    return this.inner.name;\n  }\n\n  /** Inner provider — delegated verbatim. */\n  public get provider(): string {\n    return this.inner.provider;\n  }\n\n  /** Inner capability flags — delegated verbatim. */\n  public get capabilities(): ModelCapabilities | undefined {\n    return this.inner.capabilities;\n  }\n\n  /** Inner pricing — delegated verbatim so cost accounting is unchanged. */\n  public get pricing(): ModelPricing | undefined {\n    return this.inner.pricing;\n  }\n\n  /** Loaded/recorded cassette, exposed for assertions. */\n  public get cassette(): Cassette {\n    return this.loadedCassette;\n  }\n\n  /**\n   * Load the on-disk cassette exactly once. Pure `record` mode skips the\n   * read — it always writes fresh — but the in-memory cassette still starts\n   * empty so a record run never accidentally replays a stale entry.\n   */\n  private async ensureLoaded(): Promise<void> {\n    if (this.loadPromise) {\n      return this.loadPromise;\n    }\n\n    this.loadPromise =\n      this.mode === \"record\"\n        ? Promise.resolve()\n        : (async () => {\n            this.loadedCassette = await loadCassette(\n              this.path,\n              this.inner.name,\n              this.inner.provider,\n            );\n          })();\n\n    return this.loadPromise;\n  }\n\n  /** Find a recorded entry whose hash matches the current request. */\n  private findEntry(hash: string): CassetteEntry | undefined {\n    return this.loadedCassette.entries.find((entry) => entry.requestHash === hash);\n  }\n\n  /** Re-throw a recorded error by reconstructing a plain `Error`. */\n  private throwRecordedError(entry: CassetteEntry): never {\n    const error = new Error(entry.error?.message ?? \"Recorded error\");\n\n    error.name = entry.error?.name ?? \"Error\";\n\n    throw error;\n  }\n\n  /**\n   * Non-streaming call. In `replay` a miss throws; in `auto`/`record` a miss\n   * calls the inner model and records the outcome (response or error).\n   */\n  public async complete(messages: Message[], options?: ModelCallOptions): Promise<ModelResponse> {\n    await this.ensureLoaded();\n\n    const hash = hashRequest(messages, options, this.hashOptions);\n\n    if (this.mode !== \"record\") {\n      const entry = this.findEntry(hash);\n\n      if (entry) {\n        if (entry.error) {\n          this.throwRecordedError(entry);\n        }\n\n        if (entry.response) {\n          return entry.response;\n        }\n      }\n\n      if (this.mode === \"replay\") {\n        throw new VcrCassetteMissError(\n          `No cassette entry for this request (model \"${this.inner.name}\", hash ${hash}).`,\n          { requestHash: hash, path: this.path },\n        );\n      }\n    }\n\n    try {\n      const response = await this.inner.complete(messages, options);\n\n      this.record({ requestHash: hash, request: { messages, options }, response });\n\n      return response;\n    } catch (error) {\n      this.record({\n        requestHash: hash,\n        request: { messages, options },\n        error: { name: (error as Error).name, message: (error as Error).message },\n      });\n\n      throw error;\n    }\n  }\n\n  /**\n   * Streaming call. On replay the stored `chunks` are re-yielded in order\n   * (reproducing the `delta`/`tool-call`/`done` sequence) or the stored\n   * error is re-thrown. On record the inner stream is buffered into\n   * `chunks[]` while being re-emitted, then recorded once exhausted.\n   */\n  public async *stream(\n    messages: Message[],\n    options?: ModelCallOptions,\n  ): AsyncIterable<ModelStreamChunk> {\n    await this.ensureLoaded();\n\n    const hash = hashRequest(messages, options, this.hashOptions);\n\n    if (this.mode !== \"record\") {\n      const entry = this.findEntry(hash);\n\n      if (entry) {\n        if (entry.error) {\n          this.throwRecordedError(entry);\n        }\n\n        if (entry.chunks) {\n          for (const chunk of entry.chunks) {\n            yield chunk;\n          }\n\n          return;\n        }\n      }\n\n      if (this.mode === \"replay\") {\n        throw new VcrCassetteMissError(\n          `No cassette entry for this request (model \"${this.inner.name}\", hash ${hash}).`,\n          { requestHash: hash, path: this.path },\n        );\n      }\n    }\n\n    const chunks: ModelStreamChunk[] = [];\n\n    try {\n      for await (const chunk of this.inner.stream(messages, options)) {\n        chunks.push(chunk);\n\n        yield chunk;\n      }\n    } catch (error) {\n      this.record({\n        requestHash: hash,\n        request: { messages, options },\n        error: { name: (error as Error).name, message: (error as Error).message },\n      });\n\n      throw error;\n    }\n\n    this.record({ requestHash: hash, request: { messages, options }, chunks });\n  }\n\n  /**\n   * Append an entry to the in-memory cassette and mark it dirty, applying\n   * the configured request/response/error redaction first (S2). Pure\n   * `replay` never reaches this path, so no replay run is ever dirtied.\n   */\n  private record(entry: CassetteEntry): void {\n    this.loadedCassette.entries.push(this.applyRedaction(entry));\n    this.dirty = true;\n    this.maybeWarnVerbatim();\n  }\n\n  /**\n   * Apply the persisted-body privacy controls to an entry before it is\n   * stored. The request body follows `recordRequest`; response/error\n   * redactors are applied only when supplied. Replay matching is by the\n   * recomputed hash (kept verbatim), so none of this affects replay.\n   */\n  private applyRedaction(entry: CassetteEntry): CassetteEntry {\n    const out: CassetteEntry = {\n      requestHash: entry.requestHash,\n      request: entry.request,\n    };\n\n    if (this.recordRequest === \"hash-only\") {\n      out.request = { messages: [] };\n    } else if (this.recordRequest === \"redacted\") {\n      out.request = this.redactRequestHook\n        ? this.redactRequestHook(entry.request)\n        : redact(entry.request);\n    }\n\n    if (entry.response) {\n      out.response = this.redactResponseHook\n        ? this.redactResponseHook(entry.response)\n        : entry.response;\n    }\n    if (entry.chunks) {\n      out.chunks = entry.chunks;\n    }\n    if (entry.error) {\n      out.error = this.redactErrorHook\n        ? this.redactErrorHook(entry.error)\n        : entry.error;\n    }\n\n    return out;\n  }\n\n  /**\n   * Warn once (outside tests) when the cassette is recording verbatim\n   * request bodies — they may carry prompts, tool args, and PII, so the\n   * file is not safe to commit until sanitized.\n   */\n  private maybeWarnVerbatim(): void {\n    if (this.warnedVerbatim || this.recordRequest !== \"verbatim\") return;\n    if (process.env.VITEST || process.env.NODE_ENV === \"test\") return;\n\n    this.warnedVerbatim = true;\n    console.warn(\n      `[warlock-ai] VCR is recording verbatim request bodies to \"${this.path}\" — prompts, tool args, and any PII are stored unredacted. ` +\n        'Sanitize before committing, or set recordRequest: \"redacted\" | \"hash-only\".',\n    );\n  }\n\n  /**\n   * Flush newly recorded entries to `path`. No-op when nothing was recorded\n   * (pure replay, or a record/auto run that only ever hit cached entries).\n   */\n  public async save(): Promise<void> {\n    if (!this.dirty) {\n      return;\n    }\n\n    await saveCassette(this.path, this.loadedCassette);\n    this.dirty = false;\n  }\n}\n\n/**\n * Wrap any `ModelContract` in a record/replay decorator backed by a JSON\n * cassette on disk.\n *\n * **What it does.** Intercepts only `complete()`/`stream()` — the single\n * seam every agent trip funnels through — and delegates `name`, `provider`,\n * `capabilities`, and `pricing` to the inner model untouched. On a call it\n * computes a stable hash over `{ messages, picked options }` and, depending\n * on `mode`:\n *\n * - **`record`** — always calls the inner model and appends a cassette entry.\n * - **`replay`** — returns the matching entry (or re-yields its chunks /\n *   re-throws its error); a miss throws `VcrCassetteMissError`, never a live\n *   call.\n * - **`auto`** (default) — replays a hit, records a miss.\n *\n * Composes *below* `fallbackModel` and works with any adapter because it\n * depends only on `ModelContract`. Call `save()` to flush new entries.\n *\n * @example\n * const model = vcr(liveModel, { path: \"./cassettes/support.json\" });\n * const response = await model.complete(messages);\n * await model.save(); // first run records; later runs replay deterministically.\n */\nexport function vcr(model: ModelContract, options: VcrOptions): VcrModel {\n  return new Vcr(model, options);\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAyBA,IAAM,MAAN,MAA8B;CAuB5B,AAAO,YACL,AAAiB,OACjB,SACA;EAFiB;eAfH;wBAYS;EAMvB,KAAK,OAAO,QAAQ;EACpB,KAAK,OAAO,QAAQ,QAAQ;EAC5B,KAAK,cAAc,QAAQ,eAAe;EAC1C,KAAK,gBAAgB,QAAQ,iBAAiB;EAC9C,KAAK,oBAAoB,QAAQ;EACjC,KAAK,qBAAqB,QAAQ;EAClC,KAAK,kBAAkB,QAAQ;EAC/B,KAAK,iBAAiB,cAAc,MAAM,MAAM,MAAM,QAAQ;CAChE;;CAGA,IAAW,OAAe;EACxB,OAAO,KAAK,MAAM;CACpB;;CAGA,IAAW,WAAmB;EAC5B,OAAO,KAAK,MAAM;CACpB;;CAGA,IAAW,eAA8C;EACvD,OAAO,KAAK,MAAM;CACpB;;CAGA,IAAW,UAAoC;EAC7C,OAAO,KAAK,MAAM;CACpB;;CAGA,IAAW,WAAqB;EAC9B,OAAO,KAAK;CACd;;;;;;CAOA,MAAc,eAA8B;EAC1C,IAAI,KAAK,aACP,OAAO,KAAK;EAGd,KAAK,cACH,KAAK,SAAS,WACV,QAAQ,QAAQ,KACf,YAAY;GACX,KAAK,iBAAiB,MAAM,aAC1B,KAAK,MACL,KAAK,MAAM,MACX,KAAK,MAAM,QACb;EACF,EAAC,CAAE;EAET,OAAO,KAAK;CACd;;CAGA,AAAQ,UAAU,MAAyC;EACzD,OAAO,KAAK,eAAe,QAAQ,MAAM,UAAU,MAAM,gBAAgB,IAAI;CAC/E;;CAGA,AAAQ,mBAAmB,OAA6B;EACtD,MAAM,QAAQ,IAAI,MAAM,MAAM,OAAO,WAAW,gBAAgB;EAEhE,MAAM,OAAO,MAAM,OAAO,QAAQ;EAElC,MAAM;CACR;;;;;CAMA,MAAa,SAAS,UAAqB,SAAoD;EAC7F,MAAM,KAAK,aAAa;EAExB,MAAM,OAAO,YAAY,UAAU,SAAS,KAAK,WAAW;EAE5D,IAAI,KAAK,SAAS,UAAU;GAC1B,MAAM,QAAQ,KAAK,UAAU,IAAI;GAEjC,IAAI,OAAO;IACT,IAAI,MAAM,OACR,KAAK,mBAAmB,KAAK;IAG/B,IAAI,MAAM,UACR,OAAO,MAAM;GAEjB;GAEA,IAAI,KAAK,SAAS,UAChB,MAAM,IAAI,qBACR,8CAA8C,KAAK,MAAM,KAAK,UAAU,KAAK,KAC7E;IAAE,aAAa;IAAM,MAAM,KAAK;GAAK,CACvC;EAEJ;EAEA,IAAI;GACF,MAAM,WAAW,MAAM,KAAK,MAAM,SAAS,UAAU,OAAO;GAE5D,KAAK,OAAO;IAAE,aAAa;IAAM,SAAS;KAAE;KAAU;IAAQ;IAAG;GAAS,CAAC;GAE3E,OAAO;EACT,SAAS,OAAO;GACd,KAAK,OAAO;IACV,aAAa;IACb,SAAS;KAAE;KAAU;IAAQ;IAC7B,OAAO;KAAE,MAAO,MAAgB;KAAM,SAAU,MAAgB;IAAQ;GAC1E,CAAC;GAED,MAAM;EACR;CACF;;;;;;;CAQA,OAAc,OACZ,UACA,SACiC;EACjC,MAAM,KAAK,aAAa;EAExB,MAAM,OAAO,YAAY,UAAU,SAAS,KAAK,WAAW;EAE5D,IAAI,KAAK,SAAS,UAAU;GAC1B,MAAM,QAAQ,KAAK,UAAU,IAAI;GAEjC,IAAI,OAAO;IACT,IAAI,MAAM,OACR,KAAK,mBAAmB,KAAK;IAG/B,IAAI,MAAM,QAAQ;KAChB,KAAK,MAAM,SAAS,MAAM,QACxB,MAAM;KAGR;IACF;GACF;GAEA,IAAI,KAAK,SAAS,UAChB,MAAM,IAAI,qBACR,8CAA8C,KAAK,MAAM,KAAK,UAAU,KAAK,KAC7E;IAAE,aAAa;IAAM,MAAM,KAAK;GAAK,CACvC;EAEJ;EAEA,MAAM,SAA6B,CAAC;EAEpC,IAAI;GACF,WAAW,MAAM,SAAS,KAAK,MAAM,OAAO,UAAU,OAAO,GAAG;IAC9D,OAAO,KAAK,KAAK;IAEjB,MAAM;GACR;EACF,SAAS,OAAO;GACd,KAAK,OAAO;IACV,aAAa;IACb,SAAS;KAAE;KAAU;IAAQ;IAC7B,OAAO;KAAE,MAAO,MAAgB;KAAM,SAAU,MAAgB;IAAQ;GAC1E,CAAC;GAED,MAAM;EACR;EAEA,KAAK,OAAO;GAAE,aAAa;GAAM,SAAS;IAAE;IAAU;GAAQ;GAAG;EAAO,CAAC;CAC3E;;;;;;CAOA,AAAQ,OAAO,OAA4B;EACzC,KAAK,eAAe,QAAQ,KAAK,KAAK,eAAe,KAAK,CAAC;EAC3D,KAAK,QAAQ;EACb,KAAK,kBAAkB;CACzB;;;;;;;CAQA,AAAQ,eAAe,OAAqC;EAC1D,MAAM,MAAqB;GACzB,aAAa,MAAM;GACnB,SAAS,MAAM;EACjB;EAEA,IAAI,KAAK,kBAAkB,aACzB,IAAI,UAAU,EAAE,UAAU,CAAC,EAAE;OACxB,IAAI,KAAK,kBAAkB,YAChC,IAAI,UAAU,KAAK,oBACf,KAAK,kBAAkB,MAAM,OAAO,IACpC,OAAO,MAAM,OAAO;EAG1B,IAAI,MAAM,UACR,IAAI,WAAW,KAAK,qBAChB,KAAK,mBAAmB,MAAM,QAAQ,IACtC,MAAM;EAEZ,IAAI,MAAM,QACR,IAAI,SAAS,MAAM;EAErB,IAAI,MAAM,OACR,IAAI,QAAQ,KAAK,kBACb,KAAK,gBAAgB,MAAM,KAAK,IAChC,MAAM;EAGZ,OAAO;CACT;;;;;;CAOA,AAAQ,oBAA0B;EAChC,IAAI,KAAK,kBAAkB,KAAK,kBAAkB,YAAY;EAC9D,IAAI,QAAQ,IAAI,UAAU,QAAQ,IAAI,aAAa,QAAQ;EAE3D,KAAK,iBAAiB;EACtB,QAAQ,KACN,6DAA6D,KAAK,KAAK,uIAEzE;CACF;;;;;CAMA,MAAa,OAAsB;EACjC,IAAI,CAAC,KAAK,OACR;EAGF,MAAM,aAAa,KAAK,MAAM,KAAK,cAAc;EACjD,KAAK,QAAQ;CACf;AACF;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,SAAgB,IAAI,OAAsB,SAA+B;CACvE,OAAO,IAAI,IAAI,OAAO,OAAO;AAC/B"}