{"version":3,"file":"semantic-cache.mjs","names":[],"sources":["../../../../../../../../ai/src/middleware/builtins/semantic-cache.ts"],"sourcesContent":["import type { CacheDriver } from \"@warlock.js/cache\";\nimport { resolveDefaultStore } from \"../../config\";\nimport type { Message } from \"../../contracts/conversation-message.type\";\nimport type { EmbedderContract } from \"../../contracts/embedder.contract\";\nimport type { AgentMiddleware } from \"../../contracts/middleware\";\nimport type { MiddlewareTripContext } from \"../../contracts/middleware/middleware-context.type\";\nimport type { ModelResponse } from \"../../contracts/model.contract\";\nimport { extractUserText } from \"../utils\";\n\n/**\n * Isolation boundary for cache reads and writes.\n *\n * - `\"session\"` (default) — key every entry off the run's\n *   `AgentExecuteOptions.sessionId`, so one session never receives a\n *   response cached for another. Calls made WITHOUT a `sessionId` share\n *   one unscoped pool (the pre-4.15.0 behavior); an unscoped read never\n *   sees a session-scoped entry and vice versa.\n * - `\"shared\"` — one pool for every caller, regardless of session. The\n *   explicit opt-in for genuinely public Q&A (docs bots, FAQ) where the\n *   cross-user hit rate is the point and no response can carry one\n *   caller's private context.\n * - a resolver — derive the key yourself, e.g. per tenant\n *   (`ctx => ctx.options?.toolCtx?.tenantId`). Returning `undefined`\n *   falls back to the unscoped pool, so return a constant sentinel (or\n *   throw) if you need the call to fail closed instead.\n */\nexport type SemanticCacheScope =\n  | \"session\"\n  | \"shared\"\n  | ((context: MiddlewareTripContext) => string | undefined);\n\n/**\n * Configuration for `semanticCache()`.\n */\nexport type SemanticCacheOptions = {\n  /** Embedder used to produce the query vector from the prompt text. */\n  embedder: EmbedderContract;\n  /**\n   * Vector-capable cache driver from `@warlock.js/cache`. Production\n   * deployments pick a driver with a real ANN index (`pg` with\n   * pgvector, `redis` with RediSearch). Dev / test environments use\n   * `new MemoryCacheDriver()` — zero config, correct, but O(N) per\n   * query. Drivers without similarity support throw\n   * `CacheUnsupportedError` from `set({ vector })` / `similar()`.\n   *\n   * Falls back to `ai.config({ defaultStore })` when omitted. When\n   * neither is set, the factory throws at construction time —\n   * semantic cache cannot operate without a store.\n   */\n  store?: CacheDriver<any, any>;\n  /**\n   * Minimum cosine similarity for a vector hit. Between 0 and 1 —\n   * 0.95 is a solid default for question-answering caches.\n   */\n  threshold: number;\n  /**\n   * Optional TTL in milliseconds. Entries whose `storedAt` is older\n   * than this are treated as misses on read and overwritten on the\n   * next write. Default: no expiry — entries live until the store\n   * evicts them (per its own TTL/eviction policy).\n   */\n  ttlMs?: number;\n  /**\n   * Namespace prefix applied to every key the cache writes. Lets\n   * multiple agents share one driver without collision. Default\n   * `\"ai.cache\"`.\n   */\n  namespace?: string;\n  /**\n   * Per-caller isolation boundary. Default `\"session\"` — a cached\n   * response is served back only to the session that produced it.\n   *\n   * A `semanticCache` is normally built once at app boot and shared by\n   * every end user, and a hit is returned as the model's answer with no\n   * LLM call in between; without a scope that pools every caller's Q&A\n   * pairs into one namespace, which is both a disclosure path (user B's\n   * near-enough prompt gets served user A's answer, personal context\n   * included) and a poisoning path (an attacker seeds an entry near a\n   * predictable future query). Set `\"shared\"` to opt back into pooling\n   * where that is actually desirable. See {@link SemanticCacheScope}.\n   */\n  scope?: SemanticCacheScope;\n  /**\n   * Middleware name — also the state-bag key prefix inside a single\n   * execution. Default `\"semantic-cache\"`.\n   */\n  name?: string;\n};\n\ntype CachedEntry = {\n  response: ModelResponse;\n  storedAt: number;\n  /**\n   * Isolation key the entry was written under; absent = the unscoped\n   * pool (also the shape of every entry written before 4.15.0).\n   */\n  scope?: string;\n};\n\ntype PendingWrite = {\n  promptKey: string;\n  vector: number[];\n  scope?: string;\n};\n\nconst DEFAULT_NAMESPACE = \"ai.cache\";\n\n/**\n * Extra candidates pulled from `similar()` on a SCOPED lookup before the\n * scope filter runs. The driver ranks across every scope in the index,\n * so a bare `topK: 1` can come back as a foreign entry and mask this\n * scope's own legitimate hit. Mirrors the memory tiers' overscan.\n */\nconst SIMILAR_OVERSCAN = 5;\n\n/**\n * Build a stable fingerprint for a prompt covering the full message\n * list (system + history + user turn). Ensures two prompts sharing\n * the user text but differing in prior context do not collide on\n * the exact-match fast path.\n *\n * FNV-1a variant — cheap, collision-resistant enough for a cache,\n * dependency-free. NOT a cryptographic hash: collisions would\n * surface as wrong cache hits, not a security issue in the current\n * trust model.\n */\nfunction hashPrompt(messages: ReadonlyArray<Message>): string {\n  return fnv1a(\n    messages\n      .map((message) => {\n        const role = message.role;\n        const content = Array.isArray(message.content)\n          ? message.content\n              .filter((part) => part.type === \"text\")\n              .map((part) => (part as { text: string }).text)\n              .join(\"|\")\n          : message.content;\n\n        return `${role}:${content}`;\n      })\n      .join(\"||\"),\n  );\n}\n\n/** FNV-1a over a string — see {@link hashPrompt} for the caveats. */\nfunction fnv1a(serialized: string): string {\n  let hash = 0x811c9dc5;\n\n  for (let index = 0; index < serialized.length; index++) {\n    hash ^= serialized.charCodeAt(index);\n    hash = Math.imul(hash, 0x01000193);\n  }\n\n  return (hash >>> 0).toString(16);\n}\n\n/**\n * Resolve the isolation key this trip reads and writes under.\n *\n * Derived from the run's own `sessionId` (or the developer's resolver) —\n * never from the prompt, the model's output, or anything the LLM can\n * write to. `\"shared\"` and an unidentified run both resolve to\n * `undefined`, i.e. the unscoped pool, which a scoped lookup can never\n * read.\n */\nfunction resolveScope(\n  scope: SemanticCacheScope,\n  context: MiddlewareTripContext,\n): string | undefined {\n  if (scope === \"shared\") {\n    return undefined;\n  }\n\n  const key =\n    typeof scope === \"function\"\n      ? scope(context)\n      : sessionScope(context.options?.sessionId);\n\n  return key ? key : undefined;\n}\n\n/**\n * The default `\"session\"` key: the session id under a reserved prefix so\n * a custom resolver returning a bare tenant id can't collide with a\n * session pool. Mirrors the orchestrator's `sessionMemoryScope`.\n */\nfunction sessionScope(sessionId: string | undefined): string | undefined {\n  return sessionId ? `session:${sessionId}` : undefined;\n}\n\nfunction isFresh(entry: CachedEntry, ttlMs: number | undefined): boolean {\n  if (ttlMs === undefined) {\n    return true;\n  }\n\n  return Date.now() - entry.storedAt <= ttlMs;\n}\n\n/**\n * Semantic-similarity response cache for an agent run.\n *\n * **Role.** Skips LLM round-trips when the current prompt is\n * semantically close to one the agent has already answered. For\n * FAQ / support-style traffic this often eliminates 60–80% of\n * model calls — the production win is massive for cost and\n * latency.\n *\n * **Delegation to `@warlock.js/cache`.** This middleware does NOT\n * implement similarity search itself. It delegates to the supplied\n * `CacheDriver`. Production deployments pick a driver with an ANN\n * index (`pg` + pgvector, `redis` + RediSearch). Dev / test\n * environments pass `new MemoryCacheDriver()` — zero config, correct,\n * but O(N) per query. Drivers without similarity support throw\n * `CacheUnsupportedError` from `set({ vector })` / `similar()`.\n *\n * **Two-tier lookup.**\n * 1. *Exact-match key* — a cheap FNV hash over the entire message\n *    list. `store.get(hash)` returns the entry without an embedding\n *    round trip when the prompt hasn't changed at all.\n * 2. *Vector-match* — on exact-match miss, embed the prompt and\n *    call `store.similar(vector, { topK: 1, threshold })`. The\n *    driver uses its native similarity index; anything clearing\n *    `threshold` is returned as a hit.\n *\n * **Write-on-miss.** When both tiers miss, `trip.before` stashes\n * the prompt hash + vector in `ctx.state`; `trip.after` reads back\n * the pending entry and calls\n * `store.set(hash, entry, { vector })`. If an outer middleware\n * (guardrail) throws in `trip.after` before the cache's `trip.after`\n * runs, the pending entry is never written — bad responses stay out\n * of the cache **as long as the canonical install order is followed**\n * (cache outermost).\n *\n * **Synthetic-response on hit.** Returns a `ModelResponse` with\n * `usage: { input: 0, output: 0, total: 0 }` so budget /\n * observability correctly exclude the saved trip.\n *\n * **Per-session scoping (4.15.0).** One `semanticCache` instance\n * normally serves every end user, and a hit is returned as the answer\n * with no model call in between — so entries are keyed by the run's\n * `sessionId` (`scope`, default `\"session\"`) and a lookup only ever\n * sees entries written under the same key. Runs made without a\n * `sessionId` share one unscoped pool; pass `sessionId` on\n * `agent.execute()` (composites thread their own through automatically)\n * to get the isolation, or set `scope: \"shared\"` to pool deliberately.\n * Note the cost/benefit shift: scoping trades cross-user hit rate for\n * isolation, so public-FAQ deployments where no response can carry a\n * caller's private context should opt into `\"shared\"` explicitly.\n *\n * @example\n * import { semanticCache } from \"@warlock.js/ai\";\n * import { MemoryCacheDriver } from \"@warlock.js/cache\";\n *\n * const store = new MemoryCacheDriver();\n * store.setOptions({});\n *\n * const cache = semanticCache({\n *   embedder: openai.embedder({ name: \"text-embedding-3-small\" }),\n *   store,\n *   threshold: 0.95,\n *   ttlMs: 60 * 60 * 1000,\n * });\n *\n * const myAgent = agent({ model, middleware: [cache] });\n */\nexport function semanticCache(options: SemanticCacheOptions): AgentMiddleware {\n  const name = options.name ?? \"semantic-cache\";\n  const namespace = options.namespace ?? DEFAULT_NAMESPACE;\n  const scopeMode: SemanticCacheScope = options.scope ?? \"session\";\n  const pendingKey = `${name}.pending`;\n\n  // Resolve the effective store at factory time, not per-call. Every\n  // subsequent hook closes over `store` so the resolution happens once.\n  // Throws now (loud, at construction) instead of later during the\n  // first trip (silent until the agent actually runs).\n  const store = options.store ?? resolveDefaultStore();\n\n  if (!store) {\n    throw new Error(\n      `semanticCache: no store supplied — pass \\`store\\` in options or call \\`ai.config({ defaultStore })\\` at app boot before constructing the middleware`,\n    );\n  }\n\n  // Cache's parseKey replaces \":\" with \".\" so the namespace boundary\n  // matches what `similar()` actually returns in `hit.key`. Using a\n  // dot here keeps prefix checks aligned with stored keys.\n  //\n  // A scoped entry gets an extra hashed segment, so two sessions asking\n  // the identical question stay two entries instead of overwriting each\n  // other; the scope is hashed because a `sessionId` is caller-supplied\n  // and may contain the key delimiter. The unscoped key shape is\n  // unchanged, so pre-4.15.0 entries still resolve. The hash is a\n  // write-separation device only — a read is authorized by the exact\n  // `entry.scope` equality check below, so even a hash collision cannot\n  // widen what a session can read.\n  const keyFor = (hash: string, scope: string | undefined): string =>\n    scope === undefined\n      ? `${namespace}.${hash}`\n      : `${namespace}.${fnv1a(scope)}.${hash}`;\n\n  return {\n    name,\n    log: true,\n    trip: {\n      async before(context) {\n        // Only cache the first trip's response. Subsequent trips\n        // happen because the previous trip requested tool calls — the\n        // message list now carries tool results the original prompt\n        // never saw, so a semantic match on the unchanged user text\n        // would serve back the prior `tool_calls` response and loop\n        // the agent forever. The first turn is also the only one\n        // where a \"same question → same final answer\" caching story\n        // is sound.\n        if (context.tripIndex !== 0) {\n          return;\n        }\n\n        const promptText = extractUserText(context.messages);\n\n        if (!promptText) {\n          return;\n        }\n\n        const scope = resolveScope(scopeMode, context);\n        const promptKey = hashPrompt(context.messages);\n\n        const exact = await store.get<CachedEntry>(keyFor(promptKey, scope));\n\n        // The key already carries the scope; re-checking the stored\n        // `scope` is the actual authorization step, so a key collision\n        // or a hand-written entry can't serve across the boundary.\n        if (exact && exact.scope === scope && isFresh(exact, options.ttlMs)) {\n          return toSyntheticResponse(exact.response);\n        }\n\n        const query = await options.embedder.embed(promptText);\n\n        const hits = await store.similar<CachedEntry>(query.vector, {\n          topK: scope === undefined ? 1 : SIMILAR_OVERSCAN,\n          threshold: options.threshold,\n        });\n\n        // Only entries written inside this cache's namespace AND this\n        // caller's scope are eligible. A shared driver would otherwise\n        // leak a foreign namespace's entries; a shared namespace would\n        // leak another session's answer to this one.\n        const hit = hits.find(\n          (candidate) =>\n            candidate.key.startsWith(`${namespace}.`) &&\n            candidate.value?.scope === scope &&\n            isFresh(candidate.value, options.ttlMs),\n        );\n\n        if (hit) {\n          return toSyntheticResponse(hit.value.response);\n        }\n\n        const pending: PendingWrite = {\n          promptKey,\n          vector: query.vector,\n          scope,\n        };\n        context.state.set(pendingKey, pending);\n\n        return;\n      },\n      async after(context, response) {\n        const pending = context.state.get(pendingKey) as PendingWrite | undefined;\n\n        if (!pending) {\n          return;\n        }\n\n        // Mid-stream tool-call responses must not be cached — the\n        // useful answer comes from the trip *after* the tool returns.\n        // Crucially, leave the pending entry in place so a later trip\n        // (the one that actually finishes with `stop`) can read it\n        // and write the final response under the *original* trip-0\n        // prompt key. Deleting here would orphan the pending and the\n        // post-tool answer would never make it into the store.\n        if (response.finishReason === \"tool_calls\") {\n          return;\n        }\n\n        context.state.delete(pendingKey);\n\n        const entry: CachedEntry = {\n          response,\n          storedAt: Date.now(),\n          scope: pending.scope,\n        };\n\n        await store.set(keyFor(pending.promptKey, pending.scope), entry, {\n          vector: pending.vector,\n        });\n\n        return;\n      },\n    },\n  };\n}\n\nfunction toSyntheticResponse(response: ModelResponse): ModelResponse {\n  return {\n    content: response.content,\n    finishReason: response.finishReason,\n    usage: { input: 0, output: 0, total: 0 },\n    toolCalls: response.toolCalls,\n  };\n}\n"],"mappings":"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"}