diff --git a/.github/workflows/semantic-cache-release.yml b/.github/workflows/semantic-cache-release.yml new file mode 100644 index 0000000..b4d61bc --- /dev/null +++ b/.github/workflows/semantic-cache-release.yml @@ -0,0 +1,144 @@ +# Publish @betterdb/semantic-cache to npm +# +# Required secrets: +# NPM_TOKEN — npmjs.com automation token with publish access to @betterdb/semantic-cache + +name: Publish Semantic Cache + +on: + push: + tags: + - 'semantic-cache-v*' + workflow_dispatch: + inputs: + version: + description: 'Version to publish (e.g. 0.1.0)' + required: true + type: string + skip_npm: + description: 'Skip npm publish' + type: boolean + default: false + +jobs: + publish-npm: + if: ${{ !inputs.skip_npm }} + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Resolve version + id: version + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ -n "$INPUT_VERSION" ]; then + echo "version=$INPUT_VERSION" >> $GITHUB_OUTPUT + else + # semantic-cache-v0.1.0 → 0.1.0 + echo "version=${GITHUB_REF_NAME#semantic-cache-v}" >> $GITHUB_OUTPUT + fi + + - name: Update package.json version + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + cd packages/semantic-cache + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); + pkg.version = process.env.VERSION; + fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + - name: Build semantic-cache + run: pnpm --filter @betterdb/semantic-cache build + + - name: Verify build + run: | + test -f packages/semantic-cache/dist/index.js + test -f packages/semantic-cache/dist/index.d.ts + + - name: Prepare for publishing + run: | + cd packages/semantic-cache + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); + delete pkg.devDependencies; + fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: cd packages/semantic-cache && npm publish --provenance --access public + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: semantic-cache-v${{ steps.version.outputs.version }} + name: Semantic Cache v${{ steps.version.outputs.version }} + draft: false + prerelease: ${{ contains(steps.version.outputs.version, '-') }} + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + verify: + needs: publish-npm + runs-on: ubuntu-latest + if: ${{ !inputs.skip_npm }} + + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Verify npm package (with retry) + env: + VERSION: ${{ needs.publish-npm.outputs.version }} + run: | + for i in 1 2 3 4 5; do + if npm view "@betterdb/semantic-cache@$VERSION" version; then + echo "Package verified successfully" + exit 0 + fi + echo "Attempt $i: package not yet available, retrying in 30s..." + sleep 30 + done + echo "Package not available after 5 attempts" + exit 1 diff --git a/docs/packages.md b/docs/packages.md new file mode 100644 index 0000000..74a73c8 --- /dev/null +++ b/docs/packages.md @@ -0,0 +1,6 @@ +--- +layout: default +title: Packages +nav_order: 4 +has_children: true +--- diff --git a/docs/packages/semantic-cache.md b/docs/packages/semantic-cache.md new file mode 100644 index 0000000..2660500 --- /dev/null +++ b/docs/packages/semantic-cache.md @@ -0,0 +1,322 @@ +--- +layout: default +title: Semantic Cache +parent: Packages +nav_order: 1 +--- + +# Semantic Cache + +`@betterdb/semantic-cache` is a standalone, framework-agnostic semantic cache library for LLM applications backed by Valkey. It uses the `valkey-search` module's vector similarity search to match incoming prompts against previously cached responses, returning hits when the cosine distance falls below a configurable threshold. Every cache operation emits an OpenTelemetry span and updates Prometheus metrics, giving teams running Valkey full production observability over their cache layer without additional instrumentation. + +## Prerequisites + +- **Valkey 8.0+** with the `valkey-search` module loaded (self-hosted via the `valkey/valkey-bundle` Docker image) +- Or **Amazon ElastiCache for Valkey** (8.0+) +- Or **Google Cloud Memorystore for Valkey** +- Node.js >= 20 + +## Installation + +```bash +npm install @betterdb/semantic-cache iovalkey +``` + +`iovalkey` is a peer dependency — you must install it alongside the package. + +## Quick start + +```typescript +import Valkey from 'iovalkey'; +import { SemanticCache } from '@betterdb/semantic-cache'; + +const client = new Valkey({ host: 'localhost', port: 6399 }); + +const cache = new SemanticCache({ + client, + embedFn: async (text) => { + // Any embedding provider works — OpenAI, Voyage AI, Cohere, a local model, etc. + const res = await fetch('https://api.voyageai.com/v1/embeddings', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.VOYAGE_API_KEY}` }, + body: JSON.stringify({ model: 'voyage-3-lite', input: [text] }), + }); + const json = await res.json(); + return json.data[0].embedding; + }, + defaultThreshold: 0.1, + defaultTtl: 3600, +}); + +await cache.initialize(); + +// Store a response +await cache.store('What is the capital of France?', 'Paris', { + category: 'geography', + model: 'gpt-4o', +}); + +// Check for a semantically similar prompt +const result = await cache.check('Capital city of France?'); +console.log(result.hit); // true +console.log(result.response); // 'Paris' +console.log(result.confidence); // 'high' +console.log(result.similarity); // ~0.02 (cosine distance) +``` + +The `embedFn` parameter is caller-supplied — any embedding provider works (OpenAI, Cohere, a local model via Ollama, or a custom inference endpoint). + +## Configuration reference + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `name` | `string` | `'betterdb_scache'` | Index name prefix used for all Valkey keys (`{name}:idx`, `{name}:entry:*`, `{name}:__stats`) | +| `client` | `Valkey` | *required* | An `iovalkey` client instance. The caller owns the connection lifecycle | +| `embedFn` | `(text: string) => Promise` | *required* | Async function returning a float embedding vector for a text string | +| `defaultThreshold` | `number` | `0.1` | Cosine distance threshold (0–2). A lookup is a hit when `score <= threshold` | +| `defaultTtl` | `number` | `undefined` | Default TTL in seconds for stored entries. `undefined` means no expiry | +| `categoryThresholds` | `Record` | `{}` | Per-category threshold overrides. Applied when `CacheCheckOptions.category` matches a key | +| `uncertaintyBand` | `number` | `0.05` | Width of the uncertainty band below the threshold. Hits within `[threshold - band, threshold]` are flagged `confidence: 'uncertain'` | +| `telemetry.tracerName` | `string` | `'@betterdb/semantic-cache'` | OpenTelemetry tracer name | +| `telemetry.metricsPrefix` | `string` | `'semantic_cache'` | Prefix for all Prometheus metric names | +| `telemetry.registry` | `Registry` | prom-client default | prom-client `Registry` to register metrics on. Pass a custom `Registry` in library or multi-tenant contexts to avoid polluting the host application's default registry | + +## Threshold tuning + +This library uses **cosine distance** (0–2 scale), not cosine similarity (0–1). The relationship is `distance = 1 - similarity`. Lower distance means more similar: + +| Distance | Meaning | +|----------|---------| +| 0 | Identical vectors | +| 1 | Orthogonal (unrelated) | +| 2 | Opposite vectors | + +A cache lookup is a **hit** when the nearest neighbour's cosine distance is `<= threshold`. Choose your threshold based on the precision/recall trade-off: + +| `defaultThreshold` | Behaviour | +|---|---| +| `0.05` | Very strict — only near-identical phrasings hit | +| `0.10` | Default — balanced precision/recall | +| `0.15` | Looser — catches more paraphrases, higher false-positive risk | +| `0.20+` | Very loose — use per-category overrides instead | + +### Uncertainty band + +When a hit's cosine distance falls within `[threshold - uncertaintyBand, threshold]`, the result is flagged `confidence: 'uncertain'` rather than `'high'`. This lets you handle borderline matches differently in your application — for example, by serving the cached response but also triggering a background refresh. + +### Per-category thresholds + +For mixed workloads, use `categoryThresholds` to set different thresholds per query category rather than loosening the global default: + +```typescript +const cache = new SemanticCache({ + client, + embedFn, + defaultThreshold: 0.10, + categoryThresholds: { + faq: 0.08, // strict — FAQs have canonical phrasings + search: 0.15, // looser — search queries vary more + }, +}); +``` + +Pass `{ category: 'faq' }` in `check()` and `store()` options to activate the override. + +## Observability + +### OpenTelemetry + +Every public method emits a span via the `@opentelemetry/api` tracer. Spans require an OpenTelemetry SDK to be configured in the host application — this package does not bundle an SDK. + +| Span name | Key attributes | +|-----------|----------------| +| `semantic_cache.initialize` | `cache.name` | +| `semantic_cache.check` | `cache.hit`, `cache.similarity`, `cache.threshold`, `cache.confidence`, `cache.category`, `cache.matched_key`, `embedding_latency_ms`, `search_latency_ms` | +| `semantic_cache.store` | `cache.name`, `cache.key`, `cache.ttl`, `cache.category`, `cache.model`, `embedding_latency_ms` | +| `semantic_cache.invalidate` | `cache.name`, `cache.filter`, `cache.deleted_count` | + +### Prometheus + +All metric names are prefixed with the configured `telemetry.metricsPrefix` (default: `semantic_cache`). + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `{prefix}_requests_total` | Counter | `cache_name`, `result`, `category` | Total cache lookups. `result` is `hit`, `miss`, or `uncertain_hit` | +| `{prefix}_similarity_score` | Histogram | `cache_name`, `category` | Cosine distance of the nearest neighbour (0–2). Recorded on hit and near-miss | +| `{prefix}_operation_duration_seconds` | Histogram | `cache_name`, `operation` | End-to-end duration per operation (`check`, `store`, `invalidate`, `initialize`) | +| `{prefix}_embedding_duration_seconds` | Histogram | `cache_name` | Time spent in the caller-supplied `embedFn` | + +If you use [BetterDB Monitor](https://betterdb.com), connect it to the same Valkey instance and it will automatically detect the cache index and surface these metrics alongside your other Valkey observability data. + +## BetterDB Monitor integration + +BetterDB Monitor polls the `{name}:__stats` Valkey hash written by this package on every `check()` call and surfaces hit rate, similarity score distribution, and cache growth rate in the dashboard. Connect Monitor to the same Valkey instance used by the cache — no additional configuration is required. See [betterdb.com](https://betterdb.com) for details. + +## Framework adapters + +Two optional adapters are available as subpath exports. They do not add framework dependencies to the base package — only install the adapter's peer dependency if you use it. + +### LangChain + +Import from `@betterdb/semantic-cache/langchain`. Requires `@langchain/core` >= 0.3.0 as a peer dependency. + +```typescript +import { ChatOpenAI } from '@langchain/openai'; +import { BetterDBSemanticCache } from '@betterdb/semantic-cache/langchain'; + +const llm = new ChatOpenAI({ + modelName: 'gpt-4o', + cache: new BetterDBSemanticCache({ cache }), // pass your SemanticCache instance +}); +``` + +The adapter implements LangChain's `BaseCache` interface. Set `filterByModel: true` to scope cache lookups by the LLM configuration string. + +### Vercel AI SDK + +Import from `@betterdb/semantic-cache/ai`. Requires `ai` >= 4.0.0 as a peer dependency. + +```typescript +import { wrapLanguageModel } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { createSemanticCacheMiddleware } from '@betterdb/semantic-cache/ai'; + +const model = wrapLanguageModel({ + model: openai('gpt-4o'), + middleware: createSemanticCacheMiddleware({ cache }), +}); +``` + +The middleware intercepts `doGenerate` calls. On a cache hit, the model is not called. Streaming (`wrapStream`) is not supported in v0.1. + +## Valkey Search 1.2 compatibility notes + +The following divergences from Redis/RediSearch were discovered during live verification and are handled in the implementation: + +1. **`FT.INFO` error message** — Valkey Search 1.2 returns `"Index with name '...' not found in database 0"` rather than `"Unknown Index name"` (Redis/RediSearch convention) or `"no such index"`. The code matches all three patterns for cross-compatibility. +2. **`FT.DROPINDEX DD`** — The `DD` (Delete Documents) flag is not supported in Valkey Search 1.2. Key cleanup is done separately via `SCAN` + `DEL` after dropping the index. +3. **`FT.SEARCH` KNN score aliases** — KNN score aliases (`__score`) cannot be used in `RETURN` or `SORTBY` clauses. Results are returned automatically (without a `RETURN` clause) and pre-sorted by distance. +4. **`FT.INFO` dimension parsing** — The vector field dimension is nested inside an `"index"` sub-array (as `"dimensions"`) rather than exposed at the top-level `DIM` key used by RediSearch. + +## Known limitations + +### Cluster mode + +`@betterdb/semantic-cache` works with single-node Valkey instances and managed +single-endpoint services (Amazon ElastiCache for Valkey, Google Cloud Memorystore +for Valkey). It does not fully support Valkey in cluster mode. + +The specific issue is `flush()`: it uses `SCAN` to find and delete entry keys, +but `SCAN` in cluster mode only iterates keys on the node it is sent to. In a +multi-node cluster, `flush()` will silently leave entry keys on other nodes +(the FT index itself is dropped correctly). + +`check()`, `store()`, `invalidate()`, and `stats()` are unaffected — these use +`FT.SEARCH`, `HSET`, `DEL`, and `HINCRBY` which route correctly in cluster mode +via the key hash slot. + +If you need cluster support, either avoid `flush()` or implement a cluster-aware +key sweep using the iovalkey cluster client's per-node scan capability. +Cluster mode support is planned for a future release. + +### Streaming + +Streaming LLM responses are not supported. `store()` expects a complete response +string. If your application uses streaming, accumulate the full response before +calling `store()`. The cached response is always returned as a complete string, +not re-streamed token-by-token. + +## API reference + +### `cache.initialize(): Promise` + +Creates or reconnects to the Valkey search index. If the index already exists, reads the vector dimension from `FT.INFO` and marks the instance as initialized. If the index does not exist, calls `embedFn('probe')` to determine the embedding dimension, then creates the index via `FT.CREATE`. + +Must be called before `check()` or `store()`. Safe to call multiple times. + +**Throws:** `EmbeddingError` if `embedFn('probe')` fails, `ValkeyCommandError` if `FT.CREATE` or `FT.INFO` fails for a reason other than a missing index. + +### `cache.check(prompt: string, options?: CacheCheckOptions): Promise` + +Searches the cache for a semantically similar prompt using KNN vector search. Returns a `CacheCheckResult`: + +| Field | Type | Description | +|-------|------|-------------| +| `hit` | `boolean` | Whether the nearest neighbour's cosine distance was `<= threshold` | +| `response` | `string \| undefined` | The cached response text. Present on hit | +| `similarity` | `number \| undefined` | Cosine distance (0–2). Present when a nearest neighbour was found | +| `confidence` | `'high' \| 'uncertain' \| 'miss'` | `'uncertain'` if the hit falls within the uncertainty band | +| `matchedKey` | `string \| undefined` | The Valkey key of the matched entry. Present on hit | +| `nearestMiss` | `{ similarity, deltaToThreshold } \| undefined` | Present on miss when a candidate existed but didn't clear the threshold | + +**Options** (`CacheCheckOptions`): + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `threshold` | `number` | — | Per-request threshold override (highest priority) | +| `category` | `string` | — | Category tag for per-category threshold lookup and metric labels | +| `filter` | `string` | — | Additional `valkey-search` pre-filter expression (e.g. `'@model:{gpt-4o}'`) | +| `k` | `number` | `1` | Number of nearest neighbours to fetch before threshold check | + +On a hit, refreshes the entry's TTL if `defaultTtl` is configured (sliding window). + +**Throws:** `SemanticCacheUsageError` if `initialize()` was not called, `EmbeddingError` if `embedFn` fails, `ValkeyCommandError` if `FT.SEARCH` fails. + +### `cache.store(prompt: string, response: string, options?: CacheStoreOptions): Promise` + +Stores a prompt/response pair with its embedding vector. Returns the Valkey key of the stored entry (format: `{name}:entry:{uuid}`). + +**Options** (`CacheStoreOptions`): + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `ttl` | `number` | `defaultTtl` | Per-entry TTL in seconds | +| `category` | `string` | `''` | Category tag | +| `model` | `string` | `''` | Model name tag (e.g. `'gpt-4o'`) | +| `metadata` | `Record` | `{}` | Arbitrary metadata stored as JSON | + +**Throws:** `SemanticCacheUsageError` if `initialize()` was not called, `EmbeddingError` if `embedFn` fails, `SemanticCacheUsageError` if the embedding dimension doesn't match the index (usually means the embedding model changed — call `flush()` then `initialize()` to rebuild), `ValkeyCommandError` if `HSET` fails. + +### `cache.invalidate(filter: string): Promise` + +Deletes all entries matching a `valkey-search` filter expression. Fetches up to 1000 matching keys via `FT.SEARCH`, then deletes them in a single `DEL` call. Returns `{ deleted: number, truncated: boolean }`. If `truncated` is true, call again with the same filter until it returns false. + +```typescript +const { deleted, truncated } = await cache.invalidate('@model:{gpt-4o}'); +``` + +**Throws:** `SemanticCacheUsageError` if `initialize()` was not called, `ValkeyCommandError` if `FT.SEARCH` or `DEL` fails. + +### `cache.stats(): Promise` + +Returns cumulative hit/miss statistics from the `{name}:__stats` Valkey hash: + +```typescript +interface CacheStats { + hits: number; + misses: number; + total: number; + hitRate: number; // hits / total, or 0 if total is 0 +} +``` + +### `cache.indexInfo(): Promise` + +Returns index metadata parsed from `FT.INFO`: + +```typescript +interface IndexInfo { + name: string; // e.g. 'betterdb_scache:idx' + numDocs: number; // number of indexed entries + dimension: number; // embedding vector dimension + indexingState: string; // e.g. 'ready' or 'unknown' +} +``` + +**Throws:** `ValkeyCommandError` if `FT.INFO` fails. + +### `cache.flush(): Promise` + +Drops the FT index via `FT.DROPINDEX` and deletes all entry keys and the stats hash via `SCAN` + `DEL`. Resets the instance to uninitialized — call `initialize()` again to rebuild. + +The caller owns the `iovalkey` client lifecycle — call `client.quit()` or `client.disconnect()` yourself when the application shuts down. diff --git a/packages/semantic-cache/CHANGELOG.md b/packages/semantic-cache/CHANGELOG.md new file mode 100644 index 0000000..9e3539c --- /dev/null +++ b/packages/semantic-cache/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-03-21 + +### Added + +- `SemanticCache` class — standalone, framework-agnostic semantic cache for LLM applications backed by Valkey via the `valkey-search` module +- `check(prompt, options?)` — vector similarity lookup returning hit/miss with similarity score, confidence level (`high` | `uncertain` | `miss`), and nearest-miss diagnostics on threshold failures +- `store(prompt, response, options?)` — stores prompt/response pairs with per-entry TTL, category tag, and model tag +- `invalidate(filter)` — batch delete by `valkey-search` filter expression (e.g. `@model:{gpt-4o}`); single `DEL` call for all matching keys +- `stats()` — hit/miss/total counts and hit rate, persisted in Valkey so BetterDB Monitor can poll them independently +- `indexInfo()` — returns index metadata from `FT.INFO` +- `flush()` — drops the FT index and cleans up all entry keys via `SCAN` + `DEL` +- Per-category threshold overrides via `categoryThresholds` option +- Uncertainty band: hits within `uncertaintyBand` of the threshold are flagged `confidence: 'uncertain'` +- Sliding TTL refresh on cache hits when `defaultTtl` is set +- Built-in OpenTelemetry spans on every operation (`semantic_cache.check`, `semantic_cache.store`, `semantic_cache.invalidate`, `semantic_cache.initialize`) with `cache.hit`, `cache.similarity`, `cache.threshold`, `cache.confidence`, `cache.category` attributes +- Four Prometheus metrics via `prom-client`: `{prefix}_requests_total`, `{prefix}_similarity_score`, `{prefix}_operation_duration_seconds`, `{prefix}_embedding_duration_seconds` +- Optional `telemetry.registry` parameter to isolate metrics from the host application's default prom-client registry +- Typed error classes: `SemanticCacheUsageError`, `EmbeddingError`, `ValkeyCommandError` +- Full TypeScript types exported from package root +- 27 tests: 9 unit tests (`utils.test.ts`), 8 adapter tests (`adapters.test.ts`), and 10 integration tests (`SemanticCache.integration.test.ts`); integration tests skip gracefully when Valkey is unreachable + +### Valkey Search 1.2 compatibility notes + +The following divergences from Redis/RediSearch were discovered during live verification and are handled in the implementation: + +1. **`FT.INFO` error message** — Valkey Search 1.2 returns `"Index with name '...' not found in database 0"` rather than `"Unknown Index name"` (Redis/RediSearch convention) or `"no such index"`. The code matches all three patterns for cross-compatibility. +2. **`FT.DROPINDEX DD`** — The `DD` (Delete Documents) flag is not supported in Valkey Search 1.2. Key cleanup is done separately via `SCAN` + `DEL` after dropping the index. +3. **`FT.SEARCH` KNN score aliases** — KNN score aliases (`__score`) cannot be used in `RETURN` or `SORTBY` clauses. Results are returned automatically (without a `RETURN` clause) and pre-sorted by distance. +4. **`FT.INFO` dimension parsing** — The vector field dimension is nested inside an `"index"` sub-array (as `"dimensions"`) rather than exposed at the top-level `DIM` key used by RediSearch. diff --git a/packages/semantic-cache/README.md b/packages/semantic-cache/README.md new file mode 100644 index 0000000..2db308d --- /dev/null +++ b/packages/semantic-cache/README.md @@ -0,0 +1,223 @@ +# @betterdb/semantic-cache + +A standalone, framework-agnostic semantic cache for LLM applications backed by [Valkey](https://valkey.io/) (or Redis). Uses Valkey's vector search (`valkey-search` module) for similarity matching with built-in [OpenTelemetry](https://opentelemetry.io/) tracing and [Prometheus](https://prometheus.io/) metrics via `prom-client`. The first semantic cache library designed to work natively with Valkey and BetterDB Monitor. + +## Prerequisites + +- **Valkey 8.0+** with the `valkey-search` module loaded +- Or **Amazon ElastiCache for Valkey** (8.0+) +- Or **Google Cloud Memorystore for Valkey** +- Node.js >= 20.0.0 + +## Installation + +```bash +npm install @betterdb/semantic-cache +``` + +You must also have `iovalkey` installed (it is a peer dependency): + +```bash +npm install iovalkey +``` + +## Why @betterdb/semantic-cache + +As of 2026, no existing semantic cache library simultaneously satisfies all three of the following properties: **Valkey-native** support (explicitly handling `valkey-search` API differences rather than assuming Redis wire compatibility), **standalone** operation (no coupling to LangChain, LiteLLM, AWS, or any other orchestration layer), and **built-in observability** (OpenTelemetry spans and Prometheus metrics emitted at the cache operation level, not just at the HTTP or LLM call level). This package was built to fill that gap. + +| Library / Service | Valkey-native | Standalone | Built-in OTel + Prometheus | +|---|---|---|---| +| **@betterdb/semantic-cache** | ✅ | ✅ | ✅ | +| RedisVL `SemanticCache` | ❌ Redis only | ✅ | ❌ | +| LangChain `RedisSemanticCache` | ❌ Redis only | ❌ Requires LangChain | ❌ | +| LiteLLM `redis-semantic` | ❌ Redis only | ❌ Requires LiteLLM | ❌ Partial (no cache metrics) | +| `langgraph-checkpoint-aws` `ValkeyCache` | ✅ | ❌ Requires AWS + LangGraph | ❌ | +| Mem0 + Valkey | ✅ | ❌ Full memory framework | ❌ | +| Redis LangCache | ❌ Redis Cloud only | ❌ Managed service | ✅ Dashboard only | +| Upstash `semantic-cache` | ❌ Upstash Vector only | ✅ | ❌ | +| GPTCache | ❌ Abandoned (2023) | ✅ | ❌ | + +- **Valkey-native**: `valkey-search` has API differences from Redis's RediSearch that require explicit handling (see [Valkey Search 1.2 compatibility notes](#valkey-search-12-compatibility-notes) in the changelog). Libraries targeting Redis are not guaranteed to work correctly against self-hosted Valkey or managed Valkey services (ElastiCache, Memorystore). +- **Standalone**: no dependency on a specific AI framework means you can use this with any LLM client — OpenAI SDK, Anthropic SDK, a local model, or a custom inference endpoint — and swap it out without changing your cache layer. +- **Built-in OTel + Prometheus**: every `check()` and `store()` call emits a span and increments counters. You get hit rate, similarity score distribution, and latency percentiles in Grafana or any OTel-compatible backend without writing any instrumentation code. If you use [BetterDB Monitor](https://betterdb.com), these metrics are surfaced automatically alongside your other Valkey observability data. + +## Quick Start + +```typescript +import Valkey from 'iovalkey'; +import { SemanticCache } from '@betterdb/semantic-cache'; + +const client = new Valkey({ host: 'localhost', port: 6399 }); + +const cache = new SemanticCache({ + client, + embedFn: async (text) => { + // Any embedding provider works — OpenAI, Voyage AI, Cohere, a local model, etc. + const res = await fetch('https://api.voyageai.com/v1/embeddings', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.VOYAGE_API_KEY}` }, + body: JSON.stringify({ model: 'voyage-3-lite', input: [text] }), + }); + const json = await res.json(); + return json.data[0].embedding; + }, +}); + +await cache.initialize(); + +// Store a response +await cache.store('What is the capital of France?', 'Paris'); + +// Check for a semantically similar prompt +const result = await cache.check('Capital city of France?'); +// result.hit === true, result.response === 'Paris' +``` + +## Client Lifecycle + +SemanticCache does **not** own the iovalkey client. You create it, you close it: + +```typescript +const client = new Valkey({ host: 'localhost', port: 6399 }); +const cache = new SemanticCache({ client, embedFn }); + +// ... use cache ... + +// When shutting down, close the client yourself: +await client.quit(); +``` + +## Threshold: Cosine Distance vs Cosine Similarity + +This library uses **cosine distance** (0–2 scale), not cosine similarity (0–1 scale): + +| Distance | Meaning | +|----------|---------| +| 0 | Identical vectors | +| 1 | Orthogonal (unrelated) | +| 2 | Opposite vectors | + +A cache lookup is a **hit** when `score <= threshold`. The default threshold of `0.1` is strict — it matches only very similar prompts. Increase to `0.15–0.2` for broader matching. + +The relationship is: `distance = 1 - similarity`. A cosine similarity of 0.95 corresponds to a distance of 0.05. + +### Handling uncertain hits + +When `confidence` is `'uncertain'`, the cached response is technically above +the similarity threshold but close to the boundary. Three common patterns: + +**Accept and monitor** — return the cached response but track uncertain hits +separately via the `result: 'uncertain_hit'` Prometheus label. Review them +periodically to decide if the threshold needs adjustment. + +**Fall back to LLM** — treat uncertain hits as misses, call the LLM, then +update the cache entry with `store()` using the fresh response. + +**Prompt for feedback** — in user-facing applications, show the cached +response but collect a thumbs up/down signal to identify false positives. + +A high rate of uncertain hits (visible in the `{prefix}_requests_total` +metric) indicates the threshold may be too loose for the query distribution. + +## Configuration Reference + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `name` | `string` | `'betterdb_scache'` | Index name prefix for Valkey keys | +| `client` | `Valkey` | — | iovalkey client instance (required) | +| `embedFn` | `(text: string) => Promise` | — | Embedding function (required) | +| `defaultThreshold` | `number` | `0.1` | Cosine distance threshold (0–2) | +| `defaultTtl` | `number` | `undefined` | Default TTL in seconds for entries | +| `categoryThresholds` | `Record` | `{}` | Per-category threshold overrides | +| `uncertaintyBand` | `number` | `0.05` | Width of the uncertainty band below threshold | +| `telemetry.tracerName` | `string` | `'@betterdb/semantic-cache'` | OpenTelemetry tracer name | +| `telemetry.metricsPrefix` | `string` | `'semantic_cache'` | Prometheus metric name prefix | +| `telemetry.registry` | `Registry` | default registry | prom-client Registry for metrics | + +## Observability + +### Prometheus Metrics + +All metric names are prefixed with `semantic_cache_` by default (configurable via `telemetry.metricsPrefix`). + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `semantic_cache_requests_total` | Counter | `cache_name`, `result`, `category` | Total cache requests. `result` is `hit`, `miss`, or `uncertain_hit` | +| `semantic_cache_similarity_score` | Histogram | `cache_name`, `category` | Cosine distance scores for lookups with candidates | +| `semantic_cache_operation_duration_seconds` | Histogram | `cache_name`, `operation` | Duration of cache operations (`check`, `store`, `invalidate`, `initialize`) | +| `semantic_cache_embedding_duration_seconds` | Histogram | `cache_name` | Duration of embedding function calls | + +### OpenTelemetry Tracing + +Every public method emits an OTel span with relevant attributes (`cache.hit`, `cache.similarity`, `cache.threshold`, `cache.confidence`, etc.). Spans require an OpenTelemetry SDK to be configured in the host application — this library uses `@opentelemetry/api` and does not bundle an SDK. + +## BetterDB Monitor Integration + +If you connect [BetterDB Monitor](https://github.com/KIvanow/monitor) to the same Valkey instance, it will automatically detect the semantic cache index and surface: + +- Hit rate and miss rate over time +- Similarity score distribution +- Cache entry count and memory usage +- Cost savings estimates based on cache hit rates + +## API + +### `cache.initialize()` + +Creates or reconnects to the Valkey search index. Must be called before `check()` or `store()`. Safe to call multiple times. + +### `cache.check(prompt, options?)` + +Searches for a semantically similar cached prompt. Returns `{ hit, response, similarity, confidence, matchedKey, nearestMiss }`. + +### `cache.store(prompt, response, options?)` + +Stores a prompt/response pair with its embedding vector. Returns the Valkey key. + +### `cache.invalidate(filter)` + +Deletes entries matching a valkey-search filter expression. Example: `cache.invalidate('@model:{gpt-4o}')`. + +### `cache.stats()` + +Returns `{ hits, misses, total, hitRate }` from the Valkey stats hash. + +### `cache.indexInfo()` + +Returns index metadata: `{ name, numDocs, dimension, indexingState }`. + +### `cache.flush()` + +Drops the index and all entries. Call `initialize()` again to rebuild. + +## Known limitations + +### Cluster mode + +`@betterdb/semantic-cache` works with single-node Valkey instances and managed +single-endpoint services (Amazon ElastiCache for Valkey, Google Cloud Memorystore +for Valkey). It does not fully support Valkey in cluster mode. + +The specific issue is `flush()`: it uses `SCAN` to find and delete entry keys, +but `SCAN` in cluster mode only iterates keys on the node it is sent to. In a +multi-node cluster, `flush()` will silently leave entry keys on other nodes +(the FT index itself is dropped correctly). + +`check()`, `store()`, `invalidate()`, and `stats()` are unaffected — these use +`FT.SEARCH`, `HSET`, `DEL`, and `HINCRBY` which route correctly in cluster mode +via the key hash slot. + +If you need cluster support, either avoid `flush()` or implement a cluster-aware +key sweep using the iovalkey cluster client's per-node scan capability. +Cluster mode support is planned for a future release. + +### Streaming + +Streaming LLM responses are not supported. `store()` expects a complete response +string. If your application uses streaming, accumulate the full response before +calling `store()`. The cached response is always returned as a complete string, +not re-streamed token-by-token. + +## License + +MIT diff --git a/packages/semantic-cache/examples/basic/README.md b/packages/semantic-cache/examples/basic/README.md new file mode 100644 index 0000000..2c12373 --- /dev/null +++ b/packages/semantic-cache/examples/basic/README.md @@ -0,0 +1,91 @@ +# Basic example — @betterdb/semantic-cache + +A runnable example demonstrating core semantic cache operations against a live Valkey instance. + +## Prerequisites + +- Docker +- Node.js 20+ + +## Quick start (no API key needed) + +Start Valkey with `valkey-search`: + +```bash +docker-compose up -d +``` + +Install dependencies and run in mock mode: + +```bash +npm install +npm start -- --mock +``` + +Expected output (abbreviated): + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + MOCK MODE — no API key needed + + ⚠️ Uses WORD OVERLAP, not semantic understanding. + ... + Threshold: 0.25 (mock) vs 0.10 (real mode default) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[check 1] "What is the capital of France?" + hit: true | confidence: high | similarity: 0.0000 | response: Paris + (mock: shared words — capital, france) + +[check 2] "Capital city of France?" + hit: true | confidence: high | similarity: 0.1835 | response: Paris + (mock: shared words — capital, france) + +[check 3] "Who wrote Hamlet?" + hit: false | nearest miss: 0.5918 (delta: +0.3418) + (mock: no shared words with stored prompts) + +[check 4] "What is the best pizza topping?" + hit: false | nearest miss: 1.0000 (delta: +0.7500) + (mock: no shared words with stored prompts) + +Cache stats: 2 hits / 4 lookups (50.0% hit rate) +``` + +## With Voyage AI (real semantic similarity) + +```bash +VOYAGE_API_KEY=pa-... npm start +``` + +## Mock mode vs real embeddings + +Mock mode uses **word overlap** (TF-IDF), not semantic understanding. Results differ from a real embedding model: + +| Query | Mock result | Real embedder result | Reason | +|-------|-------------|----------------------|--------| +| "Capital city of France?" | ✅ hit | ✅ hit | Both: shares `capital`, `france` | +| "Where does the French government sit?" | ❌ miss | ✅ hit | Mock: no shared words. Real: semantically equivalent | +| "France capital budget 2024" | ✅ hit | ❌ miss | Mock: shares `france`, `capital`. Real: different meaning | +| "Who wrote Hamlet?" | ❌ miss | ✅ hit | Mock: no shared words. Real: same author as Romeo and Juliet | + +Mock mode is useful for verifying the cache plumbing works end-to-end without an API key. +Use a real embedding model to evaluate actual semantic cache effectiveness. + +The mock threshold is also set higher (`0.25`) than the real mode default (`0.10`) to +account for the coarser word-overlap distances. This is not representative of production behaviour. + +## What it demonstrates + +1. **Exact match** — looking up a prompt identical to one that was stored; returns a hit with `confidence: 'high'` +2. **Paraphrase** — looking up a rephrased version of a stored prompt; hits because key content words overlap +3. **Related but different** — "Who wrote Hamlet?" vs stored "Who wrote Romeo and Juliet?"; misses in mock mode +4. **Unrelated prompt** — a completely unrelated query; returns a miss with `nearestMiss` diagnostics + +The example also prints cache statistics (`hits`, `misses`, `hitRate`) and index metadata (`numDocs`, `dimension`). + +## Using a different Valkey port + +```bash +VALKEY_URL=redis://localhost:6390 npm start -- --mock +``` diff --git a/packages/semantic-cache/examples/basic/docker-compose.yml b/packages/semantic-cache/examples/basic/docker-compose.yml new file mode 100644 index 0000000..c216c70 --- /dev/null +++ b/packages/semantic-cache/examples/basic/docker-compose.yml @@ -0,0 +1,8 @@ +services: + valkey: + # unstable is currently the only tag shipping valkey-search 1.2 (TEXT fields, etc.) + # Pin to a stable tag once a release includes valkey-search >= 1.2. + image: valkey/valkey-bundle:unstable + ports: + - "6399:6379" + command: ["valkey-server", "--loglevel", "notice"] diff --git a/packages/semantic-cache/examples/basic/index.ts b/packages/semantic-cache/examples/basic/index.ts new file mode 100644 index 0000000..74bfd9b --- /dev/null +++ b/packages/semantic-cache/examples/basic/index.ts @@ -0,0 +1,214 @@ +import Valkey from 'iovalkey'; +import { SemanticCache, CacheCheckResult } from '@betterdb/semantic-cache'; +import { mockEmbed, tokenise } from './mock-embedder'; + +const USE_MOCK = process.argv.includes('--mock') || process.env.MOCK_EMBEDDINGS === 'true'; + +/** Real embedder using Voyage AI — only constructed if not in mock mode. */ +async function voyageEmbed(text: string): Promise { + const apiKey = process.env.VOYAGE_API_KEY; + if (!apiKey) { + throw new Error( + 'VOYAGE_API_KEY environment variable is not set.\n' + + 'Run with --mock to use the built-in mock embedder instead:\n' + + ' npm start -- --mock', + ); + } + const res = await fetch('https://api.voyageai.com/v1/embeddings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model: 'voyage-3-lite', input: [text] }), + }); + if (!res.ok) { + throw new Error(`Voyage API error: ${res.status} ${await res.text()}`); + } + const json = (await res.json()) as { data: Array<{ embedding: number[] }> }; + return json.data[0].embedding; +} + +const embedFn = USE_MOCK ? mockEmbed : voyageEmbed; + +const storedPrompts = [ + 'What is the capital of France?', + 'Who wrote Romeo and Juliet?', + 'What is the speed of light?', +]; + +function mockReason(prompt: string, result: CacheCheckResult): string { + if (!USE_MOCK) return ''; + + const queryTokens = new Set(tokenise(prompt)); + + if (!result.hit) { + const allStoredTokens = storedPrompts.flatMap(tokenise); + const shared = [...new Set(allStoredTokens.filter(t => queryTokens.has(t)))]; + if (shared.length === 0) return '\n (mock: no shared words with stored prompts)'; + return `\n (mock: shares words [${shared.slice(0, 4).join(', ')}] but above threshold)`; + } + + const matchedTokens = storedPrompts + .flatMap(tokenise) + .filter(t => queryTokens.has(t)); + const unique = [...new Set(matchedTokens)]; + if (unique.length === 0) return '\n (mock: vector collision — no obvious shared words)'; + return `\n (mock: shared words — ${unique.slice(0, 4).join(', ')})`; +} + +async function main() { + // --- Mode banner --- + + if (USE_MOCK) { + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(' MOCK MODE — no API key needed'); + console.log(''); + console.log(' ⚠️ Uses WORD OVERLAP, not semantic understanding.'); + console.log(' A hit occurs when prompts share tokens — not because'); + console.log(' the embedder understands meaning. Real embedding models'); + console.log(' will produce different results for some queries.'); + console.log(''); + console.log(` Threshold: 0.25 (mock) vs 0.10 (real mode default)`); + console.log(' Run without --mock to use Voyage AI voyage-3-lite.'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(); + } else { + console.log('Running with Voyage AI voyage-3-lite'); + } + console.log(); + + // --- Setup --- + + // Mock embedder produces larger cosine distances than real models because + // it relies on exact word overlap rather than learned semantic similarity. + // Threshold 0.25 in mock mode gives meaningful demo hits/misses: + // "Capital city of France?" vs "What is the capital of France?" → 0.184 (hit) + // "Who wrote Hamlet?" vs "Who wrote Romeo and Juliet?" → 0.592 (miss) + const threshold = USE_MOCK ? 0.25 : 0.10; + + const url = process.env.VALKEY_URL; + const port = url ? parseInt(new URL(url).port, 10) : 6399; + const host = url ? new URL(url).hostname : 'localhost'; + + const client = new Valkey({ host, port }); + + const cache = new SemanticCache({ + name: 'example_basic', + client, + embedFn, + defaultThreshold: threshold, + defaultTtl: 300, + categoryThresholds: USE_MOCK + ? { geography: 0.25, literature: 0.25, science: 0.25 } + : { geography: 0.12, literature: 0.12, science: 0.10 }, + }); + + // --- Initialize --- + + console.log('Initializing cache...'); + await cache.initialize(); + console.log('Cache initialized.\n'); + + // --- Store entries --- + + console.log('Storing 3 prompt/response pairs...'); + + await cache.store('What is the capital of France?', 'Paris', { + category: 'geography', + model: 'claude-sonnet-4-6', + }); + console.log(' Stored: "What is the capital of France?" -> "Paris" [geography]'); + + await cache.store('Who wrote Romeo and Juliet?', 'William Shakespeare', { + category: 'literature', + model: 'claude-sonnet-4-6', + }); + console.log(' Stored: "Who wrote Romeo and Juliet?" -> "William Shakespeare" [literature]'); + + await cache.store('What is the speed of light?', 'Approximately 299,792 kilometres per second', { + category: 'science', + model: 'claude-sonnet-4-6', + }); + console.log(' Stored: "What is the speed of light?" -> "Approximately 299,792 km/s" [science]'); + console.log(); + + // --- Check 1: Exact match --- + + const q1 = 'What is the capital of France?'; + console.log(`[check 1] "${q1}"`); + const r1 = await cache.check(q1); + if (r1.hit) { + console.log(` hit: true | confidence: ${r1.confidence} | similarity: ${r1.similarity?.toFixed(4)} | response: ${r1.response}${mockReason(q1, r1)}`); + } else { + console.log(` hit: false | similarity: ${r1.similarity?.toFixed(4)}${mockReason(q1, r1)}`); + } + console.log(); + + // --- Check 2: Paraphrase --- + + const q2 = 'Capital city of France?'; + console.log(`[check 2] "${q2}"`); + const r2 = await cache.check(q2); + if (r2.hit) { + console.log(` hit: true | confidence: ${r2.confidence} | similarity: ${r2.similarity?.toFixed(4)} | response: ${r2.response}${mockReason(q2, r2)}`); + } else if (r2.nearestMiss) { + console.log(` hit: false | nearest miss: ${r2.nearestMiss.similarity.toFixed(4)} (delta: +${r2.nearestMiss.deltaToThreshold.toFixed(4)})${mockReason(q2, r2)}`); + } else { + console.log(` hit: false${mockReason(q2, r2)}`); + } + console.log(); + + // --- Check 3: Different topic --- + + const q3 = 'Who wrote Hamlet?'; + console.log(`[check 3] "${q3}"`); + const r3 = await cache.check(q3); + if (r3.hit) { + console.log(` hit: true | confidence: ${r3.confidence} | similarity: ${r3.similarity?.toFixed(4)} | response: ${r3.response}${mockReason(q3, r3)}`); + } else if (r3.nearestMiss) { + console.log(` hit: false | nearest miss: ${r3.nearestMiss.similarity.toFixed(4)} (delta: +${r3.nearestMiss.deltaToThreshold.toFixed(4)})${mockReason(q3, r3)}`); + } else { + console.log(` hit: false${mockReason(q3, r3)}`); + } + console.log(); + + // --- Check 4: Unrelated --- + + const q4 = 'What is the best pizza topping?'; + console.log(`[check 4] "${q4}"`); + const r4 = await cache.check(q4); + if (r4.hit) { + console.log(` hit: true | confidence: ${r4.confidence} | similarity: ${r4.similarity?.toFixed(4)} | response: ${r4.response}${mockReason(q4, r4)}`); + } else if (r4.nearestMiss) { + console.log(` hit: false | nearest miss: ${r4.nearestMiss.similarity.toFixed(4)} (delta: +${r4.nearestMiss.deltaToThreshold.toFixed(4)})${mockReason(q4, r4)}`); + } else { + console.log(` hit: false${mockReason(q4, r4)}`); + } + console.log(); + + // --- Stats --- + + const stats = await cache.stats(); + console.log(`Cache stats: ${stats.hits} hits / ${stats.total} lookups (${(stats.hitRate * 100).toFixed(1)}% hit rate)`); + console.log(); + + // --- Index info --- + + const info = await cache.indexInfo(); + console.log(`Index: ${info.name}, docs: ${info.numDocs}, dimension: ${info.dimension}, state: ${info.indexingState}`); + console.log(); + + // --- Cleanup --- + + console.log('Flushing cache...'); + await cache.flush(); + console.log('Done.'); + + await client.quit(); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/packages/semantic-cache/examples/basic/mock-embedder.ts b/packages/semantic-cache/examples/basic/mock-embedder.ts new file mode 100644 index 0000000..9d037d9 --- /dev/null +++ b/packages/semantic-cache/examples/basic/mock-embedder.ts @@ -0,0 +1,80 @@ +/** + * Mock embedder for demonstration purposes ONLY. + * + * HOW IT WORKS: + * Tokens (words) from the input text are hashed to dimensions in a 128-dim + * vector space. The result is L2-normalised. Two texts with more shared words + * produce lower cosine distance (more similar). + * + * THIS IS NOT SEMANTIC SIMILARITY. + * "France capital budget 2024" will score close to "What is the capital of France?" + * because they share the words "france" and "capital" — even though they mean + * different things. A real embedding model would score these as dissimilar. + * + * USE THIS FOR: + * - Verifying the cache pipeline works (connect, store, retrieve, stats) + * - Running the example without an API key + * - CI/CD integration tests + * + * DO NOT USE THIS FOR: + * - Evaluating cache hit rates or threshold values + * - Benchmarking semantic cache effectiveness + * - Any production use + */ + +const DIM = 128; + +export const STOP_WORDS = new Set([ + 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'do', 'for', + 'from', 'had', 'has', 'have', 'he', 'her', 'his', 'how', 'i', 'if', 'in', + 'is', 'it', 'its', 'me', 'my', 'no', 'not', 'of', 'on', 'or', 'our', + 'she', 'so', 'than', 'that', 'the', 'their', 'them', 'then', 'there', + 'these', 'they', 'this', 'to', 'us', 'was', 'we', 'what', 'when', 'where', + 'which', 'who', 'will', 'with', 'would', 'you', 'your', +]); + +/** Hash a string to an integer in [0, max). djb2 variant. */ +function hashToIndex(s: string, max: number): number { + let h = 5381; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) + h) ^ s.charCodeAt(i); + h = h >>> 0; // keep unsigned 32-bit + } + return h % max; +} + +/** Tokenise: lowercase, strip punctuation, split on whitespace, remove stop words. */ +export function tokenise(text: string): string[] { + return text + .toLowerCase() + .replace(/[^\w\s]/g, ' ') + .split(/\s+/) + .filter((t) => t.length > 1 && !STOP_WORDS.has(t)); +} + +/** L2-normalise a vector in place. Returns the vector. */ +function normalise(vec: number[]): number[] { + const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)); + if (norm === 0) return vec; + for (let i = 0; i < vec.length; i++) vec[i] /= norm; + return vec; +} + +export async function mockEmbed(text: string): Promise { + const vec = new Array(DIM).fill(0); + const tokens = tokenise(text); + if (tokens.length === 0) return normalise(vec); + + for (const token of tokens) { + // Primary dimension for the token + const primary = hashToIndex(token, DIM); + vec[primary] += 1; + + // Secondary dimension for bigram context (token + length hash). + // Helps distinguish "capital France" from "capital Germany". + const secondary = hashToIndex(token + token.length.toString(), DIM); + vec[secondary] += 0.5; + } + + return normalise(vec); +} diff --git a/packages/semantic-cache/examples/basic/package-lock.json b/packages/semantic-cache/examples/basic/package-lock.json new file mode 100644 index 0000000..af17659 --- /dev/null +++ b/packages/semantic-cache/examples/basic/package-lock.json @@ -0,0 +1,749 @@ +{ + "name": "@betterdb/semantic-cache-example-basic", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@betterdb/semantic-cache-example-basic", + "version": "0.0.1", + "dependencies": { + "@betterdb/semantic-cache": "^0.1.0", + "iovalkey": "^0.3.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "ts-node": "^10.9.0", + "typescript": "^5.4.0" + } + }, + "../..": { + "name": "@betterdb/semantic-cache", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "prom-client": "^15.1.3" + }, + "devDependencies": { + "@langchain/core": ">=0.3.0", + "@types/node": "^20.0.0", + "ai": ">=4.0.0", + "typescript": "^5.4.0", + "vitest": "^1.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@langchain/core": ">=0.3.0", + "ai": ">=4.0.0", + "iovalkey": ">=0.3.0" + }, + "peerDependenciesMeta": { + "@langchain/core": { + "optional": true + }, + "ai": { + "optional": true + } + } + }, + "../../../../node_modules/.pnpm/@langchain+core@1.1.12_@opentelemetry+api@1.9.0_openai@6.16.0_ws@8.19.0_zod@4.3.5_/node_modules/@langchain/core": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", + "js-tiktoken": "^1.0.12", + "langsmith": ">=0.4.0 <1.0.0", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "uuid": "^10.0.0", + "zod": "^3.25.76 || ^4" + }, + "devDependencies": { + "@langchain/eslint": "0.1.1", + "@langchain/tsconfig": "0.0.1", + "@types/decamelize": "^1.2.0", + "@types/mustache": "^4", + "@types/uuid": "^10.0.0", + "dotenv": "^17.2.1", + "dpdm": "^3.14.0", + "eslint": "^9.34.0", + "ml-matrix": "^6.10.4", + "prettier": "^3.5.0", + "rimraf": "^5.0.1", + "typescript": "~5.8.3", + "vitest": "^3.2.4", + "web-streams-polyfill": "^4.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "../../../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/mocha": "10.0.6", + "@types/node": "18.6.5", + "@types/sinon": "17.0.3", + "@types/webpack": "5.28.5", + "@types/webpack-env": "1.16.3", + "babel-plugin-istanbul": "6.1.1", + "codecov": "3.8.3", + "cross-var": "1.1.0", + "dpdm": "3.13.1", + "karma": "6.4.3", + "karma-chrome-launcher": "3.1.0", + "karma-coverage": "2.2.1", + "karma-mocha": "2.0.1", + "karma-mocha-webworker": "1.3.0", + "karma-spec-reporter": "0.0.36", + "karma-webpack": "5.0.1", + "lerna": "6.6.2", + "memfs": "3.5.3", + "mocha": "10.2.0", + "nyc": "15.1.0", + "sinon": "15.1.2", + "ts-loader": "9.5.1", + "ts-mocha": "10.0.0", + "typescript": "4.4.4", + "unionfs": "4.5.4", + "webpack": "5.89.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "../../../../node_modules/.pnpm/@types+node@20.19.27/node_modules/@types/node": { + "version": "20.19.27", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "../../../../node_modules/.pnpm/ai@6.0.134_zod@4.3.5/node_modules/ai": { + "version": "6.0.134", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.77", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.21", + "@opentelemetry/api": "1.9.0" + }, + "devDependencies": { + "@ai-sdk/test-server": "1.0.3", + "@edge-runtime/vm": "^5.0.0", + "@types/json-schema": "7.0.15", + "@types/node": "20.17.24", + "@vercel/ai-tsconfig": "0.0.0", + "esbuild": "^0.24.2", + "tsup": "^7.2.0", + "tsx": "^4.19.2", + "typescript": "5.8.3", + "zod": "3.25.76" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "../../../../node_modules/.pnpm/iovalkey@0.3.3/node_modules/iovalkey": { + "version": "0.3.3", + "license": "MIT", + "peer": true, + "dependencies": { + "@iovalkey/commands": "^0.1.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "devDependencies": { + "@iovalkey/interface-generator": "^0.1.0", + "@types/chai": "^4.3.0", + "@types/chai-as-promised": "^7.1.5", + "@types/debug": "^4.1.5", + "@types/lodash.defaults": "^4.2.7", + "@types/lodash.isarguments": "^3.1.7", + "@types/mocha": "^9.1.0", + "@types/node": "^14.18.12", + "@types/redis-errors": "^1.2.1", + "@types/sinon": "^10.0.11", + "@typescript-eslint/eslint-plugin": "^5.48.1", + "@typescript-eslint/parser": "^5.48.1", + "chai": "^4.3.6", + "chai-as-promised": "^7.1.1", + "eslint": "^8.31.0", + "eslint-config-prettier": "^8.6.0", + "mocha": "^9.2.1", + "nyc": "^15.1.0", + "prettier": "^2.6.1", + "server-destroy": "^1.0.1", + "sinon": "^13.0.1", + "ts-node": "^10.4.0", + "tsd": "^0.19.1", + "typedoc": "^0.22.18", + "typescript": "^4.6.3", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "../../../../node_modules/.pnpm/prom-client@15.1.3/node_modules/prom-client": { + "version": "15.1.3", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "devDependencies": { + "@clevernature/benchmark-regression": "^1.0.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.32.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-n": "^16.0.0", + "eslint-plugin-prettier": "^5.0.1", + "express": "^4.13.3", + "husky": "^9.0.0", + "jest": "^29.3.1", + "lint-staged": "^13.1.0", + "nock": "^13.0.5", + "prettier": "3.3.2", + "typescript": "^5.0.4" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "devDependencies": { + "@dprint/formatter": "^0.4.1", + "@dprint/typescript": "0.93.4", + "@esfx/canceltoken": "^1.0.0", + "@eslint/js": "^9.20.0", + "@octokit/rest": "^21.1.1", + "@types/chai": "^4.3.20", + "@types/diff": "^7.0.1", + "@types/minimist": "^1.2.5", + "@types/mocha": "^10.0.10", + "@types/ms": "^0.7.34", + "@types/node": "latest", + "@types/source-map-support": "^0.5.10", + "@types/which": "^3.0.4", + "@typescript-eslint/rule-tester": "^8.24.1", + "@typescript-eslint/type-utils": "^8.24.1", + "@typescript-eslint/utils": "^8.24.1", + "azure-devops-node-api": "^14.1.0", + "c8": "^10.1.3", + "chai": "^4.5.0", + "chokidar": "^4.0.3", + "diff": "^7.0.0", + "dprint": "^0.49.0", + "esbuild": "^0.25.0", + "eslint": "^9.20.1", + "eslint-formatter-autolinkable-stylish": "^1.4.0", + "eslint-plugin-regexp": "^2.7.0", + "fast-xml-parser": "^4.5.2", + "glob": "^10.4.5", + "globals": "^15.15.0", + "hereby": "^1.10.0", + "jsonc-parser": "^3.3.1", + "knip": "^5.44.4", + "minimist": "^1.2.8", + "mocha": "^10.8.2", + "mocha-fivemat-progress-reporter": "^0.1.0", + "monocart-coverage-reports": "^2.12.1", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "playwright": "^1.50.1", + "source-map-support": "^0.5.21", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "typescript-eslint": "^8.24.1", + "which": "^3.0.1" + }, + "engines": { + "node": ">=14.17" + } + }, + "../../../../node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.27_terser@5.44.1/node_modules/vitest": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "devDependencies": { + "@ampproject/remapping": "^2.2.1", + "@antfu/install-pkg": "^0.3.1", + "@edge-runtime/vm": "^3.1.8", + "@sinonjs/fake-timers": "11.1.0", + "@types/estree": "^1.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/jsdom": "^21.1.6", + "@types/micromatch": "^4.0.6", + "@types/node": "^20.11.5", + "@types/prompts": "^2.4.9", + "@types/sinonjs__fake-timers": "^8.1.5", + "birpc": "0.2.15", + "cac": "^6.7.14", + "chai-subset": "^1.6.0", + "cli-truncate": "^4.0.0", + "expect-type": "^0.17.3", + "fast-glob": "^3.3.2", + "find-up": "^6.3.0", + "flatted": "^3.2.9", + "get-tsconfig": "^4.7.3", + "happy-dom": "^14.3.10", + "jsdom": "^24.0.0", + "log-update": "^5.0.1", + "micromatch": "^4.0.5", + "p-limit": "^5.0.0", + "pretty-format": "^29.7.0", + "prompts": "^2.4.2", + "strip-ansi": "^7.1.0", + "ws": "^8.14.2" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "../../node_modules/@langchain/core": { + "resolved": "../../../../node_modules/.pnpm/@langchain+core@1.1.12_@opentelemetry+api@1.9.0_openai@6.16.0_ws@8.19.0_zod@4.3.5_/node_modules/@langchain/core", + "link": true + }, + "../../node_modules/@opentelemetry/api": { + "resolved": "../../../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api", + "link": true + }, + "../../node_modules/@types/node": { + "resolved": "../../../../node_modules/.pnpm/@types+node@20.19.27/node_modules/@types/node", + "link": true + }, + "../../node_modules/ai": { + "resolved": "../../../../node_modules/.pnpm/ai@6.0.134_zod@4.3.5/node_modules/ai", + "link": true + }, + "../../node_modules/iovalkey": { + "resolved": "../../../../node_modules/.pnpm/iovalkey@0.3.3/node_modules/iovalkey", + "link": true + }, + "../../node_modules/prom-client": { + "resolved": "../../../../node_modules/.pnpm/prom-client@15.1.3/node_modules/prom-client", + "link": true + }, + "../../node_modules/typescript": { + "resolved": "../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript", + "link": true + }, + "../../node_modules/vitest": { + "resolved": "../../../../node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.27_terser@5.44.1/node_modules/vitest", + "link": true + }, + "node_modules/@betterdb/semantic-cache": { + "resolved": "../..", + "link": true + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@iovalkey/commands": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz", + "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==", + "license": "MIT" + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/iovalkey": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.3.3.tgz", + "integrity": "sha512-4rTJX6Q5wTYEvxboXi8DsEiUo+OvqJGtLYOSGm37KpdRXsG5XJjbVtYKGJpPSWP+QT7rWscA4vsrdmzbEbenpw==", + "license": "MIT", + "dependencies": { + "@iovalkey/commands": "^0.1.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/packages/semantic-cache/examples/basic/package.json b/packages/semantic-cache/examples/basic/package.json new file mode 100644 index 0000000..b72aac7 --- /dev/null +++ b/packages/semantic-cache/examples/basic/package.json @@ -0,0 +1,19 @@ +{ + "name": "@betterdb/semantic-cache-example-basic", + "version": "0.0.1", + "private": true, + "description": "Basic usage example for @betterdb/semantic-cache", + "scripts": { + "start": "ts-node index.ts", + "dev": "ts-node --watch index.ts" + }, + "dependencies": { + "@betterdb/semantic-cache": "^0.1.0", + "iovalkey": "^0.3.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "ts-node": "^10.9.0", + "typescript": "^5.4.0" + } +} diff --git a/packages/semantic-cache/package.json b/packages/semantic-cache/package.json new file mode 100644 index 0000000..6b40a1a --- /dev/null +++ b/packages/semantic-cache/package.json @@ -0,0 +1,61 @@ +{ + "name": "@betterdb/semantic-cache", + "version": "0.1.0", + "description": "Valkey-native semantic cache for LLM applications with built-in OpenTelemetry and Prometheus instrumentation", + "keywords": ["valkey", "redis", "semantic-cache", "llm", "opentelemetry", "prometheus"], + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./langchain": { + "import": "./dist/adapters/langchain.js", + "require": "./dist/adapters/langchain.js", + "types": "./dist/adapters/langchain.d.ts" + }, + "./ai": { + "import": "./dist/adapters/ai.js", + "require": "./dist/adapters/ai.js", + "types": "./dist/adapters/ai.d.ts" + } + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "prom-client": "^15.1.3" + }, + "devDependencies": { + "@langchain/core": ">=0.3.0", + "@types/node": "^20.0.0", + "ai": ">=4.0.0", + "typescript": "^5.4.0", + "vitest": "^1.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "iovalkey": ">=0.3.0", + "@langchain/core": ">=0.3.0", + "ai": ">=4.0.0" + }, + "peerDependenciesMeta": { + "@langchain/core": { + "optional": true + }, + "ai": { + "optional": true + } + } +} diff --git a/packages/semantic-cache/src/SemanticCache.ts b/packages/semantic-cache/src/SemanticCache.ts new file mode 100644 index 0000000..16580c3 --- /dev/null +++ b/packages/semantic-cache/src/SemanticCache.ts @@ -0,0 +1,495 @@ +import { randomUUID } from 'node:crypto'; +import { SpanStatusCode, type Span } from '@opentelemetry/api'; +import type { + SemanticCacheOptions, + CacheCheckOptions, + CacheStoreOptions, + CacheCheckResult, + CacheConfidence, + CacheStats, + IndexInfo, + InvalidateResult, + Valkey, + EmbedFn, +} from './types'; +import { + SemanticCacheUsageError, + EmbeddingError, + ValkeyCommandError, +} from './errors'; +import { createTelemetry, type Telemetry } from './telemetry'; +import { encodeFloat32, parseFtSearchResponse } from './utils'; + +const INVALIDATE_BATCH_SIZE = 1000; + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export class SemanticCache { + private readonly client: Valkey; + private readonly embedFn: EmbedFn; + private readonly name: string; + private readonly indexName: string; + private readonly entryPrefix: string; + private readonly statsKey: string; + private readonly defaultThreshold: number; + private readonly defaultTtl: number | undefined; + private readonly categoryThresholds: Record; + private readonly uncertaintyBand: number; + private readonly telemetry: Telemetry; + + private _initialized = false; + private _dimension = 0; + private _initPromise: Promise | null = null; + private _initGeneration = 0; + + /** + * Creates a new SemanticCache instance. + * + * The caller owns the iovalkey client lifecycle. SemanticCache does not + * close or disconnect the client when it is done. Call client.quit() or + * client.disconnect() yourself when the application shuts down. + * + * Call initialize() before using check() or store(). + */ + constructor(options: SemanticCacheOptions) { + this.client = options.client; + this.embedFn = options.embedFn; + this.name = options.name ?? 'betterdb_scache'; + this.indexName = `${this.name}:idx`; + this.entryPrefix = `${this.name}:entry:`; + this.statsKey = `${this.name}:__stats`; + this.defaultThreshold = options.defaultThreshold ?? 0.1; + this.defaultTtl = options.defaultTtl; + this.categoryThresholds = options.categoryThresholds ?? {}; + this.uncertaintyBand = options.uncertaintyBand ?? 0.05; + + this.telemetry = createTelemetry({ + prefix: options.telemetry?.metricsPrefix ?? 'semantic_cache', + tracerName: options.telemetry?.tracerName ?? '@betterdb/semantic-cache', + registry: options.telemetry?.registry, + }); + } + + // ── Lifecycle ────────────────────────────────────────────── + + async initialize(): Promise { + if (!this._initPromise) { + this._initPromise = this._doInitialize().catch((err) => { + this._initPromise = null; + throw err; + }); + } + return this._initPromise; + } + + async flush(): Promise { + // Mark uninitialized immediately so concurrent check()/store() calls get + // a clear SemanticCacheUsageError instead of cryptic Valkey errors. + // Bump generation so any in-flight _doInitialize() won't overwrite this state. + this._initialized = false; + this._initPromise = null; + this._initGeneration++; + + // Valkey Search 1.2 does not support the DD (Delete Documents) flag on + // FT.DROPINDEX. Drop the index first, then clean up keys separately. + try { + await this.client.call('FT.DROPINDEX', this.indexName); + } catch (err: unknown) { + if (!this.isIndexNotFoundError(err)) { + throw new ValkeyCommandError('FT.DROPINDEX', err); + } + } + + const entryPattern = `${this.name}:entry:*`; + let cursor = '0'; + do { + const [nextCursor, keys] = await this.client.scan( + cursor, 'MATCH', entryPattern, 'COUNT', '100', + ); + cursor = nextCursor; + if (keys.length > 0) await this.client.del(keys); + } while (cursor !== '0'); + + await this.client.del(this.statsKey); + } + + // ── Public operations ────────────────────────────────────── + + async check(prompt: string, options?: CacheCheckOptions): Promise { + this.assertInitialized('check'); + + return this.traced('check', async (span) => { + const category = options?.category ?? ''; + const k = options?.k ?? 1; + const threshold = + options?.threshold ?? + (category && this.categoryThresholds[category] !== undefined + ? this.categoryThresholds[category] + : this.defaultThreshold); + + const { vector: embedding, durationSec: embedSec } = await this.embed(prompt); + this.assertDimension(embedding); + + // FT.SEARCH — Valkey Search 1.2 rejects KNN aliases in RETURN/SORTBY, + // so we omit both. Results include all fields and are pre-sorted by distance. + const searchStart = performance.now(); + const filter = options?.filter; + const query = `${filter ? `(${filter})` : '*'}=>[KNN ${k} @embedding $vec AS __score]`; + let rawResult: unknown; + try { + rawResult = await this.client.call( + 'FT.SEARCH', this.indexName, query, + 'PARAMS', '2', 'vec', encodeFloat32(embedding), + 'LIMIT', '0', String(k), + 'DIALECT', '2', + ); + } catch (err) { + throw new ValkeyCommandError('FT.SEARCH', err); + } + const searchMs = performance.now() - searchStart; + + const parsed = parseFtSearchResponse(rawResult); + const categoryLabel = category || 'none'; + const timingAttrs = { 'embedding_latency_ms': embedSec * 1000, 'search_latency_ms': searchMs }; + + // No candidates at all + if (parsed.length === 0) { + await this.recordStat('misses'); + this.telemetry.metrics.requestsTotal + .labels({ cache_name: this.name, result: 'miss', category: categoryLabel }).inc(); + span.setAttributes({ + 'cache.hit': false, 'cache.name': this.name, + 'cache.category': categoryLabel, ...timingAttrs, + }); + return { hit: false, confidence: 'miss' as const }; + } + + const scoreStr = parsed[0].fields['__score']; + const score = scoreStr !== undefined ? parseFloat(scoreStr) : NaN; + + if (!isNaN(score)) { + this.telemetry.metrics.similarityScore + .labels({ cache_name: this.name, category: categoryLabel }).observe(score); + } + + // Miss (no usable score, or score exceeds threshold) + if (isNaN(score) || score > threshold) { + await this.recordStat('misses'); + this.telemetry.metrics.requestsTotal + .labels({ cache_name: this.name, result: 'miss', category: categoryLabel }).inc(); + span.setAttributes({ + 'cache.hit': false, 'cache.name': this.name, + 'cache.category': categoryLabel, ...timingAttrs, + ...(isNaN(score) ? {} : { 'cache.similarity': score, 'cache.threshold': threshold }), + }); + + const result: CacheCheckResult = { hit: false, confidence: 'miss' as const }; + if (!isNaN(score)) { + result.similarity = score; + result.nearestMiss = { similarity: score, deltaToThreshold: score - threshold }; + } + return result; + } + + // Hit + const confidence: CacheConfidence = + score >= threshold - this.uncertaintyBand ? 'uncertain' : 'high'; + + await this.recordStat('hits'); + const metricResult = confidence === 'uncertain' ? 'uncertain_hit' : 'hit'; + this.telemetry.metrics.requestsTotal + .labels({ cache_name: this.name, result: metricResult, category: categoryLabel }).inc(); + + const matchedKey = parsed[0].key; + if (this.defaultTtl !== undefined && matchedKey) { + await this.client.expire(matchedKey, this.defaultTtl); + } + + span.setAttributes({ + 'cache.hit': true, 'cache.similarity': score, 'cache.threshold': threshold, + 'cache.confidence': confidence, 'cache.matched_key': matchedKey, + 'cache.category': categoryLabel, ...timingAttrs, + }); + + return { + hit: true, response: parsed[0].fields['response'], + similarity: score, confidence, matchedKey, + }; + }); + } + + async store(prompt: string, response: string, options?: CacheStoreOptions): Promise { + this.assertInitialized('store'); + + return this.traced('store', async (span) => { + const { vector: embedding, durationSec: embedSec } = await this.embed(prompt); + this.assertDimension(embedding); + + const entryKey = `${this.entryPrefix}${randomUUID()}`; + const category = options?.category ?? ''; + const model = options?.model ?? ''; + + try { + await this.client.hset(entryKey, { + prompt, response, model, category, + inserted_at: Date.now().toString(), + metadata: JSON.stringify(options?.metadata ?? {}), + embedding: encodeFloat32(embedding), + } as Record); + } catch (err) { + throw new ValkeyCommandError('HSET', err); + } + + const ttl = options?.ttl ?? this.defaultTtl; + if (ttl !== undefined) await this.client.expire(entryKey, ttl); + + span.setAttributes({ + 'cache.name': this.name, 'cache.key': entryKey, 'cache.ttl': ttl ?? -1, + 'cache.category': category || 'none', 'cache.model': model || 'none', + 'embedding_latency_ms': embedSec * 1000, + }); + + return entryKey; + }); + } + + /** + * Deletes all entries matching a valkey-search filter expression. + * + * **Security note:** `filter` is passed directly to FT.SEARCH. Only pass + * trusted, programmatically-constructed expressions — never unsanitised + * user input. + */ + async invalidate(filter: string): Promise { + this.assertInitialized('invalidate'); + + return this.traced('invalidate', async (span) => { + let rawResult: unknown; + try { + rawResult = await this.client.call( + 'FT.SEARCH', this.indexName, filter, + 'RETURN', '0', + 'LIMIT', '0', String(INVALIDATE_BATCH_SIZE), + 'DIALECT', '2', + ); + } catch (err) { + throw new ValkeyCommandError('FT.SEARCH', err); + } + + const parsed = parseFtSearchResponse(rawResult); + if (parsed.length === 0) { + span.setAttributes({ + 'cache.name': this.name, 'cache.filter': filter, + 'cache.deleted_count': 0, 'cache.truncated': false, + }); + return { deleted: 0, truncated: false }; + } + + const keys = parsed.map((r) => r.key); + const truncated = keys.length === INVALIDATE_BATCH_SIZE; + try { + await this.client.del(keys); + } catch (err) { + throw new ValkeyCommandError('DEL', err); + } + + span.setAttributes({ + 'cache.name': this.name, 'cache.filter': filter, + 'cache.deleted_count': keys.length, 'cache.truncated': truncated, + }); + return { deleted: keys.length, truncated }; + }); + } + + async stats(): Promise { + this.assertInitialized('stats'); + const raw = await this.client.hgetall(this.statsKey); + const hits = parseInt(raw.hits ?? '0', 10); + const misses = parseInt(raw.misses ?? '0', 10); + const total = parseInt(raw.total ?? '0', 10); + return { hits, misses, total, hitRate: total === 0 ? 0 : hits / total }; + } + + async indexInfo(): Promise { + this.assertInitialized('indexInfo'); + let raw: unknown; + try { + raw = await this.client.call('FT.INFO', this.indexName); + } catch (err) { + throw new ValkeyCommandError('FT.INFO', err); + } + + const info = raw as unknown[]; + let numDocs = 0; + let indexingState = 'unknown'; + for (let i = 0; i < info.length - 1; i += 2) { + const key = String(info[i]); + if (key === 'num_docs') numDocs = parseInt(String(info[i + 1]), 10) || 0; + else if (key === 'indexing') indexingState = String(info[i + 1]); + } + + return { name: this.indexName, numDocs, dimension: this._dimension, indexingState }; + } + + // ── Private helpers ──────────────────────────────────────── + + private async _doInitialize(): Promise { + const gen = this._initGeneration; + return this.traced('initialize', async () => { + const dim = await this.ensureIndexAndGetDimension(); + // If flush() ran while we were initializing, don't overwrite its state. + if (this._initGeneration !== gen) return; + this._dimension = dim; + this._initialized = true; + }); + } + + private async ensureIndexAndGetDimension(): Promise { + // Try reading an existing index + try { + const info = (await this.client.call('FT.INFO', this.indexName)) as unknown[]; + const dim = this.parseDimensionFromInfo(info); + if (dim > 0) return dim; + // Couldn't parse dimension from FT.INFO — fall back to probe + return (await this.embed('probe')).vector.length; + } catch (err) { + if (err instanceof EmbeddingError) throw err; + if (!this.isIndexNotFoundError(err)) { + throw new ValkeyCommandError('FT.INFO', err); + } + } + + // Index doesn't exist — probe dimension and create it + const dim = (await this.embed('probe')).vector.length; + try { + await this.client.call( + 'FT.CREATE', this.indexName, 'ON', 'HASH', + 'PREFIX', '1', this.entryPrefix, + 'SCHEMA', + 'prompt', 'TEXT', 'NOSTEM', + 'response', 'TEXT', 'NOSTEM', + 'model', 'TAG', + 'category', 'TAG', + 'inserted_at', 'NUMERIC', 'SORTABLE', + 'embedding', 'VECTOR', 'HNSW', '6', + 'TYPE', 'FLOAT32', 'DIM', String(dim), 'DISTANCE_METRIC', 'COSINE', + ); + } catch (err) { + throw new ValkeyCommandError('FT.CREATE', err); + } + return dim; + } + + /** Wraps embedFn with error handling and duration tracking. */ + private async embed(text: string): Promise<{ vector: number[]; durationSec: number }> { + const start = performance.now(); + let vector: number[]; + try { + vector = await this.embedFn(text); + } catch (err) { + throw new EmbeddingError(`embedFn failed: ${errMsg(err)}`, err); + } + const durationSec = (performance.now() - start) / 1000; + this.telemetry.metrics.embeddingDuration + .labels({ cache_name: this.name }) + .observe(durationSec); + return { vector, durationSec }; + } + + /** + * Wraps a method body in an OTel span with automatic status, end, and + * operation duration metric. The span is passed to fn so callers can + * set attributes — but callers must NOT call span.end() or span.setStatus(), + * as traced() handles both. + */ + private async traced(operation: string, fn: (span: Span) => Promise): Promise { + const start = performance.now(); + return this.telemetry.tracer.startActiveSpan(`semantic_cache.${operation}`, async (span) => { + try { + const result = await fn(span); + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (err) { + span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) }); + throw err; + } finally { + span.end(); + this.telemetry.metrics.operationDuration + .labels({ cache_name: this.name, operation }) + .observe((performance.now() - start) / 1000); + } + }); + } + + /** Increment stats counters via pipeline. */ + private async recordStat(field: 'hits' | 'misses'): Promise { + const pipeline = this.client.pipeline(); + pipeline.hincrby(this.statsKey, 'total', 1); + pipeline.hincrby(this.statsKey, field, 1); + await pipeline.exec(); + } + + private assertInitialized(method: string): void { + if (!this._initialized) { + throw new SemanticCacheUsageError( + `SemanticCache.initialize() must be called before ${method}().`, + ); + } + } + + private assertDimension(embedding: number[]): void { + if (embedding.length !== this._dimension) { + throw new SemanticCacheUsageError( + `Embedding dimension mismatch: index expects ${this._dimension}, embedFn returned ${embedding.length}. Call flush() then initialize() to rebuild.`, + ); + } + } + + private isIndexNotFoundError(err: unknown): boolean { + const msg = err instanceof Error ? err.message.toLowerCase() : ''; + return ( + msg.includes('unknown index name') || + msg.includes('no such index') || + msg.includes('not found') + ); + } + + private parseDimensionFromInfo(info: unknown[]): number { + for (let i = 0; i < info.length - 1; i += 2) { + const key = String(info[i]); + if (key !== 'attributes' && key !== 'fields') continue; + + const attributes = info[i + 1]; + if (!Array.isArray(attributes)) continue; + + for (const attr of attributes) { + if (!Array.isArray(attr)) continue; + + let isVector = false; + let dim = 0; + + for (let j = 0; j < attr.length - 1; j++) { + const attrKey = String(attr[j]); + if (attrKey === 'type' && String(attr[j + 1]) === 'VECTOR') isVector = true; + if (attrKey.toLowerCase() === 'dim') dim = parseInt(String(attr[j + 1]), 10) || 0; + // Valkey Search 1.2 nests dimension inside an 'index' sub-array + if (attrKey === 'index' && Array.isArray(attr[j + 1])) { + const indexArr = attr[j + 1] as unknown[]; + for (let k = 0; k < indexArr.length - 1; k++) { + if (String(indexArr[k]) === 'dimensions') { + const d = parseInt(String(indexArr[k + 1]), 10) || 0; + if (d > 0) dim = d; + } + } + } + } + + if (isVector && dim > 0) return dim; + } + } + + return 0; + } +} diff --git a/packages/semantic-cache/src/__tests__/SemanticCache.integration.test.ts b/packages/semantic-cache/src/__tests__/SemanticCache.integration.test.ts new file mode 100644 index 0000000..7549e85 --- /dev/null +++ b/packages/semantic-cache/src/__tests__/SemanticCache.integration.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import Valkey from 'iovalkey'; +import { SemanticCache } from '../SemanticCache'; +import { SemanticCacheUsageError } from '../errors'; +import { sha256 } from '../utils'; +import { Registry } from 'prom-client'; +import type { EmbedFn } from '../types'; + +const VALKEY_URL = process.env.VALKEY_URL ?? 'redis://localhost:6380'; +const cacheName = `betterdb_test_${Date.now()}`; +const dim = 8; + +const fakeEmbed: EmbedFn = async (text: string) => { + const hash = sha256(text); + const vec = Array.from({ length: dim }, (_, i) => + parseInt(hash.slice(i * 2, i * 2 + 2), 16) / 255, + ); + const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)); + return vec.map((v) => v / norm); +}; + +let client: Valkey; +let cache: SemanticCache; +let skip = false; +let registry: Registry; + +beforeAll(async () => { + registry = new Registry(); + + // Use lazyConnect + connect() with a tight retry limit so we fail fast + // when Valkey is not reachable instead of retrying forever. + client = new Valkey(VALKEY_URL, { + lazyConnect: true, + retryStrategy: () => null, // do not retry + }); + + try { + await client.connect(); + await client.ping(); + } catch { + skip = true; + // Suppress further error events from the disconnected client + client.on('error', () => {}); + return; + } + + cache = new SemanticCache({ + name: cacheName, + client, + embedFn: fakeEmbed, + defaultThreshold: 0.1, + uncertaintyBand: 0.05, + telemetry: { + registry, + }, + }); +}); + +afterAll(async () => { + if (!skip && cache) { + try { + await cache.flush(); + } catch { + // Ignore cleanup errors + } + } + if (client) { + client.disconnect(); + } +}); + +describe('SemanticCache integration', () => { + it('initialize() creates the index; calling it twice does not throw', async () => { + if (skip) return; + await cache.initialize(); + // Second call should not throw + await cache.initialize(); + }); + + it('store() returns a string key matching the entry prefix', async () => { + if (skip) return; + const key = await cache.store('What is the capital of France?', 'Paris', { + category: 'test', + }); + expect(typeof key).toBe('string'); + expect(key.startsWith(`${cacheName}:entry:`)).toBe(true); + }); + + it('check() after storing same prompt returns hit with high confidence', async () => { + if (skip) return; + // Small delay for indexing + await new Promise((r) => setTimeout(r, 500)); + + const result = await cache.check('What is the capital of France?'); + expect(result.hit).toBe(true); + expect(result.confidence).toBe('high'); + expect(result.response).toBe('Paris'); + expect(result.similarity).toBeDefined(); + expect(result.matchedKey).toBeDefined(); + }); + + it('check() with a very different prompt returns miss with nearestMiss', async () => { + if (skip) return; + const result = await cache.check( + 'How do quantum computers use superposition for parallel computation?', + { threshold: 0.01 }, + ); + expect(result.hit).toBe(false); + expect(result.confidence).toBe('miss'); + if (result.nearestMiss) { + expect(result.nearestMiss.similarity).toBeGreaterThan(0); + expect(result.nearestMiss.deltaToThreshold).toBeGreaterThan(0); + } + }); + + it('check() before initialize() throws SemanticCacheUsageError', async () => { + if (skip) return; + const uninitCache = new SemanticCache({ + name: `uninit_${Date.now()}`, + client, + embedFn: fakeEmbed, + telemetry: { registry }, + }); + + await expect(uninitCache.check('test')).rejects.toThrow(SemanticCacheUsageError); + }); + + it('store() before initialize() throws SemanticCacheUsageError', async () => { + if (skip) return; + const uninitCache = new SemanticCache({ + name: `uninit_${Date.now()}`, + client, + embedFn: fakeEmbed, + telemetry: { registry }, + }); + + await expect(uninitCache.store('test', 'test')).rejects.toThrow( + SemanticCacheUsageError, + ); + }); + + it('stats() returns correct counts after hits and misses', async () => { + if (skip) return; + // Create a fresh cache with its own stats + const statsCacheName = `betterdb_stats_test_${Date.now()}`; + const statsCache = new SemanticCache({ + name: statsCacheName, + client, + embedFn: fakeEmbed, + defaultThreshold: 0.1, + telemetry: { registry }, + }); + + try { + await statsCache.initialize(); + await statsCache.store('Hello world', 'Hi there', { category: 'test' }); + + // Wait for indexing + await new Promise((r) => setTimeout(r, 500)); + + // This should be a hit (same prompt) + await statsCache.check('Hello world'); + + // This should be a miss (very different) + await statsCache.check('Quantum entanglement theory in physics', { + threshold: 0.001, + }); + + const stats = await statsCache.stats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.total).toBe(2); + expect(stats.hitRate).toBeCloseTo(0.5, 5); + } finally { + await statsCache.flush(); + } + }); + + it('invalidate() deletes matching entries and reports truncation status', async () => { + if (skip) return; + const invCacheName = `betterdb_inv_test_${Date.now()}`; + const invCache = new SemanticCache({ + name: invCacheName, + client, + embedFn: fakeEmbed, + defaultThreshold: 0.5, + telemetry: { registry }, + }); + + try { + await invCache.initialize(); + await invCache.store('Test invalidate prompt', 'Test response', { + category: 'test', + }); + + // Wait for indexing + await new Promise((r) => setTimeout(r, 500)); + + const { deleted, truncated } = await invCache.invalidate('@category:{test}'); + expect(deleted).toBeGreaterThanOrEqual(1); + expect(truncated).toBe(false); + + // Wait for index update + await new Promise((r) => setTimeout(r, 500)); + + // Subsequent check should be a miss + const result = await invCache.check('Test invalidate prompt'); + expect(result.hit).toBe(false); + } finally { + await invCache.flush(); + } + }); + + it('flush() drops index; subsequent initialize() re-creates it', async () => { + if (skip) return; + const flushCacheName = `betterdb_flush_test_${Date.now()}`; + const flushCache = new SemanticCache({ + name: flushCacheName, + client, + embedFn: fakeEmbed, + telemetry: { registry }, + }); + + await flushCache.initialize(); + await flushCache.flush(); + // Re-initializing should not throw + await flushCache.initialize(); + await flushCache.flush(); + }); + + it('indexInfo() returns valid metadata', async () => { + if (skip) return; + const info = await cache.indexInfo(); + expect(info.name).toBe(`${cacheName}:idx`); + expect(info.numDocs).toBeGreaterThanOrEqual(0); + expect(info.dimension).toBe(dim); + }); +}); diff --git a/packages/semantic-cache/src/__tests__/adapters.test.ts b/packages/semantic-cache/src/__tests__/adapters.test.ts new file mode 100644 index 0000000..a956043 --- /dev/null +++ b/packages/semantic-cache/src/__tests__/adapters.test.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ---------- LangChain adapter tests ---------- + +describe('BetterDBSemanticCache (LangChain adapter)', () => { + const mockCache = { + initialize: vi.fn().mockResolvedValue(undefined), + check: vi.fn(), + store: vi.fn().mockResolvedValue('key:1'), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Lazy import to avoid hard dependency on @langchain/core in test runner + async function createAdapter(opts?: { filterByModel?: boolean }) { + const { BetterDBSemanticCache } = await import('../adapters/langchain'); + return new BetterDBSemanticCache({ + cache: mockCache as any, + ...opts, + }); + } + + it('lookup() returns null on cache miss', async () => { + mockCache.check.mockResolvedValueOnce({ hit: false, confidence: 'miss' }); + const adapter = await createAdapter(); + const result = await adapter.lookup('What is AI?', 'model-hash'); + expect(result).toBeNull(); + }); + + it('lookup() returns [{ text }] on cache hit', async () => { + mockCache.check.mockResolvedValueOnce({ + hit: true, + response: 'Artificial intelligence is...', + confidence: 'high', + }); + const adapter = await createAdapter(); + const result = await adapter.lookup('What is AI?', 'model-hash'); + expect(result).toEqual([{ text: 'Artificial intelligence is...' }]); + }); + + it('update() calls cache.store() with joined generation text', async () => { + const adapter = await createAdapter(); + await adapter.update('What is AI?', 'model-hash', [ + { text: 'Part 1. ' }, + { text: 'Part 2.' }, + ]); + const expectedHash = createHash('sha256').update('model-hash').digest('hex').slice(0, 16); + expect(mockCache.store).toHaveBeenCalledWith('What is AI?', 'Part 1. Part 2.', { + model: expectedHash, + }); + }); + + it('initialize() is called lazily on first lookup(), not in constructor', async () => { + const adapter = await createAdapter(); + expect(mockCache.initialize).not.toHaveBeenCalled(); + mockCache.check.mockResolvedValueOnce({ hit: false, confidence: 'miss' }); + await adapter.lookup('test', 'hash'); + expect(mockCache.initialize).toHaveBeenCalledTimes(1); + }); + + it('initialize() is called only once across multiple calls', async () => { + const adapter = await createAdapter(); + mockCache.check.mockResolvedValue({ hit: false, confidence: 'miss' }); + await adapter.lookup('a', 'h'); + await adapter.lookup('b', 'h'); + await adapter.update('c', 'h', [{ text: 'response' }]); + expect(mockCache.initialize).toHaveBeenCalledTimes(1); + }); +}); + +// ---------- Vercel AI SDK middleware tests ---------- + +describe('createSemanticCacheMiddleware (AI SDK adapter)', () => { + const mockCache = { + initialize: vi.fn().mockResolvedValue(undefined), + check: vi.fn(), + store: vi.fn().mockResolvedValue('key:1'), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function createMiddleware() { + const { createSemanticCacheMiddleware } = await import('../adapters/ai'); + return createSemanticCacheMiddleware({ cache: mockCache as any }); + } + + function makeParams(userText: string) { + return { + prompt: [ + { role: 'user', content: [{ type: 'text', text: userText }] }, + ], + }; + } + + const modelResult = { + content: [{ type: 'text', text: 'Model response' }], + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 5 }, + warnings: [], + }; + + it('on cache hit, doGenerate is not called', async () => { + mockCache.check.mockResolvedValueOnce({ + hit: true, + response: 'Cached response', + confidence: 'high', + matchedKey: 'key:1', + }); + const middleware = await createMiddleware(); + const doGenerate = vi.fn(); + const doStream = vi.fn(); + + const result = await middleware.wrapGenerate!({ + doGenerate, + doStream, + params: makeParams('Hello') as any, + model: {} as any, + }); + + expect(doGenerate).not.toHaveBeenCalled(); + expect((result as any).content[0].text).toBe('Cached response'); + expect((result as any).finishReason).toBe('stop'); + }); + + it('on cache miss, doGenerate is called and result is stored', async () => { + mockCache.check.mockResolvedValueOnce({ hit: false, confidence: 'miss' }); + const middleware = await createMiddleware(); + const doGenerate = vi.fn().mockResolvedValue(modelResult); + const doStream = vi.fn(); + + const result = await middleware.wrapGenerate!({ + doGenerate, + doStream, + params: makeParams('Hello') as any, + model: {} as any, + }); + + expect(doGenerate).toHaveBeenCalledTimes(1); + expect(mockCache.store).toHaveBeenCalledWith('Hello', 'Model response'); + expect(result).toBe(modelResult); + }); + + it('initialize() is called lazily on first invocation', async () => { + mockCache.check.mockResolvedValue({ hit: false, confidence: 'miss' }); + const middleware = await createMiddleware(); + expect(mockCache.initialize).not.toHaveBeenCalled(); + + const doGenerate = vi.fn().mockResolvedValue(modelResult); + const doStream = vi.fn(); + await middleware.wrapGenerate!({ + doGenerate, + doStream, + params: makeParams('test') as any, + model: {} as any, + }); + expect(mockCache.initialize).toHaveBeenCalledTimes(1); + + // Second call should not re-initialize + await middleware.wrapGenerate!({ + doGenerate, + doStream, + params: makeParams('test2') as any, + model: {} as any, + }); + expect(mockCache.initialize).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/semantic-cache/src/__tests__/utils.test.ts b/packages/semantic-cache/src/__tests__/utils.test.ts new file mode 100644 index 0000000..631c47f --- /dev/null +++ b/packages/semantic-cache/src/__tests__/utils.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { + sha256, + encodeFloat32, + parseFtSearchResponse, +} from '../utils'; + +describe('sha256', () => { + it('returns consistent output for the same input', () => { + expect(sha256('test')).toBe(sha256('test')); + }); + + it('returns known digest for "hello"', () => { + expect(sha256('hello')).toBe( + '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + ); + }); +}); + +describe('encodeFloat32', () => { + it('returns a Buffer with byteLength === vec.length * 4', () => { + const vec = [1.0, 2.0, 3.0, 4.0]; + const buf = encodeFloat32(vec); + expect(buf.byteLength).toBe(vec.length * 4); + }); +}); + +describe('parseFtSearchResponse', () => { + it('returns [] for null', () => { + expect(parseFtSearchResponse(null)).toEqual([]); + }); + + it('returns [] for empty array', () => { + expect(parseFtSearchResponse([])).toEqual([]); + }); + + it('returns [] for ["0"]', () => { + expect(parseFtSearchResponse(['0'])).toEqual([]); + }); + + it('parses a single-entry response', () => { + const raw = [ + '1', + 'cache:entry:abc', + ['prompt', 'hello', 'response', 'world', '__score', '0.05'], + ]; + const result = parseFtSearchResponse(raw); + expect(result).toHaveLength(1); + expect(result[0].key).toBe('cache:entry:abc'); + expect(result[0].fields['prompt']).toBe('hello'); + expect(result[0].fields['response']).toBe('world'); + expect(result[0].fields['__score']).toBe('0.05'); + }); + + it('correctly extracts __score from a realistic response', () => { + const raw = [ + '2', + 'sc:entry:111', + ['prompt', 'q1', 'response', 'a1', '__score', '0.0234', 'model', 'gpt-4o', 'category', 'faq'], + 'sc:entry:222', + ['prompt', 'q2', 'response', 'a2', '__score', '0.1500', 'model', 'gpt-4o', 'category', 'search'], + ]; + const result = parseFtSearchResponse(raw); + expect(result).toHaveLength(2); + expect(parseFloat(result[0].fields['__score'])).toBeCloseTo(0.0234, 4); + expect(parseFloat(result[1].fields['__score'])).toBeCloseTo(0.15, 4); + }); + + it('returns [] without throwing for a malformed field list (odd-length)', () => { + const raw = ['1', 'key1', ['field1', 'val1', 'orphan']]; + const result = parseFtSearchResponse(raw); + expect(result).toHaveLength(1); + expect(result[0].fields['field1']).toBe('val1'); + // The orphan field should be skipped + expect(Object.keys(result[0].fields)).toHaveLength(1); + }); + + it('handles a two-result response', () => { + const raw = [ + '2', + 'key:a', + ['f1', 'v1'], + 'key:b', + ['f2', 'v2'], + ]; + const result = parseFtSearchResponse(raw); + expect(result).toHaveLength(2); + expect(result[0].key).toBe('key:a'); + expect(result[0].fields['f1']).toBe('v1'); + expect(result[1].key).toBe('key:b'); + expect(result[1].fields['f2']).toBe('v2'); + }); +}); diff --git a/packages/semantic-cache/src/adapters/ai.ts b/packages/semantic-cache/src/adapters/ai.ts new file mode 100644 index 0000000..a8e3b97 --- /dev/null +++ b/packages/semantic-cache/src/adapters/ai.ts @@ -0,0 +1,130 @@ +import type { LanguageModelMiddleware } from 'ai'; +import { SemanticCache } from '../SemanticCache'; + +export interface SemanticCacheMiddlewareOptions { + /** A pre-configured SemanticCache instance. */ + cache: SemanticCache; + /** + * Extract the prompt text from AI SDK messages. + * Default: joins all user message content text parts. + */ + extractPrompt?: (params: { prompt: Array<{ role: string; content: unknown }> }) => string; + /** + * Extract the response text from an AI SDK result. + * Default: finds the first text content part. + */ + extractResponse?: (result: { content: Array<{ type: string; text?: string }> }) => string; +} + +function defaultExtractPrompt(params: { + prompt: Array<{ role: string; content: unknown }>; +}): string { + const parts: string[] = []; + for (const msg of params.prompt) { + if (msg.role === 'user' && Array.isArray(msg.content)) { + for (const part of msg.content) { + if ( + typeof part === 'object' && + part !== null && + 'type' in part && + (part as { type: string }).type === 'text' && + 'text' in part + ) { + parts.push((part as { text: string }).text); + } + } + } + } + return parts.join('\n'); +} + +function defaultExtractResponse(result: { + content: Array<{ type: string; text?: string }>; +}): string { + for (const part of result.content ?? []) { + if (part.type === 'text' && part.text) { + return part.text; + } + } + return ''; +} + +/** + * Creates a LanguageModelMiddleware that adds semantic caching to any + * AI SDK language model. Use with wrapLanguageModel() from the 'ai' package. + * + * @example + * ```typescript + * import { wrapLanguageModel } from 'ai'; + * import { openai } from '@ai-sdk/openai'; + * import { createSemanticCacheMiddleware } from '@betterdb/semantic-cache/ai'; + * + * const model = wrapLanguageModel({ + * model: openai('gpt-4o'), + * middleware: createSemanticCacheMiddleware({ cache }), + * }); + * ``` + */ +export function createSemanticCacheMiddleware( + opts: SemanticCacheMiddlewareOptions, +): LanguageModelMiddleware { + const { cache } = opts; + const extractPrompt = opts.extractPrompt ?? defaultExtractPrompt; + const extractResponse = opts.extractResponse ?? defaultExtractResponse; + let initPromise: Promise | null = null; + + async function ensureInitialized(): Promise { + if (!initPromise) { + initPromise = cache.initialize().catch((err) => { + initPromise = null; // allow retry on transient failure + throw err; + }); + } + await initPromise; + } + + return { + specificationVersion: 'v3', + + wrapGenerate: async ({ doGenerate, params }) => { + await ensureInitialized(); + + const prompt = extractPrompt(params as unknown as { prompt: Array<{ role: string; content: unknown }> }); + if (prompt) { + try { + const cached = await cache.check(prompt); + if (cached.hit && cached.response) { + // Return a minimal generate result. Cast required because + // LanguageModelV3GenerateResult is imported transitively via the + // LanguageModelMiddleware type — we construct it inline to avoid + // depending on @ai-sdk/provider directly. + return { + content: [{ type: 'text', text: cached.response }], + finishReason: 'stop', + usage: { promptTokens: 0, completionTokens: 0 }, + warnings: [], + } as unknown as Awaited>; + } + } catch { + // Swallow check errors — caching should not break inference + } + } + + const result = await doGenerate(); + + if (prompt) { + const response = extractResponse(result as unknown as { content: Array<{ type: string; text?: string }> }); + if (response) { + await cache.store(prompt, response).catch(() => { + // Swallow store errors — caching should not break inference + }); + } + } + + return result; + }, + + // wrapStream is intentionally not implemented — semantic caching of + // streaming responses is not supported in v0.1 + }; +} diff --git a/packages/semantic-cache/src/adapters/langchain.ts b/packages/semantic-cache/src/adapters/langchain.ts new file mode 100644 index 0000000..88b70da --- /dev/null +++ b/packages/semantic-cache/src/adapters/langchain.ts @@ -0,0 +1,70 @@ +import { BaseCache } from '@langchain/core/caches'; +import type { Generation } from '@langchain/core/outputs'; +import { SemanticCache } from '../SemanticCache'; +import type { CacheCheckOptions } from '../types'; +import { sha256 } from '../utils'; + +export interface BetterDBSemanticCacheOptions { + /** A pre-configured SemanticCache instance. */ + cache: SemanticCache; + /** + * When true, cache lookups and stores are scoped to the specific LLM + * configuration (model, temperature, etc.). This prevents cross-model + * cache pollution but reduces hit rates — a prompt cached against gpt-4o + * will not hit against gpt-4o-mini even if the responses would be identical. + * + * The llm_string is hashed (SHA-256, first 16 hex chars) for use as a + * Valkey TAG field. The hash is deterministic: same LLM config = same hash. + * + * Default: false. + */ + filterByModel?: boolean; +} + +export class BetterDBSemanticCache extends BaseCache { + private cache: SemanticCache; + private filterByModel: boolean; + private initPromise: Promise | null = null; + + constructor(opts: BetterDBSemanticCacheOptions) { + super(); + this.cache = opts.cache; + this.filterByModel = opts.filterByModel ?? false; + } + + private async ensureInitialized(): Promise { + if (!this.initPromise) { + this.initPromise = this.cache.initialize().catch((err) => { + this.initPromise = null; // allow retry on transient failure + throw err; + }); + } + await this.initPromise; + } + + private modelHash(llm_string: string): string { + // llm_string is a serialised LangChain LLM config — not human-readable. + // Hash it to a stable, TAG-safe identifier. + return sha256(llm_string).slice(0, 16); + } + + async lookup(prompt: string, llm_string: string): Promise { + await this.ensureInitialized(); + const opts: CacheCheckOptions = {}; + if (this.filterByModel) { + opts.filter = `@model:{${this.modelHash(llm_string)}}`; + } + const result = await this.cache.check(prompt, opts); + if (!result.hit || !result.response) return null; + return [{ text: result.response }]; + } + + async update(prompt: string, llm_string: string, return_val: Generation[]): Promise { + await this.ensureInitialized(); + const text = return_val.map((g) => g.text).join(''); + if (!text) return; + await this.cache.store(prompt, text, { + model: this.modelHash(llm_string), + }); + } +} diff --git a/packages/semantic-cache/src/errors.ts b/packages/semantic-cache/src/errors.ts new file mode 100644 index 0000000..8b155e6 --- /dev/null +++ b/packages/semantic-cache/src/errors.ts @@ -0,0 +1,41 @@ +/** + * Thrown when the caller does something wrong — e.g. calling check() + * before initialize(), or providing an embedding with the wrong dimension. + * The message is always actionable: it tells the caller what to fix. + */ +export class SemanticCacheUsageError extends Error { + constructor(message: string) { + super(message); + this.name = 'SemanticCacheUsageError'; + } +} + +/** + * Thrown when the embedding function fails. + * Check the underlying cause for the original error from the embedding provider. + */ +export class EmbeddingError extends Error { + constructor( + message: string, + public readonly cause: unknown, + ) { + super(message); + this.name = 'EmbeddingError'; + } +} + +/** + * Thrown when a Valkey command fails unexpectedly. + * Includes the command name and the underlying error. + */ +export class ValkeyCommandError extends Error { + constructor( + public readonly command: string, + public readonly cause: unknown, + ) { + super( + `Valkey command '${command}' failed: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + this.name = 'ValkeyCommandError'; + } +} diff --git a/packages/semantic-cache/src/index.ts b/packages/semantic-cache/src/index.ts new file mode 100644 index 0000000..c47aa52 --- /dev/null +++ b/packages/semantic-cache/src/index.ts @@ -0,0 +1,17 @@ +export { SemanticCache } from './SemanticCache'; +export type { + SemanticCacheOptions, + CacheCheckOptions, + CacheStoreOptions, + CacheCheckResult, + CacheStats, + IndexInfo, + InvalidateResult, + CacheConfidence, + EmbedFn, +} from './types'; +export { + SemanticCacheUsageError, + EmbeddingError, + ValkeyCommandError, +} from './errors'; diff --git a/packages/semantic-cache/src/telemetry.ts b/packages/semantic-cache/src/telemetry.ts new file mode 100644 index 0000000..4879757 --- /dev/null +++ b/packages/semantic-cache/src/telemetry.ts @@ -0,0 +1,89 @@ +import { trace, type Tracer } from '@opentelemetry/api'; +import { + Counter, + Histogram, + Registry, + register as defaultRegistry, + type CounterConfiguration, + type HistogramConfiguration, +} from 'prom-client'; + +interface TelemetryFactoryOptions { + prefix: string; + tracerName: string; + registry?: Registry; +} + +interface CacheMetrics { + requestsTotal: Counter; + similarityScore: Histogram; + operationDuration: Histogram; + embeddingDuration: Histogram; +} + +export interface Telemetry { + tracer: Tracer; + metrics: CacheMetrics; +} + +function getOrCreateCounter( + registry: Registry, + config: CounterConfiguration, +): Counter { + const existing = registry.getSingleMetric(config.name); + if (existing) return existing as Counter; + return new Counter({ ...config, registers: [registry] }); +} + +function getOrCreateHistogram( + registry: Registry, + config: HistogramConfiguration, +): Histogram { + const existing = registry.getSingleMetric(config.name); + if (existing) return existing as Histogram; + return new Histogram({ ...config, registers: [registry] }); +} + +export function createTelemetry(opts: TelemetryFactoryOptions): Telemetry { + const registry = opts.registry ?? defaultRegistry; + const tracer = trace.getTracer(opts.tracerName); + + const operationBuckets = [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0]; + + const requestsTotal = getOrCreateCounter(registry, { + name: `${opts.prefix}_requests_total`, + help: 'Total number of semantic cache requests', + labelNames: ['cache_name', 'result', 'category'], + }); + + const similarityScore = getOrCreateHistogram(registry, { + name: `${opts.prefix}_similarity_score`, + help: 'Cosine distance similarity scores for cache lookups', + labelNames: ['cache_name', 'category'], + buckets: [0.02, 0.05, 0.08, 0.1, 0.12, 0.15, 0.2, 0.3, 0.5, 1.0, 2.0], + }); + + const operationDuration = getOrCreateHistogram(registry, { + name: `${opts.prefix}_operation_duration_seconds`, + help: 'Duration of semantic cache operations in seconds', + labelNames: ['cache_name', 'operation'], + buckets: operationBuckets, + }); + + const embeddingDuration = getOrCreateHistogram(registry, { + name: `${opts.prefix}_embedding_duration_seconds`, + help: 'Duration of embedding function calls in seconds', + labelNames: ['cache_name'], + buckets: operationBuckets, + }); + + return { + tracer, + metrics: { + requestsTotal, + similarityScore, + operationDuration, + embeddingDuration, + }, + }; +} diff --git a/packages/semantic-cache/src/types.ts b/packages/semantic-cache/src/types.ts new file mode 100644 index 0000000..4e0b85b --- /dev/null +++ b/packages/semantic-cache/src/types.ts @@ -0,0 +1,152 @@ +import type Valkey from 'iovalkey'; +import type { Registry } from 'prom-client'; + +export type { Valkey }; + +export type EmbedFn = (text: string) => Promise; + +export interface SemanticCacheOptions { + /** Index name prefix used for Valkey keys. Default: 'betterdb_scache'. */ + name?: string; + /** iovalkey client instance. Required. */ + client: Valkey; + /** Async function that returns a float embedding vector for a text string. Required. */ + embedFn: EmbedFn; + /** + * Default similarity threshold as cosine DISTANCE (0–2 scale, lower = more similar). + * A lookup is a hit when score <= threshold. Default: 0.1. + * NOTE: this is cosine DISTANCE not cosine SIMILARITY. + * Distance 0 = identical, distance 2 = opposite. + */ + defaultThreshold?: number; + /** Default TTL in seconds for stored entries. undefined = no expiry. */ + defaultTtl?: number; + /** + * Per-category threshold overrides (cosine distance, 0–2). + * Applied when CacheCheckOptions.category matches a key here. + * Example: { faq: 0.08, search: 0.15 } + */ + categoryThresholds?: Record; + /** + * Width of the "uncertainty band" below the threshold. + * A hit whose cosine distance falls within [threshold - band, threshold] + * is returned with confidence 'uncertain' instead of 'high'. + * + * What to do with an uncertain hit: + * - Use the cached response but flag it for downstream review + * - Fall back to the LLM and optionally update the cache entry + * - Collect uncertain hits via Prometheus/OTel and review them to tune + * your threshold — a high rate of uncertain hits suggests your threshold + * is too loose + * + * Default: 0.05. Set to 0 to disable uncertainty flagging (all hits are 'high'). + */ + uncertaintyBand?: number; + telemetry?: { + /** OTel tracer name. Default: '@betterdb/semantic-cache'. */ + tracerName?: string; + /** Prefix for Prometheus metric names. Default: 'semantic_cache'. */ + metricsPrefix?: string; + /** + * prom-client Registry to register metrics on. + * If omitted, uses the prom-client default registry. + * Pass a custom Registry in library/multi-tenant contexts to avoid + * polluting the host application's default registry. + */ + registry?: Registry; + }; +} + +export interface CacheCheckOptions { + /** Per-request threshold override (cosine distance 0–2). Highest priority. */ + threshold?: number; + /** Category tag — used for per-category threshold lookup and metric labels. */ + category?: string; + /** + * Additional FT.SEARCH pre-filter expression. + * Example: '@model:{gpt-4o}' + * Applied as: "({filter})=>[KNN {k} @embedding $vec AS __score]" + * + * **Security note:** this string is interpolated directly into the FT.SEARCH + * query. Only pass trusted, programmatically-constructed expressions — never + * unsanitised user input. + */ + filter?: string; + /** + * Number of nearest neighbours to fetch via KNN. Default: 1. + * Currently only the closest result is evaluated for hit/miss. + * Values > 1 are reserved for future multi-candidate support. + */ + k?: number; +} + +export interface CacheStoreOptions { + /** Per-entry TTL in seconds. Overrides SemanticCacheOptions.defaultTtl. */ + ttl?: number; + /** Category tag stored with the entry. */ + category?: string; + /** Model name stored with the entry (e.g. 'gpt-4o'). Enables invalidation by model. */ + model?: string; + /** + * Arbitrary metadata stored as JSON alongside the entry. + * Stored for external consumption (e.g. BetterDB Monitor) — not returned by check(). + */ + metadata?: Record; +} + +export type CacheConfidence = 'high' | 'uncertain' | 'miss'; + +export interface CacheCheckResult { + hit: boolean; + response?: string; + /** + * Cosine distance score (0–2). Present when a nearest neighbour was found, + * regardless of whether it was a hit or miss. + */ + similarity?: number; + /** + * Confidence classification for the result. + * + * - 'high': similarity score is comfortably below the threshold (distance <= threshold - uncertaintyBand). + * Safe to return directly. + * - 'uncertain': similarity score is close to the threshold boundary + * (threshold - uncertaintyBand < distance <= threshold). + * Consider falling back to the LLM or flagging for review. + * - 'miss': no hit. response is undefined. + */ + confidence: CacheConfidence; + /** Valkey key of the matched entry. Present on hit only. */ + matchedKey?: string; + /** + * On a miss where a candidate existed but didn't clear the threshold, + * describes how close it was. Useful for threshold tuning. + */ + nearestMiss?: { + similarity: number; + deltaToThreshold: number; + }; +} + +export interface InvalidateResult { + /** Number of entries deleted in this call. */ + deleted: number; + /** + * True if the result set was truncated at 1000 entries. + * If true, call invalidate() again with the same filter until truncated is false. + */ + truncated: boolean; +} + +export interface CacheStats { + hits: number; + misses: number; + total: number; + hitRate: number; +} + +export interface IndexInfo { + name: string; + numDocs: number; + dimension: number; + indexingState: string; +} diff --git a/packages/semantic-cache/src/utils.ts b/packages/semantic-cache/src/utils.ts new file mode 100644 index 0000000..6329342 --- /dev/null +++ b/packages/semantic-cache/src/utils.ts @@ -0,0 +1,82 @@ +import { createHash } from 'node:crypto'; + +/** SHA-256 hex digest of a string. */ +export function sha256(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +/** + * Encode number[] as a little-endian Float32 Buffer. + * Used to store embeddings as binary HSET field values. + */ +export function encodeFloat32(vec: number[]): Buffer { + const buf = Buffer.alloc(vec.length * 4); + for (let i = 0; i < vec.length; i++) { + buf.writeFloatLE(vec[i], i * 4); + } + return buf; +} + +/** + * Parse a raw FT.SEARCH response from iovalkey's client.call(). + * + * iovalkey returns FT.SEARCH results in the following shape: + * [totalCount, key1, [field1, val1, field2, val2, ...], key2, [...], ...] + * + * - totalCount is a string (e.g. "2") + * - Each key is a string + * - Each field list is a flat string array: [fieldName, value, fieldName, value, ...] + * + * Returns an array of { key: string, fields: Record }. + * Returns [] if totalCount is "0" or the response is empty/malformed. + * Never throws — on any parse error, returns []. + */ +export function parseFtSearchResponse( + raw: unknown, +): Array<{ key: string; fields: Record }> { + try { + if (!Array.isArray(raw) || raw.length < 1) { + return []; + } + + const totalCount = typeof raw[0] === 'string' ? parseInt(raw[0], 10) : Number(raw[0]); + if (!totalCount || totalCount <= 0) { + return []; + } + + const results: Array<{ key: string; fields: Record }> = []; + + let i = 1; + while (i < raw.length) { + const key = raw[i]; + if (typeof key !== 'string') { + i++; + continue; + } + + const fieldList = raw[i + 1]; + const fields: Record = {}; + + if (Array.isArray(fieldList)) { + const len = fieldList.length - (fieldList.length % 2); + for (let j = 0; j < len; j += 2) { + const fieldName = String(fieldList[j]); + const fieldValue = String(fieldList[j + 1]); + fields[fieldName] = fieldValue; + } + i += 2; + } else { + // No field list follows the key (e.g. RETURN 0 mode) + results.push({ key, fields }); + i++; + continue; + } + + results.push({ key, fields }); + } + + return results; + } catch { + return []; + } +} diff --git a/packages/semantic-cache/tsconfig.json b/packages/semantic-cache/tsconfig.json new file mode 100644 index 0000000..78f3e3f --- /dev/null +++ b/packages/semantic-cache/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022"], + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/__tests__"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48b8af7..ec92915 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -371,6 +371,34 @@ importers: specifier: ^5.0.0 version: 5.9.3 + packages/semantic-cache: + dependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + iovalkey: + specifier: '>=0.3.0' + version: 0.3.3 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 + devDependencies: + '@langchain/core': + specifier: '>=0.3.0' + version: 1.1.12(@opentelemetry/api@1.9.0)(openai@6.16.0(ws@8.19.0)(zod@4.3.5)) + '@types/node': + specifier: ^20.0.0 + version: 20.19.27 + ai: + specifier: '>=4.0.0' + version: 6.0.134(zod@4.3.5) + typescript: + specifier: ^5.4.0 + version: 5.9.3 + vitest: + specifier: ^1.6.0 + version: 1.6.1(@types/node@20.19.27)(terser@5.44.1) + packages/shared: devDependencies: '@types/node': @@ -455,6 +483,22 @@ importers: packages: + '@ai-sdk/gateway@3.0.77': + resolution: {integrity: sha512-UdwIG2H2YMuntJQ5L+EmED5XiwnlvDT3HOmKfVFxR4Nq/RSLFA/HcchhwfNXHZ5UJjyuL2VO0huLbWSZ9ijemQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.21': + resolution: {integrity: sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -734,102 +778,204 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -842,6 +988,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -854,6 +1006,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -866,24 +1024,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -2389,6 +2571,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -2819,12 +3004,31 @@ packages: resolution: {integrity: sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==} hasBin: true + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2907,6 +3111,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2919,6 +3128,12 @@ packages: resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} engines: {node: '>=18'} + ai@6.0.134: + resolution: {integrity: sha512-YalNEaavld/kE444gOcsMKXdVVRGEe0SK77fAFcWYcqLg+a7xKnEet8bdfrEAJTfnMjj01rhgrIL10903w1a5Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -3025,6 +3240,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -3210,6 +3428,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -3241,6 +3463,10 @@ packages: caniuse-lite@1.0.30001761: resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + chalk-template@0.4.0: resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} engines: {node: '>=12'} @@ -3263,6 +3489,9 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -3420,6 +3649,9 @@ packages: engines: {node: '>=18'} hasBin: true + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -3658,6 +3890,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -3797,6 +4033,11 @@ packages: es-toolkit@1.43.0: resolution: {integrity: sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -3890,6 +4131,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -4182,6 +4426,9 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -4771,6 +5018,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -4814,6 +5064,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4909,6 +5162,10 @@ packages: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -4968,6 +5225,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -5097,6 +5357,9 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5279,6 +5542,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -5352,6 +5619,15 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + peek-readable@4.1.0: resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==} engines: {node: '>=8'} @@ -5439,6 +5715,9 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.57.0: resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} engines: {node: '>=18'} @@ -5949,6 +6228,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -6027,6 +6309,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -6038,6 +6323,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -6100,6 +6388,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + stripe@14.25.0: resolution: {integrity: sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==} engines: {node: '>=12.*'} @@ -6233,10 +6524,21 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -6385,6 +6687,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -6411,6 +6717,9 @@ packages: resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} engines: {node: '>=12.17'} + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -6497,6 +6806,42 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6537,6 +6882,31 @@ packages: yaml: optional: true + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -6593,6 +6963,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -6671,6 +7046,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.3: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} @@ -6688,6 +7067,24 @@ packages: snapshots: + '@ai-sdk/gateway@3.0.77(zod@4.3.5)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.21(zod@4.3.5) + '@vercel/oidc': 3.1.0 + zod: 4.3.5 + + '@ai-sdk/provider-utils@4.0.21(zod@4.3.5)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.5 + + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@alloc/quick-lru@5.2.0': {} '@angular-devkit/core@17.3.11(chokidar@3.6.0)': @@ -7048,81 +7445,150 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -8608,6 +9074,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@swc/helpers@0.5.18': @@ -9080,6 +9548,8 @@ snapshots: '@vercel/ncc@0.38.4': {} + '@vercel/oidc@3.1.0': {} + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 @@ -9092,6 +9562,35 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.17 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -9197,6 +9696,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.16.0: {} + agent-base@7.1.4: {} agentkeepalive@4.6.0: @@ -9208,6 +9709,14 @@ snapshots: clean-stack: 5.3.0 indent-string: 5.0.0 + ai@6.0.134(zod@4.3.5): + dependencies: + '@ai-sdk/gateway': 3.0.77(zod@4.3.5) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.21(zod@4.3.5) + '@opentelemetry/api': 1.9.0 + zod: 4.3.5 + ajv-formats@2.1.1(ajv@8.12.0): optionalDependencies: ajv: 8.12.0 @@ -9325,6 +9834,8 @@ snapshots: asap@2.0.6: {} + assertion-error@1.1.0: {} + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -9569,6 +10080,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -9596,6 +10109,16 @@ snapshots: caniuse-lite@1.0.30001761: {} + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + chalk-template@0.4.0: dependencies: chalk: 4.1.2 @@ -9613,6 +10136,10 @@ snapshots: chardet@2.1.1: {} + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -9758,6 +10285,8 @@ snapshots: tree-kill: 1.2.2 yargs: 17.7.2 + confbox@0.1.8: {} + consola@3.4.2: {} console-table-printer@2.15.0: @@ -10011,6 +10540,10 @@ snapshots: dedent@1.7.1: {} + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -10122,6 +10655,32 @@ snapshots: es-toolkit@1.43.0: {} + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -10248,6 +10807,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} @@ -10622,6 +11185,8 @@ snapshots: get-east-asian-width@1.5.0: {} + get-func-name@2.0.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -11671,6 +12236,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -11708,6 +12275,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -11812,6 +12381,11 @@ snapshots: loader-runner@4.3.1: {} + local-pkg@0.5.1: + dependencies: + mlly: 1.8.2 + pkg-types: 1.3.1 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -11860,6 +12434,10 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + lru-cache@10.4.3: {} lru-cache@11.2.4: {} @@ -11953,6 +12531,13 @@ snapshots: mkdirp-classic@0.5.3: {} + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + ms@2.1.3: {} mustache@4.2.0: {} @@ -12117,6 +12702,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -12179,6 +12768,12 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + peek-readable@4.1.0: {} pg-cloudflare@1.2.7: @@ -12260,6 +12855,12 @@ snapshots: dependencies: find-up: 4.1.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + playwright-core@1.57.0: {} playwright@1.57.0: @@ -12786,6 +13387,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -12859,12 +13462,16 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + standard-as-callback@2.1.0: {} statuses@2.0.1: {} statuses@2.0.2: {} + std-env@3.10.0: {} + stdin-discarder@0.2.2: {} stream-buffers@3.0.3: {} @@ -12925,6 +13532,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + stripe@14.25.0: dependencies: '@types/node': 22.19.11 @@ -13129,11 +13740,17 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -13307,6 +13924,8 @@ snapshots: type-detect@4.0.8: {} + type-detect@4.1.0: {} + type-fest@0.21.3: {} type-fest@4.41.0: {} @@ -13323,6 +13942,8 @@ snapshots: typical@7.3.0: {} + ufo@1.6.3: {} + uglify-js@3.19.3: optional: true @@ -13424,6 +14045,34 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite-node@1.6.1(@types/node@20.19.27)(terser@5.44.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@5.5.0) + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.27)(terser@5.44.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.27)(terser@5.44.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.3 + optionalDependencies: + '@types/node': 20.19.27 + fsevents: 2.3.3 + terser: 5.44.1 + vite@6.4.1(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -13439,6 +14088,40 @@ snapshots: terser: 5.44.1 yaml: 2.8.2 + vitest@1.6.1(@types/node@20.19.27)(terser@5.44.1): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.4 + chai: 4.5.0 + debug: 4.4.3(supports-color@5.5.0) + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.17 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.27)(terser@5.44.1) + vite-node: 1.6.1(@types/node@20.19.27)(terser@5.44.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.27 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -13533,6 +14216,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wordwrap@1.0.0: {} @@ -13595,6 +14283,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + yoctocolors-cjs@2.1.3: {} zod-to-json-schema@3.25.1(zod@3.25.76):