diff --git a/CLAUDE.md b/CLAUDE.md index 556c3ab..1746b8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ qmd get # Get document by path or docid (#abc123) qmd multi-get # Get multiple docs by glob or comma-separated list qmd status # Show index status and collections qmd update [--pull] # Re-index all collections (--pull: git pull first) -qmd embed # Generate vector embeddings (requires Ollama) +qmd embed # Generate vector embeddings (uses node-llama-cpp) qmd search # BM25 full-text search qmd vsearch # Vector similarity search qmd query # Hybrid search with reranking (best quality) @@ -124,8 +124,9 @@ bun link # Install globally as 'qmd' - SQLite FTS5 for full-text search (BM25) - sqlite-vec for vector similarity search -- Ollama for embeddings (embeddinggemma) and reranking (qwen3-reranker) +- node-llama-cpp for embeddings (embeddinggemma), reranking (qwen3-reranker), and query expansion (Qwen3) - Reciprocal Rank Fusion (RRF) for combining results +- Token-based chunking: 800 tokens/chunk with 15% overlap ## Important: Do NOT run automatically diff --git a/README.md b/README.md index c3020ac..81c6154 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ An on-device search engine for everything you need to remember. Index your markdown notes, meeting transcripts, documentation, and knowledge bases. Search with keywords or natural language. Ideal for your agentic flows. -QMD combines BM25 full-text search, vector semantic search, and LLM re-ranking—all running locally via Ollama. +QMD combines BM25 full-text search, vector semantic search, and LLM re-ranking—all running locally via node-llama-cpp with GGUF models. ## Quick Start @@ -112,7 +112,7 @@ Although the tool works perfectly fine when you just tell your agent to use it o ▼ ▼ ┌────────────────┐ ┌────────────────┐ │ Query Expansion│ │ Original Query│ - │ (qwen3:0.6b) │ │ (×2 weight) │ + │ (Qwen3-0.6B) │ │ (×2 weight) │ └───────┬────────┘ └───────┬────────┘ │ │ │ 2 alternative queries │ @@ -204,24 +204,18 @@ The `query` command uses **Reciprocal Rank Fusion (RRF)** with position-aware bl ```sh brew install sqlite ``` -- **Ollama** running locally (default: `http://localhost:11434`) -### Ollama Models +### GGUF Models (via node-llama-cpp) -QMD uses three models (auto-pulled if missing): +QMD uses three local GGUF models (auto-downloaded on first use): | Model | Purpose | Size | |-------|---------|------| -| `embeddinggemma` | Vector embeddings | ~1.6GB | -| `ExpedientFalcon/qwen3-reranker:0.6b-q8_0` | Re-ranking (trained) | ~640MB | -| `qwen3:0.6b` | Query expansion | ~400MB | +| `embeddinggemma-300M-Q8_0` | Vector embeddings | ~300MB | +| `qwen3-reranker-0.6b-q8_0` | Re-ranking | ~640MB | +| `Qwen3-0.6B-Q8_0` | Query expansion | ~640MB | -```sh -# Pre-pull models (optional) -ollama pull embeddinggemma -ollama pull ExpedientFalcon/qwen3-reranker:0.6b-q8_0 -ollama pull qwen3:0.6b -``` +Models are downloaded from HuggingFace and cached in `~/.cache/qmd/models/`. ## Installation @@ -257,7 +251,7 @@ qmd ls notes/subfolder ### Generate Vector Embeddings ```sh -# Embed all indexed documents (chunked into ~6KB pieces) +# Embed all indexed documents (800 tokens/chunk, 15% overlap) qmd embed # Force re-embed everything @@ -434,16 +428,15 @@ collections -- Indexed directories with name and glob patterns path_contexts -- Context descriptions by virtual path (qmd://...) documents -- Markdown content with metadata and docid (6-char hash) documents_fts -- FTS5 full-text index -content_vectors -- Embedding chunks (hash, seq, pos) +content_vectors -- Embedding chunks (hash, seq, pos, 800 tokens each) vectors_vec -- sqlite-vec vector index (hash_seq key) -ollama_cache -- Cached API responses +llm_cache -- Cached LLM responses (query expansion, rerank scores) ``` ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| -| `OLLAMA_URL` | `http://localhost:11434` | Ollama API endpoint | | `XDG_CACHE_HOME` | `~/.cache` | Cache directory location | ## How It Works @@ -465,11 +458,11 @@ Collection ──► Glob Pattern ──► Markdown Files ──► Parse Title ### Embedding Flow -Documents are chunked into ~6KB pieces to fit the embedding model's token window: +Documents are chunked into 800-token pieces with 15% overlap: ``` -Document ──► Chunk (~6KB each) ──► Format each chunk ──► Ollama API ──► Store Vectors - │ "title | text" /api/embed +Document ──► Chunk (800 tokens) ──► Format each chunk ──► node-llama-cpp ──► Store Vectors + │ "title | text" embedBatch() │ └─► Chunks stored with: - hash: document hash @@ -517,12 +510,12 @@ Query ──► LLM Expansion ──► [Original, Variant 1, Variant 2] ## Model Configuration -Models are configured as constants in `src/qmd.ts`: +Models are configured in `src/llm.ts` as HuggingFace URIs: ```typescript -const DEFAULT_EMBED_MODEL = "embeddinggemma"; -const DEFAULT_RERANK_MODEL = "ExpedientFalcon/qwen3-reranker:0.6b-q8_0"; -const DEFAULT_QUERY_MODEL = "qwen3:0.6b"; +const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf"; +const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf"; +const DEFAULT_GENERATE_MODEL = "hf:ggml-org/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf"; ``` ### EmbeddingGemma Prompt Format @@ -537,24 +530,11 @@ const DEFAULT_QUERY_MODEL = "qwen3:0.6b"; ### Qwen3-Reranker -A dedicated reranker model trained on relevance classification: - -``` -System: Judge whether the Document meets the requirements based on the Query - and the Instruct provided. Note that the answer can only be "yes" or "no". - -User: : Given a search query, determine if the document is relevant... - : {query} - : {doc} -``` - -- Uses `logprobs: true` to extract token probabilities -- Outputs yes/no with confidence score (0.0 - 1.0) -- `num_predict: 1` - Only need the yes/no token +Uses node-llama-cpp's `createRankingContext()` and `rankAndSort()` API for cross-encoder reranking. Returns documents sorted by relevance score (0.0 - 1.0). ### Qwen3 (Query Expansion) -- `num_predict: 150` - For generating query variations +Used for generating query variations via `LlamaChatSession`. ## License diff --git a/bun.lock b/bun.lock index 78df3e2..8d405cf 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "2025-12-07-bm25-q", "dependencies": { "@modelcontextprotocol/sdk": "^1.24.3", + "node-llama-cpp": "^3.14.5", "sqlite-vec": "^0.1.7-alpha.2", "yaml": "^2.8.2", "zod": "^4.1.13", @@ -25,8 +26,112 @@ }, }, "packages": { + "@huggingface/jinja": ["@huggingface/jinja@0.5.3", "", {}, "sha512-asqfZ4GQS0hD876Uw4qiUb7Tr/V5Q+JZuo2L+BtdrD4U40QU58nIRq3ZSgAzJgT874VLjhGVacaYfrdpXtEvtA=="], + + "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="], + + "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.24.3", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw=="], + "@node-llama-cpp/linux-arm64": ["@node-llama-cpp/linux-arm64@3.14.5", "", { "os": "linux", "cpu": [ "x64", "arm64", ] }, "sha512-58IcWW7EOqc/66mYWXRsoMCy1MR3pTX/YaC0HYF9Rg5XeAPKhUP7NHrglbqgjO62CkcuFZaSEiX2AtG972GQYQ=="], + + "@node-llama-cpp/linux-armv7l": ["@node-llama-cpp/linux-armv7l@3.14.5", "", { "os": "linux", "cpu": [ "arm", "x64", ] }, "sha512-mJWN0qWsn8y+r/34DC3XlSiXjjKs6wX1BTx0wwJ37fWefS/qfzuBJwQGqpfqe5xpfafib/RgQX44fsvE/9yb1w=="], + + "@node-llama-cpp/linux-x64": ["@node-llama-cpp/linux-x64@3.14.5", "", { "os": "linux", "cpu": "x64" }, "sha512-f6xCqlSqSxMP9Iwm3CpaTzFybbHrzpLkNzA18v21PwhMN8u4DP44euLoxe+BMbOpyzx4iMxU1AUsPsgcHD1Y4w=="], + + "@node-llama-cpp/linux-x64-cuda": ["@node-llama-cpp/linux-x64-cuda@3.14.5", "", { "os": "linux", "cpu": "x64" }, "sha512-yk0EGnAJ+m/paSaItigmxcqC8nNjZlkx9yZgQE51CsTip7tmnqqlj60pW1fWmhrjOJ9XnRlVVTP81fa9B+O1Hg=="], + + "@node-llama-cpp/linux-x64-cuda-ext": ["@node-llama-cpp/linux-x64-cuda-ext@3.14.5", "", { "os": "linux", "cpu": "x64" }, "sha512-AACXmXjqvAppoC6Z20UI7yeSZaFb6uP9x/2lzctVwlm42ef76SN6DNXaX1yzH7DTyzK5zYhoH4ycJUe+zOeGzw=="], + + "@node-llama-cpp/linux-x64-vulkan": ["@node-llama-cpp/linux-x64-vulkan@3.14.5", "", { "os": "linux", "cpu": "x64" }, "sha512-9wZG90CUyyO8EsqfDEh03/fK0ctbQFbKaAFa6Goh+jFLOtqPL+plLqAsW3jDFdLRF5+oAPTKt9/4Y7vHTajQbQ=="], + + "@node-llama-cpp/mac-arm64-metal": ["@node-llama-cpp/mac-arm64-metal@3.14.5", "", { "os": "darwin", "cpu": [ "x64", "arm64", ] }, "sha512-7pclj/nbQyx7gPVbyqkCn+ftlGcnw7YrewxBv1/BWWAMzBrMt2+qkjtUcUhwXH7mT5WN/+eWsszhIMXH3Uf6vQ=="], + + "@node-llama-cpp/mac-x64": ["@node-llama-cpp/mac-x64@3.14.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-iZBmLgPkLKiKS0lYAuqq8i85etGeQ9L+AjEJUhG5N6T/vCF4XSOkUTsEFMEX+iJLV3VxvY/C8R1e/UF7InUjUg=="], + + "@node-llama-cpp/win-arm64": ["@node-llama-cpp/win-arm64@3.14.5", "", { "os": "win32", "cpu": [ "x64", "arm64", ] }, "sha512-WTZJeb2JZo/qPNHf++xA2YeMXB46G7G4WsKEnHVyCpAhhslHAhe/LPgSQfNfk9rYusbsRiy9QMxeGNSOowZMVQ=="], + + "@node-llama-cpp/win-x64": ["@node-llama-cpp/win-x64@3.14.5", "", { "os": "win32", "cpu": "x64" }, "sha512-cEuhb1iLTodM+V8xc1mWKeWRYkX9tlnl0+9jUjwsv2kgnAjEob3WlTYsCXewvEe2ShSyk8AsLsBPZxv7IQaBsw=="], + + "@node-llama-cpp/win-x64-cuda": ["@node-llama-cpp/win-x64-cuda@3.14.5", "", { "os": "win32", "cpu": "x64" }, "sha512-gwBMSzUteLD765Gq/hYQ4UC21vggR7oG+DU4zAg0Mt3i34PqKJC+tBop5jsTN5Hq8RaM9+nTNrVbF/x228TLvg=="], + + "@node-llama-cpp/win-x64-cuda-ext": ["@node-llama-cpp/win-x64-cuda-ext@3.14.5", "", { "os": "win32", "cpu": "x64" }, "sha512-kBHnUmodr+n8N+sKTh1c6aNNEmvXBWM5AtaLWIEfkCb00bVHNFeqYPmLuPNtMX3dIUtD9PHdA4Jsn0RJmNZJfA=="], + + "@node-llama-cpp/win-x64-vulkan": ["@node-llama-cpp/win-x64-vulkan@3.14.5", "", { "os": "win32", "cpu": "x64" }, "sha512-rY+vr5RaGSCWEe22WZMkhUu16o9zpeqTZO/nD5G27Y0bb+xBRDLmXbxYMp2dDQTfpkNWIZ0ia3PGWwl5yhYw7A=="], + + "@octokit/app": ["@octokit/app@16.1.2", "", { "dependencies": { "@octokit/auth-app": "^8.1.2", "@octokit/auth-unauthenticated": "^7.0.3", "@octokit/core": "^7.0.6", "@octokit/oauth-app": "^8.0.3", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/types": "^16.0.0", "@octokit/webhooks": "^14.0.0" } }, "sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ=="], + + "@octokit/auth-app": ["@octokit/auth-app@8.1.2", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-db8VO0PqXxfzI6GdjtgEFHY9tzqUql5xMFXYA12juq8TeTgPAuiiP3zid4h50lwlIP457p5+56PnJOgd2GGBuw=="], + + "@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.3", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg=="], + + "@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="], + + "@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.2", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A=="], + + "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/auth-unauthenticated": ["@octokit/auth-unauthenticated@7.0.3", "", { "dependencies": { "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-8Jb1mtUdmBHL7lGmop9mU9ArMRUTRhg8vp0T1VtZ4yd9vEm3zcLwmjQkhNEduKawOOORie61xhtYIhTDN+ZQ3g=="], + + "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "@octokit/endpoint": ["@octokit/endpoint@11.0.2", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ=="], + + "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "@octokit/oauth-app": ["@octokit/oauth-app@8.0.3", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.2", "@octokit/auth-oauth-user": "^6.0.1", "@octokit/auth-unauthenticated": "^7.0.2", "@octokit/core": "^7.0.5", "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/oauth-methods": "^6.0.1", "@types/aws-lambda": "^8.10.83", "universal-user-agent": "^7.0.0" } }, "sha512-jnAjvTsPepyUaMu9e69hYBuozEPgYqP4Z3UnpmvoIzHDpf8EXDGvTY1l1jK0RsZ194oRd+k6Hm13oRU8EoDFwg=="], + + "@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="], + + "@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.2", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/openapi-webhooks-types": ["@octokit/openapi-webhooks-types@12.1.0", "", {}, "sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA=="], + + "@octokit/plugin-paginate-graphql": ["@octokit/plugin-paginate-graphql@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-crfpnIoFiBtRkvPqOyLOsw12XsveYuY2ieP6uYDosoUegBJpSVxGwut9sxUgFFcll3VTOTqpUf8yGd8x1OmAkQ=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + + "@octokit/plugin-retry": ["@octokit/plugin-retry@8.0.3", "", { "dependencies": { "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=7" } }, "sha512-vKGx1i3MC0za53IzYBSBXcrhmd+daQDzuZfYDd52X5S0M2otf3kVZTVP8bLA3EkU0lTvd1WEC2OlNNa4G+dohA=="], + + "@octokit/plugin-throttling": ["@octokit/plugin-throttling@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": "^7.0.0" } }, "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg=="], + + "@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="], + + "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@octokit/webhooks": ["@octokit/webhooks@14.2.0", "", { "dependencies": { "@octokit/openapi-webhooks-types": "12.1.0", "@octokit/request-error": "^7.0.0", "@octokit/webhooks-methods": "^6.0.0" } }, "sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw=="], + + "@octokit/webhooks-methods": ["@octokit/webhooks-methods@6.0.0", "", {}, "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ=="], + + "@reflink/reflink": ["@reflink/reflink@0.1.19", "", { "optionalDependencies": { "@reflink/reflink-darwin-arm64": "0.1.19", "@reflink/reflink-darwin-x64": "0.1.19", "@reflink/reflink-linux-arm64-gnu": "0.1.19", "@reflink/reflink-linux-arm64-musl": "0.1.19", "@reflink/reflink-linux-x64-gnu": "0.1.19", "@reflink/reflink-linux-x64-musl": "0.1.19", "@reflink/reflink-win32-arm64-msvc": "0.1.19", "@reflink/reflink-win32-x64-msvc": "0.1.19" } }, "sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA=="], + + "@reflink/reflink-darwin-arm64": ["@reflink/reflink-darwin-arm64@0.1.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA=="], + + "@reflink/reflink-darwin-x64": ["@reflink/reflink-darwin-x64@0.1.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA=="], + + "@reflink/reflink-linux-arm64-gnu": ["@reflink/reflink-linux-arm64-gnu@0.1.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg=="], + + "@reflink/reflink-linux-arm64-musl": ["@reflink/reflink-linux-arm64-musl@0.1.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA=="], + + "@reflink/reflink-linux-x64-gnu": ["@reflink/reflink-linux-x64-gnu@0.1.19", "", { "os": "linux", "cpu": "x64" }, "sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw=="], + + "@reflink/reflink-linux-x64-musl": ["@reflink/reflink-linux-x64-musl@0.1.19", "", { "os": "linux", "cpu": "x64" }, "sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ=="], + + "@reflink/reflink-win32-arm64-msvc": ["@reflink/reflink-win32-arm64-msvc@0.1.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ=="], + + "@reflink/reflink-win32-x64-msvc": ["@reflink/reflink-win32-x64-msvc@0.1.19", "", { "os": "win32", "cpu": "x64" }, "sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w=="], + + "@tinyhttp/content-disposition": ["@tinyhttp/content-disposition@2.2.2", "", {}, "sha512-crXw1txzrS36huQOyQGYFvhTeLeG0Si1xu+/l6kXUVYpE0TjFjEZRqTbuadQLfKGZ0jaI+jJoRyqaWwxOSHW2g=="], + + "@types/aws-lambda": ["@types/aws-lambda@8.10.159", "", {}, "sha512-SAP22WSGNN12OQ8PlCzGzRCZ7QDCwI85dQZbmpz7+mAk+L7j+wI7qnvmdKh+o7A5LaOp6QnOZ2NJphAZQTTHQg=="], + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], @@ -37,8 +142,28 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-escapes": ["ansi-escapes@6.2.1", "", {}, "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="], + + "are-we-there-yet": ["are-we-there-yet@3.0.1", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg=="], + + "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], + + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], + "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -47,6 +172,34 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "chmodrp": ["chmodrp@1.0.2", "", {}, "sha512-TdngOlFV1FLTzU0o1w8MB6/BFywhtLC0SzRTGJU7T9lmdjlCWeMRt1iVo0Ki+ldwNk0BqNiKoc8xpLZEQ8mY1w=="], + + "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "cmake-js": ["cmake-js@7.4.0", "", { "dependencies": { "axios": "^1.6.5", "debug": "^4", "fs-extra": "^11.2.0", "memory-stream": "^1.0.0", "node-api-headers": "^1.1.0", "npmlog": "^6.0.2", "rc": "^1.2.7", "semver": "^7.5.4", "tar": "^6.2.0", "url-join": "^4.0.1", "which": "^2.0.2", "yargs": "^17.7.2" }, "bin": { "cmake-js": "bin/cmake-js" } }, "sha512-Lw0JxEHrmk+qNj1n9W9d4IvkDdYTBn7l2BW6XmtLj7WPpIo2shvxUy+YokfjMxAAOELNonQwX3stkPhM5xSC2Q=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + + "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="], + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -61,24 +214,40 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "env-var": ["env-var@7.5.0", "", {}, "sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], @@ -87,66 +256,144 @@ "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "filename-reserved-regex": ["filename-reserved-regex@3.0.0", "", {}, "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw=="], + + "filenamify": ["filenamify@6.0.0", "", { "dependencies": { "filename-reserved-regex": "^3.0.0" } }, "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gauge": ["gauge@4.0.4", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", "console-control-strings": "^1.1.0", "has-unicode": "^2.0.1", "signal-exit": "^3.0.7", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.5" } }, "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "ipull": ["ipull@3.9.3", "", { "dependencies": { "@tinyhttp/content-disposition": "^2.2.0", "async-retry": "^1.3.3", "chalk": "^5.3.0", "ci-info": "^4.0.0", "cli-spinners": "^2.9.2", "commander": "^10.0.0", "eventemitter3": "^5.0.1", "filenamify": "^6.0.0", "fs-extra": "^11.1.1", "is-unicode-supported": "^2.0.0", "lifecycle-utils": "^2.0.1", "lodash.debounce": "^4.0.8", "lowdb": "^7.0.1", "pretty-bytes": "^6.1.0", "pretty-ms": "^8.0.0", "sleep-promise": "^9.1.0", "slice-ansi": "^7.1.0", "stdout-update": "^4.0.1", "strip-ansi": "^7.1.0" }, "optionalDependencies": { "@reflink/reflink": "^0.1.16" }, "bin": { "ipull": "dist/cli/cli.js" } }, "sha512-ZMkxaopfwKHwmEuGDYx7giNBdLxbHbRCWcQVA1D2eqE4crUguupfxej6s7UqbidYEwT69dkyumYkY8DPHIxF9g=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + + "lifecycle-utils": ["lifecycle-utils@3.0.1", "", {}, "sha512-Qt/Jl5dsNIsyCAZsHB6x3mbwHFn0HJbdmvF49sVX/bHgX2cW7+G+U+I67Zw+TPM1Sr21Gb2nfJMd2g6iUcI1EQ=="], + + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + + "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + + "lowdb": ["lowdb@7.0.1", "", { "dependencies": { "steno": "^4.0.2" } }, "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memory-stream": ["memory-stream@1.0.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-Wm13VcsPIMdG96dzILfij09PvuS3APtcKNh7M28FsCA/w6+1mjR7hhPmfFNoilX9xU7wTdhsH5lJAm6XNzdtww=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="], + + "node-api-headers": ["node-api-headers@1.7.0", "", {}, "sha512-uJMGdkhVwu9+I3UsVvI3KW6ICAy/yDfsu5Br9rSnTtY3WpoaComXvKloiV5wtx0Md2rn0B9n29Ys2WMNwWxj9A=="], + + "node-llama-cpp": ["node-llama-cpp@3.14.5", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "async-retry": "^1.3.3", "bytes": "^3.1.2", "chalk": "^5.4.1", "chmodrp": "^1.0.2", "cmake-js": "^7.4.0", "cross-spawn": "^7.0.6", "env-var": "^7.5.0", "filenamify": "^6.0.0", "fs-extra": "^11.3.0", "ignore": "^7.0.4", "ipull": "^3.9.2", "is-unicode-supported": "^2.1.0", "lifecycle-utils": "^3.0.1", "log-symbols": "^7.0.0", "nanoid": "^5.1.5", "node-addon-api": "^8.3.1", "octokit": "^5.0.3", "ora": "^8.2.0", "pretty-ms": "^9.2.0", "proper-lockfile": "^4.1.2", "semver": "^7.7.1", "simple-git": "^3.27.0", "slice-ansi": "^7.1.0", "stdout-update": "^4.0.1", "strip-ansi": "^7.1.0", "validate-npm-package-name": "^6.0.0", "which": "^5.0.0", "yargs": "^17.7.2" }, "optionalDependencies": { "@node-llama-cpp/linux-arm64": "3.14.5", "@node-llama-cpp/linux-armv7l": "3.14.5", "@node-llama-cpp/linux-x64": "3.14.5", "@node-llama-cpp/linux-x64-cuda": "3.14.5", "@node-llama-cpp/linux-x64-cuda-ext": "3.14.5", "@node-llama-cpp/linux-x64-vulkan": "3.14.5", "@node-llama-cpp/mac-arm64-metal": "3.14.5", "@node-llama-cpp/mac-x64": "3.14.5", "@node-llama-cpp/win-arm64": "3.14.5", "@node-llama-cpp/win-x64": "3.14.5", "@node-llama-cpp/win-x64-cuda": "3.14.5", "@node-llama-cpp/win-x64-cuda-ext": "3.14.5", "@node-llama-cpp/win-x64-vulkan": "3.14.5" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"], "bin": { "node-llama-cpp": "dist/cli/cli.js", "nlc": "dist/cli/cli.js" } }, "sha512-Db+RFqFMJOOVWprUINq77LVe44FaiJ6JvNiq14r2+DZRgkgyxckSZa6DcZ5Xe5MC+hGA5aqOdnNxsrudUcs74Q=="], + + "npmlog": ["npmlog@6.0.2", "", { "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", "gauge": "^4.0.3", "set-blocking": "^2.0.0" } }, "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "octokit": ["octokit@5.0.5", "", { "dependencies": { "@octokit/app": "^16.1.2", "@octokit/core": "^7.0.6", "@octokit/oauth-app": "^8.0.3", "@octokit/plugin-paginate-graphql": "^6.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0", "@octokit/plugin-retry": "^8.0.3", "@octokit/plugin-throttling": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "@octokit/webhooks": "^14.0.0" } }, "sha512-4+/OFSqOjoyULo7eN7EA97DE0Xydj/PW5aIckxqQIoFjFwqXKuFCvXUJObyJfBF9Khu4RL/jlDRI9FPaMGfPnw=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -155,24 +402,48 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -187,6 +458,14 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="], + + "sleep-promise": ["sleep-promise@9.1.0", "", {}, "sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA=="], + + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + "sqlite-vec": ["sqlite-vec@0.1.7-alpha.2", "", { "optionalDependencies": { "sqlite-vec-darwin-arm64": "0.1.7-alpha.2", "sqlite-vec-darwin-x64": "0.1.7-alpha.2", "sqlite-vec-linux-arm64": "0.1.7-alpha.2", "sqlite-vec-linux-x64": "0.1.7-alpha.2", "sqlite-vec-windows-x64": "0.1.7-alpha.2" } }, "sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ=="], "sqlite-vec-darwin-arm64": ["sqlite-vec-darwin-arm64@0.1.7-alpha.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-raIATOqFYkeCHhb/t3r7W7Cf2lVYdf4J3ogJ6GFc8PQEgHCPEsi+bYnm2JT84MzLfTlSTIdxr4/NKv+zF7oLPw=="], @@ -201,6 +480,24 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + + "stdout-update": ["stdout-update@4.0.1", "", { "dependencies": { "ansi-escapes": "^6.2.0", "ansi-styles": "^6.2.1", "string-width": "^7.1.0", "strip-ansi": "^7.1.0" } }, "sha512-wiS21Jthlvl1to+oorePvcyrIkiG/6M3D3VTmDUlJm7Cy6SbFhKkAvX+YBuHLxck/tO3mrdpC/cNesigQc3+UQ=="], + + "steno": ["steno@4.0.2", "", {}, "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + + "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -209,18 +506,126 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "universal-github-app-jwt": ["universal-github-app-jwt@2.2.2", "", {}, "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw=="], + + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate-npm-package-name": ["validate-npm-package-name@6.0.2", "", {}, "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + + "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="], + + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cmake-js/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "gauge/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "gauge/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "ipull/lifecycle-utils": ["lifecycle-utils@2.1.0", "", {}, "sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA=="], + + "ipull/pretty-ms": ["pretty-ms@8.0.0", "", { "dependencies": { "parse-ms": "^3.0.0" } }, "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q=="], + + "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + + "proper-lockfile/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "wide-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "cmake-js/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "gauge/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "gauge/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "gauge/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ipull/pretty-ms/parse-ms": ["parse-ms@3.0.0", "", {}, "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw=="], + + "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + + "wide-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wide-align/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "wide-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "wide-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index f3c3d85..292c7e0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.24.3", + "node-llama-cpp": "^3.14.5", "sqlite-vec": "^0.1.7-alpha.2", "yaml": "^2.8.2", "zod": "^4.1.13" diff --git a/src/llm.test.ts b/src/llm.test.ts index f46083d..8063065 100644 --- a/src/llm.test.ts +++ b/src/llm.test.ts @@ -1,902 +1,344 @@ /** - * llm.test.ts - Comprehensive unit tests for the LLM abstraction layer + * llm.test.ts - Unit tests for the LLM abstraction layer (node-llama-cpp) * - * Run with: bun test llm.test.ts + * Run with: bun test src/llm.test.ts * - * Tests use a mock HTTP server to simulate Ollama responses. + * These tests require the actual models to be downloaded. Run the embed or + * rerank functions first to trigger model downloads. */ -import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test"; +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import { - Ollama, - getDefaultOllama, - setDefaultOllama, - formatQueryForEmbedding, - formatDocForEmbedding, - type EmbeddingResult, - type GenerateResult, - type RerankDocumentResult, - type TokenLogProb, + LlamaCpp, + getDefaultLlamaCpp, + setDefaultLlamaCpp, + type RerankDocument, } from "./llm.js"; // ============================================================================= -// Mock Server Setup +// Singleton Tests (no model loading required) // ============================================================================= -type MockHandler = (body: unknown) => { - status: number; - body: unknown; -}; - -const mockHandlers: Map = new Map(); -let mockServerUrl: string; -let mockCallLog: Array<{ path: string; body: unknown }> = []; - -// Track original fetch -const originalFetch = globalThis.fetch; - -function installMockFetch(): void { - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - - // Only intercept calls to our mock server URL - if (!url.startsWith(mockServerUrl)) { - throw new Error(`TEST ERROR: Unexpected fetch to: ${url}`); - } - - const path = url.replace(mockServerUrl, ""); - const body = init?.body ? JSON.parse(init.body as string) : {}; - - // Log the call - mockCallLog.push({ path, body }); - - const handler = mockHandlers.get(path); - if (!handler) { - return new Response(JSON.stringify({ error: "Not found" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); - } - - const result = handler(body); - return new Response(JSON.stringify(result.body), { - status: result.status, - headers: { "Content-Type": "application/json" }, - }); - }; -} - -function restoreFetch(): void { - globalThis.fetch = originalFetch; -} - -// Setup before all tests -beforeAll(() => { - mockServerUrl = "http://mock-ollama:11434"; - installMockFetch(); -}); - -// Restore after all tests -afterAll(() => { - restoreFetch(); -}); - -// Clear call log and handlers before each test -beforeEach(() => { - mockCallLog = []; - mockHandlers.clear(); -}); - -// ============================================================================= -// Helper Functions -// ============================================================================= - -function createOllama(): Ollama { - return new Ollama({ baseUrl: mockServerUrl }); -} - -function setEmbedHandler(embeddings: number[][]): void { - mockHandlers.set("/api/embed", () => ({ - status: 200, - body: { embeddings }, - })); -} - -function setGenerateHandler( - response: string, - logprobs?: { tokens: string[]; token_logprobs: number[] } -): void { - mockHandlers.set("/api/generate", () => ({ - status: 200, - body: { - response, - done: true, - ...(logprobs && { logprobs }), - }, - })); -} - -function setModelShowHandler(exists: boolean, size?: number): void { - mockHandlers.set("/api/show", () => { - if (exists) { - return { - status: 200, - body: { size: size ?? 1000000, modified_at: "2024-01-01T00:00:00Z" }, - }; - } - return { status: 404, body: { error: "model not found" } }; - }); -} - -function setPullHandler(success: boolean): void { - mockHandlers.set("/api/pull", () => ({ - status: success ? 200 : 500, - body: success ? { status: "success" } : { error: "failed" }, - })); -} - -// ============================================================================= -// Formatting Tests -// ============================================================================= - -describe("Formatting Functions", () => { - test("formatQueryForEmbedding adds search task prefix", () => { - const result = formatQueryForEmbedding("how to deploy"); - expect(result).toBe("task: search result | query: how to deploy"); +describe("Default LlamaCpp Singleton", () => { + afterAll(() => { + setDefaultLlamaCpp(null); }); - test("formatQueryForEmbedding handles empty query", () => { - const result = formatQueryForEmbedding(""); - expect(result).toBe("task: search result | query: "); + test("getDefaultLlamaCpp creates instance on first call", () => { + setDefaultLlamaCpp(null); + const llm = getDefaultLlamaCpp(); + expect(llm).toBeInstanceOf(LlamaCpp); }); - test("formatDocForEmbedding adds title and text prefix", () => { - const result = formatDocForEmbedding("Document content", "My Title"); - expect(result).toBe("title: My Title | text: Document content"); + test("getDefaultLlamaCpp returns same instance on subsequent calls", () => { + setDefaultLlamaCpp(null); + const llm1 = getDefaultLlamaCpp(); + const llm2 = getDefaultLlamaCpp(); + expect(llm1).toBe(llm2); }); - test("formatDocForEmbedding handles missing title", () => { - const result = formatDocForEmbedding("Document content"); - expect(result).toBe("title: none | text: Document content"); - }); + test("setDefaultLlamaCpp allows replacing the singleton", () => { + const custom = new LlamaCpp({ embedModel: "custom-model" }); + setDefaultLlamaCpp(custom); - test("formatDocForEmbedding handles empty content", () => { - const result = formatDocForEmbedding("", "Title"); - expect(result).toBe("title: Title | text: "); - }); -}); - -// ============================================================================= -// Ollama Constructor Tests -// ============================================================================= - -describe("Ollama Constructor", () => { - test("uses default URL when not specified", () => { - const ollama = new Ollama(); - expect(ollama.getBaseUrl()).toBe("http://localhost:11434"); - }); - - test("uses custom URL when specified", () => { - const ollama = new Ollama({ baseUrl: "http://custom:9999" }); - expect(ollama.getBaseUrl()).toBe("http://custom:9999"); - }); - - test("respects OLLAMA_URL environment variable", () => { - const originalEnv = process.env.OLLAMA_URL; - process.env.OLLAMA_URL = "http://env-url:8888"; - - const ollama = new Ollama(); - expect(ollama.getBaseUrl()).toBe("http://env-url:8888"); - - // Restore - if (originalEnv) { - process.env.OLLAMA_URL = originalEnv; - } else { - delete process.env.OLLAMA_URL; - } - }); - - test("explicit baseUrl overrides environment variable", () => { - const originalEnv = process.env.OLLAMA_URL; - process.env.OLLAMA_URL = "http://env-url:8888"; - - const ollama = new Ollama({ baseUrl: "http://explicit:7777" }); - expect(ollama.getBaseUrl()).toBe("http://explicit:7777"); - - // Restore - if (originalEnv) { - process.env.OLLAMA_URL = originalEnv; - } else { - delete process.env.OLLAMA_URL; - } - }); -}); - -// ============================================================================= -// Embed Tests -// ============================================================================= - -describe("Ollama.embed", () => { - test("returns embedding for query", async () => { - const ollama = createOllama(); - const embedding = [0.1, 0.2, 0.3, 0.4, 0.5]; - setEmbedHandler([embedding]); - - const result = await ollama.embed("test query", { model: "test-model", isQuery: true }); - - expect(result).not.toBeNull(); - expect(result!.embedding).toEqual(embedding); - expect(result!.model).toBe("test-model"); - - // Verify the request was formatted correctly - expect(mockCallLog).toHaveLength(1); - expect(mockCallLog[0].path).toBe("/api/embed"); - expect((mockCallLog[0].body as { input: string }).input).toContain("task: search result"); - }); - - test("returns embedding for document", async () => { - const ollama = createOllama(); - const embedding = [0.5, 0.4, 0.3, 0.2, 0.1]; - setEmbedHandler([embedding]); - - const result = await ollama.embed("doc content", { - model: "test-model", - isQuery: false, - title: "Doc Title", - }); - - expect(result).not.toBeNull(); - expect(result!.embedding).toEqual(embedding); - - // Verify document formatting - expect((mockCallLog[0].body as { input: string }).input).toContain("title: Doc Title"); - expect((mockCallLog[0].body as { input: string }).input).toContain("text: doc content"); - }); - - test("returns null on API error", async () => { - const ollama = createOllama(); - mockHandlers.set("/api/embed", () => ({ status: 500, body: { error: "Server error" } })); - - const result = await ollama.embed("test", { model: "test-model" }); - expect(result).toBeNull(); - }); - - test("returns null on empty embeddings", async () => { - const ollama = createOllama(); - setEmbedHandler([]); - - const result = await ollama.embed("test", { model: "test-model" }); - expect(result).toBeNull(); - }); - - test("returns null on network error", async () => { - const ollama = new Ollama({ baseUrl: "http://nonexistent:99999" }); - - // This will throw because our mock doesn't handle this URL - const result = await ollama.embed("test", { model: "test-model" }).catch(() => null); - expect(result).toBeNull(); - }); - - test("handles high-dimensional embeddings", async () => { - const ollama = createOllama(); - const embedding = Array(768).fill(0).map((_, i) => i / 768); - setEmbedHandler([embedding]); - - const result = await ollama.embed("test", { model: "test-model" }); - expect(result!.embedding).toHaveLength(768); - expect(result!.embedding[0]).toBeCloseTo(0, 5); - expect(result!.embedding[767]).toBeCloseTo(767 / 768, 5); - }); -}); - -// ============================================================================= -// Generate Tests -// ============================================================================= - -describe("Ollama.generate", () => { - test("returns generated text", async () => { - const ollama = createOllama(); - setGenerateHandler("Generated response text"); - - const result = await ollama.generate("prompt", { model: "test-model" }); - - expect(result).not.toBeNull(); - expect(result!.text).toBe("Generated response text"); - expect(result!.model).toBe("test-model"); - expect(result!.done).toBe(true); - }); - - test("includes logprobs when requested", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { - tokens: ["yes"], - token_logprobs: [-0.1], - }); - - const result = await ollama.generate("prompt", { model: "test-model", logprobs: true }); - - expect(result!.logprobs).toBeDefined(); - expect(result!.logprobs).toHaveLength(1); - expect(result!.logprobs![0].token).toBe("yes"); - expect(result!.logprobs![0].logprob).toBe(-0.1); - }); - - test("handles multiple logprob tokens", async () => { - const ollama = createOllama(); - setGenerateHandler("hello world", { - tokens: ["hello", " world"], - token_logprobs: [-0.5, -0.3], - }); - - const result = await ollama.generate("prompt", { model: "test-model", logprobs: true }); - - expect(result!.logprobs).toHaveLength(2); - expect(result!.logprobs![0]).toEqual({ token: "hello", logprob: -0.5 }); - expect(result!.logprobs![1]).toEqual({ token: " world", logprob: -0.3 }); - }); - - test("sends maxTokens option", async () => { - const ollama = createOllama(); - setGenerateHandler("response"); - - await ollama.generate("prompt", { model: "test-model", maxTokens: 50 }); - - const body = mockCallLog[0].body as { options: { num_predict: number } }; - expect(body.options.num_predict).toBe(50); - }); - - test("sends temperature option", async () => { - const ollama = createOllama(); - setGenerateHandler("response"); - - await ollama.generate("prompt", { model: "test-model", temperature: 0.7 }); - - const body = mockCallLog[0].body as { options: { temperature: number } }; - expect(body.options.temperature).toBe(0.7); - }); - - test("sends raw option", async () => { - const ollama = createOllama(); - setGenerateHandler("response"); - - await ollama.generate("prompt", { model: "test-model", raw: true }); - - const body = mockCallLog[0].body as { raw: boolean }; - expect(body.raw).toBe(true); - }); - - test("returns null on API error", async () => { - const ollama = createOllama(); - mockHandlers.set("/api/generate", () => ({ status: 500, body: { error: "Error" } })); - - const result = await ollama.generate("prompt", { model: "test-model" }); - expect(result).toBeNull(); - }); - - test("handles empty response", async () => { - const ollama = createOllama(); - setGenerateHandler(""); - - const result = await ollama.generate("prompt", { model: "test-model" }); - expect(result!.text).toBe(""); - }); -}); - -// ============================================================================= -// Model Management Tests -// ============================================================================= - -describe("Ollama.modelExists", () => { - test("returns true for existing model", async () => { - const ollama = createOllama(); - setModelShowHandler(true, 5000000); - - const result = await ollama.modelExists("test-model"); - - expect(result.exists).toBe(true); - expect(result.name).toBe("test-model"); - expect(result.size).toBe(5000000); - expect(result.modifiedAt).toBeDefined(); - }); - - test("returns false for non-existing model", async () => { - const ollama = createOllama(); - setModelShowHandler(false); - - const result = await ollama.modelExists("nonexistent-model"); - - expect(result.exists).toBe(false); - expect(result.name).toBe("nonexistent-model"); - }); - - test("sends correct model name in request", async () => { - const ollama = createOllama(); - setModelShowHandler(true); - - await ollama.modelExists("specific-model:v1"); - - expect(mockCallLog[0].path).toBe("/api/show"); - expect((mockCallLog[0].body as { name: string }).name).toBe("specific-model:v1"); - }); -}); - -describe("Ollama.pullModel", () => { - test("returns true on successful pull", async () => { - const ollama = createOllama(); - setPullHandler(true); - - const result = await ollama.pullModel("new-model"); - - expect(result).toBe(true); - expect(mockCallLog[0].path).toBe("/api/pull"); - expect((mockCallLog[0].body as { name: string }).name).toBe("new-model"); - }); - - test("returns false on failed pull", async () => { - const ollama = createOllama(); - setPullHandler(false); - - const result = await ollama.pullModel("bad-model"); - expect(result).toBe(false); - }); - - test("calls progress callback", async () => { - const ollama = createOllama(); - setPullHandler(true); - - let progressCalled = false; - await ollama.pullModel("model", (progress) => { - progressCalled = true; - expect(progress).toBe(100); - }); - - expect(progressCalled).toBe(true); - }); -}); - -// ============================================================================= -// Query Expansion Tests -// ============================================================================= - -describe("Ollama.expandQuery", () => { - test("returns original query plus expansions", async () => { - const ollama = createOllama(); - setGenerateHandler("variation one\nvariation two"); - - const result = await ollama.expandQuery("original query", "test-model"); - - expect(result).toContain("original query"); - expect(result[0]).toBe("original query"); - expect(result.length).toBeGreaterThanOrEqual(1); - }); - - test("returns only original query on API failure", async () => { - const ollama = createOllama(); - mockHandlers.set("/api/generate", () => ({ status: 500, body: { error: "Error" } })); - - const result = await ollama.expandQuery("query", "test-model"); - - expect(result).toEqual(["query"]); - }); - - test("filters out thinking tags from response", async () => { - const ollama = createOllama(); - setGenerateHandler("some thinking\nvariation one\nvariation two"); - - const result = await ollama.expandQuery("query", "test-model"); - - expect(result).not.toContain(""); - expect(result.some((r) => r.includes("think"))).toBe(false); - }); - - test("filters out very long variations", async () => { - const ollama = createOllama(); - const longLine = "a".repeat(150); - setGenerateHandler(`short variation\n${longLine}\nanother short`); - - const result = await ollama.expandQuery("query", "test-model"); - - // Long variations (>100 chars) should be filtered - expect(result.every((r) => r.length < 100)).toBe(true); - }); - - test("respects numVariations parameter", async () => { - const ollama = createOllama(); - setGenerateHandler("one\ntwo\nthree\nfour\nfive"); - - const result = await ollama.expandQuery("query", "test-model", 3); - - // Original + up to 3 variations - expect(result.length).toBeLessThanOrEqual(4); - }); - - test("sends correct prompt format", async () => { - const ollama = createOllama(); - setGenerateHandler("variation"); - - await ollama.expandQuery("test query", "test-model", 2); - - const body = mockCallLog[0].body as { prompt: string }; - expect(body.prompt).toContain('Query: "test query"'); - expect(body.prompt).toContain("generate 2 alternative queries"); - }); -}); - -// ============================================================================= -// Reranking Tests -// ============================================================================= - -describe("Ollama.rerankerLogprobsCheck", () => { - test("returns relevance judgments for documents", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] }); - - const docs = [ - { file: "doc1.md", text: "Relevant content" }, - { file: "doc2.md", text: "Other content" }, - ]; - - const results = await ollama.rerankerLogprobsCheck("query", docs, { model: "test-model" }); - - expect(results).toHaveLength(2); - expect(results[0].file).toBe("doc1.md"); - expect(results[0].relevant).toBe(true); - expect(results[0].rawToken).toBe("yes"); - }); - - test("parses yes with high confidence correctly", async () => { - const ollama = createOllama(); - // -0.1 logprob = ~0.905 confidence - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] }); - - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: "content" }], - { model: "test-model" } - ); - - expect(results[0].relevant).toBe(true); - expect(results[0].confidence).toBeCloseTo(Math.exp(-0.1), 3); - expect(results[0].score).toBeGreaterThan(0.9); - expect(results[0].logprob).toBe(-0.1); - }); - - test("parses yes with low confidence correctly", async () => { - const ollama = createOllama(); - // -2.0 logprob = ~0.135 confidence - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-2.0] }); - - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: "content" }], - { model: "test-model" } - ); - - expect(results[0].relevant).toBe(true); - expect(results[0].confidence).toBeCloseTo(Math.exp(-2.0), 3); - expect(results[0].score).toBeLessThan(0.6); - }); - - test("parses no with high confidence correctly", async () => { - const ollama = createOllama(); - // -0.05 logprob = ~0.95 confidence - setGenerateHandler("no", { tokens: ["no"], token_logprobs: [-0.05] }); - - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: "content" }], - { model: "test-model" } - ); - - expect(results[0].relevant).toBe(false); - expect(results[0].confidence).toBeCloseTo(Math.exp(-0.05), 3); - expect(results[0].score).toBeLessThan(0.1); // Low score for confident "no" - }); - - test("parses no with low confidence correctly", async () => { - const ollama = createOllama(); - // -1.5 logprob = ~0.22 confidence - setGenerateHandler("no", { tokens: ["no"], token_logprobs: [-1.5] }); - - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: "content" }], - { model: "test-model" } - ); - - expect(results[0].relevant).toBe(false); - expect(results[0].score).toBeGreaterThan(0.3); // Higher score for uncertain "no" - }); - - test("handles unknown token", async () => { - const ollama = createOllama(); - setGenerateHandler("maybe", { tokens: ["maybe"], token_logprobs: [-0.5] }); - - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: "content" }], - { model: "test-model" } - ); - - expect(results[0].relevant).toBe(false); - expect(results[0].score).toBe(0.3); // Neutral score - }); - - test("handles API failure gracefully", async () => { - const ollama = createOllama(); - mockHandlers.set("/api/generate", () => ({ status: 500, body: { error: "Error" } })); - - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: "content" }], - { model: "test-model" } - ); - - expect(results[0].relevant).toBe(false); - expect(results[0].score).toBe(0); - expect(results[0].confidence).toBe(0); - }); - - test("respects batchSize option", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] }); - - const docs = Array(10).fill(null).map((_, i) => ({ - file: `doc${i}.md`, - text: `content ${i}`, - })); - - await ollama.rerankerLogprobsCheck("query", docs, { model: "test-model", batchSize: 3 }); - - // Should process in batches: 3 + 3 + 3 + 1 = 10 calls - expect(mockCallLog).toHaveLength(10); - }); - - test("sends correct prompt format", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] }); - - await ollama.rerankerLogprobsCheck( - "search query", - [{ file: "test.md", text: "document content", title: "Test Doc" }], - { model: "test-model" } - ); - - const body = mockCallLog[0].body as { prompt: string; raw: boolean; logprobs: boolean }; - expect(body.prompt).toContain(": search query"); - expect(body.prompt).toContain(": Test Doc"); - expect(body.prompt).toContain("document content"); - expect(body.raw).toBe(true); - expect(body.logprobs).toBe(true); - }); - - test("uses filename as title when title not provided", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] }); - - await ollama.rerankerLogprobsCheck( - "query", - [{ file: "path/to/document.md", text: "content" }], - { model: "test-model" } - ); - - const body = mockCallLog[0].body as { prompt: string }; - expect(body.prompt).toContain(": document"); - }); - - test("truncates long documents", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] }); - - const longText = "x".repeat(10000); - await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: longText }], - { model: "test-model" } - ); - - const body = mockCallLog[0].body as { prompt: string }; - // Should be truncated to ~4000 chars + "..." - expect(body.prompt.length).toBeLessThan(10000); - expect(body.prompt).toContain("..."); - }); -}); - -describe("Ollama.rerank", () => { - test("returns sorted results by score", async () => { - const ollama = createOllama(); - - // First call returns "no", second returns "yes" - let callCount = 0; - mockHandlers.set("/api/generate", () => { - callCount++; - if (callCount === 1) { - return { status: 200, body: { response: "no", done: true, logprobs: { tokens: ["no"], token_logprobs: [-0.1] } } }; - } - return { status: 200, body: { response: "yes", done: true, logprobs: { tokens: ["yes"], token_logprobs: [-0.1] } } }; - }); - - const docs = [ - { file: "low.md", text: "irrelevant" }, - { file: "high.md", text: "relevant" }, - ]; - - const result = await ollama.rerank("query", docs, { model: "test-model" }); - - expect(result.results).toHaveLength(2); - expect(result.results[0].file).toBe("high.md"); // Higher score first - expect(result.results[0].score).toBeGreaterThan(result.results[1].score); - }); - - test("includes model in result", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] }); - - const result = await ollama.rerank("query", [{ file: "doc.md", text: "content" }], { - model: "custom-reranker", - }); - - expect(result.model).toBe("custom-reranker"); - }); -}); - -// ============================================================================= -// Default Ollama Singleton Tests -// ============================================================================= - -describe("Default Ollama Singleton", () => { - afterEach(() => { - setDefaultOllama(null); - }); - - test("getDefaultOllama creates instance on first call", () => { - const ollama = getDefaultOllama(); - expect(ollama).toBeInstanceOf(Ollama); - }); - - test("getDefaultOllama returns same instance on subsequent calls", () => { - const ollama1 = getDefaultOllama(); - const ollama2 = getDefaultOllama(); - expect(ollama1).toBe(ollama2); - }); - - test("setDefaultOllama allows replacing the singleton", () => { - const custom = new Ollama({ baseUrl: "http://custom:1234" }); - setDefaultOllama(custom); - - const result = getDefaultOllama(); + const result = getDefaultLlamaCpp(); expect(result).toBe(custom); - expect(result.getBaseUrl()).toBe("http://custom:1234"); }); - test("setDefaultOllama with null resets singleton", () => { - const original = getDefaultOllama(); - setDefaultOllama(null); - const newInstance = getDefaultOllama(); + test("setDefaultLlamaCpp with null resets singleton", () => { + const original = getDefaultLlamaCpp(); + setDefaultLlamaCpp(null); + const newInstance = getDefaultLlamaCpp(); expect(newInstance).not.toBe(original); }); }); // ============================================================================= -// Logprob Math Tests +// Model Existence Tests // ============================================================================= -describe("Logprob Mathematics", () => { - test("logprob 0 = 100% confidence", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [0] }); +describe("LlamaCpp.modelExists", () => { + test("returns exists:true for HuggingFace model URIs", async () => { + const llm = new LlamaCpp(); + const result = await llm.modelExists("hf:org/repo/model.gguf"); - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: "content" }], - { model: "test-model" } - ); - - expect(results[0].confidence).toBe(1.0); - expect(results[0].score).toBe(1.0); // 0.5 + 0.5 * 1.0 + expect(result.exists).toBe(true); + expect(result.name).toBe("hf:org/repo/model.gguf"); }); - test("logprob -ln(2) ≈ 50% confidence", async () => { - const ollama = createOllama(); - const logprob = -Math.log(2); // ≈ -0.693 - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [logprob] }); + test("returns exists:false for non-existent local paths", async () => { + const llm = new LlamaCpp(); + const result = await llm.modelExists("/nonexistent/path/model.gguf"); - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: "content" }], - { model: "test-model" } - ); - - expect(results[0].confidence).toBeCloseTo(0.5, 3); - expect(results[0].score).toBeCloseTo(0.75, 3); // 0.5 + 0.5 * 0.5 - }); - - test("very negative logprob = very low confidence", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-10] }); - - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: "content" }], - { model: "test-model" } - ); - - expect(results[0].confidence).toBeLessThan(0.0001); - expect(results[0].score).toBeCloseTo(0.5, 2); // Nearly just the base 0.5 + expect(result.exists).toBe(false); + expect(result.name).toBe("/nonexistent/path/model.gguf"); }); }); // ============================================================================= -// Edge Cases +// Integration Tests (require actual models) // ============================================================================= -describe("Edge Cases", () => { - test("handles empty document list", async () => { - const ollama = createOllama(); +describe("LlamaCpp Integration", () => { + let llm: LlamaCpp; - const results = await ollama.rerankerLogprobsCheck("query", [], { model: "test-model" }); - expect(results).toHaveLength(0); + beforeAll(() => { + llm = new LlamaCpp(); }); - test("handles very short document text", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] }); - - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: "x" }], - { model: "test-model" } - ); - - expect(results).toHaveLength(1); + afterAll(async () => { + await llm.dispose(); }); - test("handles unicode in queries and documents", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] }); + describe("embed", () => { + test("returns embedding with correct dimensions", async () => { + const result = await llm.embed("Hello world"); - const results = await ollama.rerankerLogprobsCheck( - "日本語クエリ", - [{ file: "doc.md", text: "日本語コンテンツ 🎉" }], - { model: "test-model" } - ); + expect(result).not.toBeNull(); + expect(result!.embedding).toBeInstanceOf(Array); + expect(result!.embedding.length).toBeGreaterThan(0); + // embeddinggemma outputs 768 dimensions + expect(result!.embedding.length).toBe(768); + }); - expect(results).toHaveLength(1); + test("returns consistent embeddings for same input", async () => { + const result1 = await llm.embed("test text"); + const result2 = await llm.embed("test text"); - const body = mockCallLog[0].body as { prompt: string }; - expect(body.prompt).toContain("日本語クエリ"); - expect(body.prompt).toContain("日本語コンテンツ"); + expect(result1).not.toBeNull(); + expect(result2).not.toBeNull(); + + // Embeddings should be identical for the same input + for (let i = 0; i < result1!.embedding.length; i++) { + expect(result1!.embedding[i]).toBeCloseTo(result2!.embedding[i], 5); + } + }); + + test("returns different embeddings for different inputs", async () => { + const result1 = await llm.embed("cats are great"); + const result2 = await llm.embed("database optimization"); + + expect(result1).not.toBeNull(); + expect(result2).not.toBeNull(); + + // Calculate cosine similarity - should be less than 1.0 (not identical) + let dotProduct = 0; + let norm1 = 0; + let norm2 = 0; + for (let i = 0; i < result1!.embedding.length; i++) { + dotProduct += result1!.embedding[i] * result2!.embedding[i]; + norm1 += result1!.embedding[i] ** 2; + norm2 += result2!.embedding[i] ** 2; + } + const similarity = dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); + + expect(similarity).toBeLessThan(0.95); // Should be meaningfully different + }); }); - test("handles special characters in file paths", async () => { - const ollama = createOllama(); - setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] }); + describe("embedBatch", () => { + test("returns embeddings for multiple texts", async () => { + const texts = ["Hello world", "Test text", "Another document"]; + const results = await llm.embedBatch(texts); - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "path/to/file with spaces.md", text: "content" }], - { model: "test-model" } - ); + expect(results).toHaveLength(3); + for (const result of results) { + expect(result).not.toBeNull(); + expect(result!.embedding.length).toBe(768); + } + }); - expect(results[0].file).toBe("path/to/file with spaces.md"); + test("returns same results as individual embed calls", async () => { + const texts = ["cats are great", "dogs are awesome"]; + + // Get batch embeddings + const batchResults = await llm.embedBatch(texts); + + // Get individual embeddings + const individualResults = await Promise.all(texts.map(t => llm.embed(t))); + + // Compare - should be identical + for (let i = 0; i < texts.length; i++) { + expect(batchResults[i]).not.toBeNull(); + expect(individualResults[i]).not.toBeNull(); + for (let j = 0; j < batchResults[i]!.embedding.length; j++) { + expect(batchResults[i]!.embedding[j]).toBeCloseTo(individualResults[i]!.embedding[j], 5); + } + } + }); + + test("handles empty array", async () => { + const results = await llm.embedBatch([]); + expect(results).toHaveLength(0); + }); + + test("batch is faster than sequential", async () => { + const texts = Array(10).fill(null).map((_, i) => `Document number ${i} with content`); + + // Time batch + const batchStart = Date.now(); + await llm.embedBatch(texts); + const batchTime = Date.now() - batchStart; + + // Time sequential + const seqStart = Date.now(); + for (const text of texts) { + await llm.embed(text); + } + const seqTime = Date.now() - seqStart; + + console.log(`Batch: ${batchTime}ms, Sequential: ${seqTime}ms`); + // Batch should be faster (or at least not much slower) + // Allow some variance since first call may load the model + expect(batchTime).toBeLessThan(seqTime * 1.5); + }); }); - test("handles missing logprobs in response", async () => { - const ollama = createOllama(); - // Response without logprobs - mockHandlers.set("/api/generate", () => ({ - status: 200, - body: { response: "yes", done: true }, - })); + describe("rerank", () => { + test("scores capital of France question correctly", async () => { + const query = "What is the capital of France?"; + const documents: RerankDocument[] = [ + { file: "butterflies.txt", text: "Butterflies indeed fly through the garden." }, + { file: "france.txt", text: "The capital of France is Paris." }, + { file: "canada.txt", text: "The capital of Canada is Ottawa." }, + ]; - const results = await ollama.rerankerLogprobsCheck( - "query", - [{ file: "doc.md", text: "content" }], - { model: "test-model" } - ); + const result = await llm.rerank(query, documents); - // Should still work, with logprob defaulting to 0 - expect(results[0].logprob).toBe(0); + expect(result.results).toHaveLength(3); + + // The France document should score highest + expect(result.results[0].file).toBe("france.txt"); + expect(result.results[0].score).toBeGreaterThan(0.7); + + // Canada should be somewhat relevant (also about capitals) + expect(result.results[1].file).toBe("canada.txt"); + + // Butterflies should score lowest + expect(result.results[2].file).toBe("butterflies.txt"); + expect(result.results[2].score).toBeLessThan(0.6); + }); + + test("scores authentication query correctly", async () => { + const query = "How do I configure authentication?"; + const documents: RerankDocument[] = [ + { file: "weather.md", text: "The weather today is sunny with mild temperatures." }, + { file: "auth.md", text: "Authentication can be configured by setting the AUTH_SECRET environment variable." }, + { file: "pizza.md", text: "Our restaurant serves the best pizza in town." }, + { file: "jwt.md", text: "JWT authentication requires a secret key and expiration time." }, + ]; + + const result = await llm.rerank(query, documents); + + expect(result.results).toHaveLength(4); + + // Auth documents should score highest + const topTwo = result.results.slice(0, 2).map((r) => r.file); + expect(topTwo).toContain("auth.md"); + expect(topTwo).toContain("jwt.md"); + + // Irrelevant documents should score lowest + const bottomTwo = result.results.slice(2).map((r) => r.file); + expect(bottomTwo).toContain("weather.md"); + expect(bottomTwo).toContain("pizza.md"); + }); + + test("handles programming queries correctly", async () => { + const query = "How do I handle errors in JavaScript?"; + const documents: RerankDocument[] = [ + { file: "cooking.md", text: "To make a good pasta, boil water and add salt." }, + { file: "errors.md", text: "Use try-catch blocks to handle JavaScript errors gracefully." }, + { file: "python.md", text: "Python uses try-except for exception handling." }, + ]; + + const result = await llm.rerank(query, documents); + + // JavaScript errors doc should score highest + expect(result.results[0].file).toBe("errors.md"); + expect(result.results[0].score).toBeGreaterThan(0.7); + + // Python doc might be somewhat relevant (same concept, different language) + // Cooking should be least relevant + expect(result.results[2].file).toBe("cooking.md"); + }); + + test("handles empty document list", async () => { + const result = await llm.rerank("test query", []); + expect(result.results).toHaveLength(0); + }); + + test("handles single document", async () => { + const result = await llm.rerank("test", [{ file: "doc.md", text: "content" }]); + expect(result.results).toHaveLength(1); + expect(result.results[0].file).toBe("doc.md"); + }); + + test("preserves original file paths", async () => { + const documents: RerankDocument[] = [ + { file: "path/to/doc1.md", text: "content one" }, + { file: "another/path/doc2.md", text: "content two" }, + ]; + + const result = await llm.rerank("query", documents); + + const files = result.results.map((r) => r.file).sort(); + expect(files).toEqual(["another/path/doc2.md", "path/to/doc1.md"]); + }); + + test("returns scores between 0 and 1", async () => { + const documents: RerankDocument[] = [ + { file: "a.md", text: "The quick brown fox jumps over the lazy dog." }, + { file: "b.md", text: "Machine learning algorithms process data efficiently." }, + { file: "c.md", text: "React components use JSX syntax for rendering." }, + ]; + + const result = await llm.rerank("Tell me about animals", documents); + + for (const doc of result.results) { + expect(doc.score).toBeGreaterThanOrEqual(0); + expect(doc.score).toBeLessThanOrEqual(1); + } + }); + + test("batch reranks multiple documents efficiently", async () => { + // Create 10 documents to verify batch processing works + const documents: RerankDocument[] = Array(10) + .fill(null) + .map((_, i) => ({ + file: `doc${i}.md`, + text: `Document number ${i} with some content about topic ${i % 3}`, + })); + + const start = Date.now(); + const result = await llm.rerank("topic 1", documents); + const elapsed = Date.now() - start; + + expect(result.results).toHaveLength(10); + + // Verify all documents are returned with valid scores + for (const doc of result.results) { + expect(doc.score).toBeGreaterThanOrEqual(0); + expect(doc.score).toBeLessThanOrEqual(1); + } + + // Log timing for monitoring batch performance + console.log(`Batch rerank of 10 docs took ${elapsed}ms`); + }); + }); + + describe("expandQuery", () => { + test("returns at least the original query", async () => { + const result = await llm.expandQuery("test query"); + + expect(result).toContain("test query"); + expect(result.length).toBeGreaterThanOrEqual(1); + }, 30000); // 30s timeout for model loading + + test("returns original query first", async () => { + const result = await llm.expandQuery("authentication setup"); + + expect(result[0]).toBe("authentication setup"); + }); }); }); diff --git a/src/llm.ts b/src/llm.ts index 2fb7301..9ce32ec 100644 --- a/src/llm.ts +++ b/src/llm.ts @@ -1,10 +1,34 @@ /** - * llm.ts - LLM abstraction layer for QMD + * llm.ts - LLM abstraction layer for QMD using node-llama-cpp * - * Provides a clean interface for LLM operations with an Ollama implementation. - * All raw fetch calls to LLM APIs should go through this module. + * Provides embeddings, text generation, and reranking using local GGUF models. */ +import { getLlama, resolveModelFile, type Llama, type LlamaModel, type LlamaEmbeddingContext, type LlamaContext, type LlamaChatSession } from "node-llama-cpp"; +import { homedir } from "os"; +import { join } from "path"; +import { existsSync, mkdirSync } from "fs"; + +// ============================================================================= +// Embedding Formatting Functions +// ============================================================================= + +/** + * Format a query for embedding. + * Uses nomic-style task prefix format for embeddinggemma. + */ +export function formatQueryForEmbedding(query: string): string { + return `task: search result | query: ${query}`; +} + +/** + * Format a document for embedding. + * Uses nomic-style format with title and text fields. + */ +export function formatDocForEmbedding(text: string, title?: string): string { + return `title: ${title || "none"} | text: ${text}`; +} + // ============================================================================= // Types // ============================================================================= @@ -40,11 +64,8 @@ export type GenerateResult = { */ export type RerankDocumentResult = { file: string; - relevant: boolean; - confidence: number; score: number; - rawToken: string; - logprob: number; + index: number; }; /** @@ -61,15 +82,14 @@ export type RerankResult = { export type ModelInfo = { name: string; exists: boolean; - size?: number; - modifiedAt?: string; + path?: string; }; /** * Options for embedding */ export type EmbedOptions = { - model: string; + model?: string; isQuery?: boolean; title?: string; }; @@ -78,20 +98,25 @@ export type EmbedOptions = { * Options for text generation */ export type GenerateOptions = { - model: string; + model?: string; maxTokens?: number; temperature?: number; - logprobs?: boolean; - raw?: boolean; - stop?: string[]; }; /** * Options for reranking */ export type RerankOptions = { - model: string; - batchSize?: number; + model?: string; +}; + +/** + * Structured query expansion result + */ +export type ExpandedQuery = { + lexicalQuery: string | null; // Alternative query for BM25/keyword search + vectorQuery: string; // Alternative query for semantic search + hyde: string; // Hypothetical document that would answer the query }; /** @@ -103,6 +128,19 @@ export type RerankDocument = { title?: string; }; +// ============================================================================= +// Model Configuration +// ============================================================================= + +// HuggingFace model URIs for node-llama-cpp +// Format: hf:// +const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf"; +const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf"; +const DEFAULT_GENERATE_MODEL = "hf:ggml-org/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf"; + +// Local model cache directory +const MODEL_CACHE_DIR = join(homedir(), ".cache", "qmd", "models"); + // ============================================================================= // LLM Interface // ============================================================================= @@ -114,266 +152,297 @@ export interface LLM { /** * Get embeddings for text */ - embed(text: string, options: EmbedOptions): Promise; + embed(text: string, options?: EmbedOptions): Promise; /** * Generate text completion */ - generate(prompt: string, options: GenerateOptions): Promise; + generate(prompt: string, options?: GenerateOptions): Promise; /** - * Check if a model exists + * Check if a model exists/is available */ modelExists(model: string): Promise; - /** - * Pull a model (download if not available) - */ - pullModel(model: string, onProgress?: (progress: number) => void): Promise; - - // ========================================================================== - // High-level abstractions - // ========================================================================== - /** * Expand a search query into multiple variations */ - expandQuery(query: string, model: string, numVariations?: number): Promise; + expandQuery(query: string, numVariations?: number): Promise; /** * Rerank documents by relevance to a query - * Returns list of documents with relevance scores and boolean judgments + * Returns list of documents with relevance scores (higher = more relevant) */ - rerank(query: string, documents: RerankDocument[], options: RerankOptions): Promise; + rerank(query: string, documents: RerankDocument[], options?: RerankOptions): Promise; /** - * Quick relevance check - returns just boolean judgments with logprobs - * More efficient than full rerank when you just need yes/no + * Dispose of resources */ - rerankerLogprobsCheck(query: string, documents: RerankDocument[], options: RerankOptions): Promise; + dispose(): Promise; } // ============================================================================= -// Ollama Implementation +// node-llama-cpp Implementation // ============================================================================= -export type OllamaConfig = { - baseUrl?: string; - defaultEmbedModel?: string; - defaultGenerateModel?: string; - defaultRerankModel?: string; +export type LlamaCppConfig = { + embedModel?: string; + generateModel?: string; + rerankModel?: string; + modelCacheDir?: string; }; -const DEFAULT_OLLAMA_URL = "http://localhost:11434"; -const DEFAULT_EMBED_MODEL = "embeddinggemma"; -const DEFAULT_GENERATE_MODEL = "qwen3:0.6b"; -const DEFAULT_RERANK_MODEL = "ExpedientFalcon/qwen3-reranker:0.6b-q8_0"; - /** - * Format text for embedding query + * LLM implementation using node-llama-cpp */ -export function formatQueryForEmbedding(query: string): string { - return `task: search result | query: ${query}`; -} +export class LlamaCpp implements LLM { + private llama: Llama | null = null; + private embedModel: LlamaModel | null = null; + private embedContext: LlamaEmbeddingContext | null = null; + private generateModel: LlamaModel | null = null; + private generateContext: LlamaContext | null = null; + private rerankModel: LlamaModel | null = null; + private rerankContext: Awaited> | null = null; -/** - * Format text for embedding document - */ -export function formatDocForEmbedding(text: string, title?: string): string { - return `title: ${title || "none"} | text: ${text}`; -} + private embedModelUri: string; + private generateModelUri: string; + private rerankModelUri: string; + private modelCacheDir: string; -/** - * Ollama LLM implementation - */ -export class Ollama implements LLM { - private baseUrl: string; - private defaultEmbedModel: string; - private defaultGenerateModel: string; - private defaultRerankModel: string; + private initPromise: Promise | null = null; - constructor(config: OllamaConfig = {}) { - this.baseUrl = config.baseUrl || process.env.OLLAMA_URL || DEFAULT_OLLAMA_URL; - this.defaultEmbedModel = config.defaultEmbedModel || DEFAULT_EMBED_MODEL; - this.defaultGenerateModel = config.defaultGenerateModel || DEFAULT_GENERATE_MODEL; - this.defaultRerankModel = config.defaultRerankModel || DEFAULT_RERANK_MODEL; + constructor(config: LlamaCppConfig = {}) { + this.embedModelUri = config.embedModel || DEFAULT_EMBED_MODEL; + this.generateModelUri = config.generateModel || DEFAULT_GENERATE_MODEL; + this.rerankModelUri = config.rerankModel || DEFAULT_RERANK_MODEL; + this.modelCacheDir = config.modelCacheDir || MODEL_CACHE_DIR; } /** - * Get the base URL for this Ollama instance + * Ensure model cache directory exists */ - getBaseUrl(): string { - return this.baseUrl; + private ensureModelCacheDir(): void { + if (!existsSync(this.modelCacheDir)) { + mkdirSync(this.modelCacheDir, { recursive: true }); + } + } + + /** + * Initialize the llama instance (lazy) + */ + private async ensureLlama(): Promise { + if (!this.llama) { + this.llama = await getLlama({ logLevel: "error" }); + } + return this.llama; + } + + /** + * Resolve a model URI to a local path, downloading if needed + */ + private async resolveModel(modelUri: string): Promise { + this.ensureModelCacheDir(); + // resolveModelFile handles HF URIs and downloads to the cache dir + return await resolveModelFile(modelUri, this.modelCacheDir); + } + + /** + * Load embedding model and context (lazy) + */ + private async ensureEmbedContext(): Promise { + if (!this.embedContext) { + const llama = await this.ensureLlama(); + const modelPath = await this.resolveModel(this.embedModelUri); + this.embedModel = await llama.loadModel({ modelPath }); + this.embedContext = await this.embedModel.createEmbeddingContext(); + } + return this.embedContext; + } + + /** + * Load generation model and context (lazy) + */ + private async ensureGenerateContext(): Promise { + if (!this.generateContext) { + const llama = await this.ensureLlama(); + const modelPath = await this.resolveModel(this.generateModelUri); + this.generateModel = await llama.loadModel({ modelPath }); + // Create context with 4 sequences for parallel generation support + this.generateContext = await this.generateModel.createContext({ sequences: 4 }); + } + return this.generateContext; + } + + /** + * Load rerank model and context (lazy) + */ + private async ensureRerankContext(): Promise>> { + if (!this.rerankContext) { + const llama = await this.ensureLlama(); + const modelPath = await this.resolveModel(this.rerankModelUri); + this.rerankModel = await llama.loadModel({ modelPath }); + this.rerankContext = await this.rerankModel.createRankingContext(); + } + return this.rerankContext; + } + + // ========================================================================== + // Tokenization + // ========================================================================== + + /** + * Tokenize text using the embedding model's tokenizer + * Returns array of token IDs + */ + async tokenize(text: string): Promise { + await this.ensureEmbedContext(); // Ensure model is loaded + if (!this.embedModel) { + throw new Error("Embed model not loaded"); + } + return this.embedModel.tokenize(text); + } + + /** + * Count tokens in text using the embedding model's tokenizer + */ + async countTokens(text: string): Promise { + const tokens = await this.tokenize(text); + return tokens.length; + } + + /** + * Detokenize token IDs back to text + */ + async detokenize(tokens: number[]): Promise { + await this.ensureEmbedContext(); + if (!this.embedModel) { + throw new Error("Embed model not loaded"); + } + return this.embedModel.detokenize(tokens); } // ========================================================================== // Core API methods // ========================================================================== - async embed(text: string, options: EmbedOptions): Promise { - const model = options.model || this.defaultEmbedModel; - const formatted = options.isQuery - ? formatQueryForEmbedding(text) - : formatDocForEmbedding(text, options.title); - + async embed(text: string, options: EmbedOptions = {}): Promise { try { - const response = await fetch(`${this.baseUrl}/api/embed`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model, input: formatted }), - }); - - if (!response.ok) { - return null; - } - - const data = await response.json() as { embeddings?: number[][] }; - if (!data.embeddings?.[0]) { - return null; - } + const context = await this.ensureEmbedContext(); + const embedding = await context.getEmbeddingFor(text); return { - embedding: data.embeddings[0], - model, + embedding: Array.from(embedding.vector), + model: this.embedModelUri, }; - } catch { + } catch (error) { + console.error("Embedding error:", error); return null; } } - async generate(prompt: string, options: GenerateOptions): Promise { - const model = options.model || this.defaultGenerateModel; + /** + * Batch embed multiple texts efficiently + * Uses Promise.all for parallel embedding - node-llama-cpp handles batching internally + */ + async embedBatch(texts: string[]): Promise<(EmbeddingResult | null)[]> { + if (texts.length === 0) return []; - const requestBody: Record = { - model, - prompt, - stream: false, - options: { - num_predict: options.maxTokens ?? 150, - temperature: options.temperature ?? 0, - }, + try { + const context = await this.ensureEmbedContext(); + + // node-llama-cpp handles batching internally when we make parallel requests + const embeddings = await Promise.all( + texts.map(async (text) => { + try { + const embedding = await context.getEmbeddingFor(text); + return { + embedding: Array.from(embedding.vector), + model: this.embedModelUri, + }; + } catch (err) { + console.error("Embedding error for text:", err); + return null; + } + }) + ); + + return embeddings; + } catch (error) { + console.error("Batch embedding error:", error); + return texts.map(() => null); + } + } + + async generate(prompt: string, options: GenerateOptions = {}): Promise { + try { + const context = await this.ensureGenerateContext(); + const { LlamaChatSession } = await import("node-llama-cpp"); + const session = new LlamaChatSession({ + contextSequence: context.getSequence(), + }); + + const maxTokens = options.maxTokens ?? 150; + const temperature = options.temperature ?? 0; + + let result = ""; + try { + await session.prompt(prompt, { + maxTokens, + temperature, + onTextChunk: (text) => { + result += text; + }, + }); + } finally { + // Dispose session to release the sequence + await session.dispose(); + } + + return { + text: result, + model: this.generateModelUri, + done: true, + }; + } catch (error) { + console.error("Generation error:", error); + return null; + } + } + + async modelExists(modelUri: string): Promise { + // For HuggingFace URIs, we assume they exist + // For local paths, check if file exists + if (modelUri.startsWith("hf:")) { + return { name: modelUri, exists: true }; + } + + const exists = existsSync(modelUri); + return { + name: modelUri, + exists, + path: exists ? modelUri : undefined, }; - - if (options.logprobs) { - requestBody.logprobs = true; - } - - if (options.raw) { - requestBody.raw = true; - } - - if (options.stop) { - (requestBody.options as Record).stop = options.stop; - } - - try { - const response = await fetch(`${this.baseUrl}/api/generate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - return null; - } - - const data = await response.json() as { - response?: string; - done?: boolean; - logprobs?: { tokens?: string[]; token_logprobs?: number[] }; - }; - - // Parse logprobs if present - let logprobs: TokenLogProb[] | undefined; - if (data.logprobs?.tokens && data.logprobs?.token_logprobs) { - logprobs = data.logprobs.tokens.map((token, i) => ({ - token, - logprob: data.logprobs!.token_logprobs![i], - })); - } - - return { - text: data.response || "", - model, - logprobs, - done: data.done ?? true, - }; - } catch { - return null; - } - } - - async modelExists(model: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/show`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: model }), - }); - - if (!response.ok) { - return { name: model, exists: false }; - } - - const data = await response.json() as { - size?: number; - modified_at?: string; - }; - - return { - name: model, - exists: true, - size: data.size, - modifiedAt: data.modified_at, - }; - } catch { - return { name: model, exists: false }; - } - } - - async pullModel(model: string, onProgress?: (progress: number) => void): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/pull`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: model, stream: false }), - }); - - if (!response.ok) { - return false; - } - - // For non-streaming, we just wait for completion - await response.json(); - onProgress?.(100); - return true; - } catch { - return false; - } } // ========================================================================== // High-level abstractions // ========================================================================== - async expandQuery(query: string, model?: string, numVariations: number = 2): Promise { - const useModel = model || this.defaultGenerateModel; - + async expandQuery(query: string, numVariations: number = 2): Promise { const prompt = `You are a search query expander. Given a search query, generate ${numVariations} alternative queries that would help find relevant documents. Rules: -- Use synonyms and related terminology (e.g., "craft" → "craftsmanship", "quality", "excellence") -- Rephrase to capture different angles (e.g., "engineering culture" → "technical excellence", "developer practices") -- Keep proper nouns and named concepts exactly as written (e.g., "Build a Business", "Stripe", "Shopify") +- Use synonyms and related terminology +- Rephrase to capture different angles +- Keep proper nouns exactly as written - Each variation should be 3-8 words, natural search terms -- Do NOT just append words like "search" or "find" or "documents" +- Do NOT append words like "search" or "find" Query: "${query}" Output exactly ${numVariations} variations, one per line, no numbering or bullets:`; const result = await this.generate(prompt, { - model: useModel, maxTokens: 150, temperature: 0, }); @@ -392,148 +461,226 @@ Output exactly ${numVariations} variations, one per line, no numbering or bullet return [query, ...lines.slice(0, numVariations)]; } + /** + * Expand query using structured output with JSON schema grammar. + * Returns different query types optimized for different retrieval methods. + * + * @param query - Original search query + * @param includeLexical - Whether to include lexical query (false for vector-only search) + */ + async expandQueryStructured(query: string, includeLexical: boolean = true): Promise { + const llama = await this.ensureLlama(); + const context = await this.ensureGenerateContext(); + + // Define JSON schema for structured output + const schema = { + type: "object" as const, + properties: { + lexicalQuery: { + type: "string" as const, + description: "Alternative keyword-based query using synonyms (3-6 words)" + }, + vectorQuery: { + type: "string" as const, + description: "Semantically rephrased query capturing the intent (5-10 words)" + }, + hyde: { + type: "string" as const, + description: "A hypothetical document snippet that would perfectly answer this query (50-100 words)" + } + }, + required: ["vectorQuery", "hyde"] as const + }; + + const grammar = await llama.createGrammarForJsonSchema(schema); + + const systemPrompt = includeLexical + ? `You expand search queries into structured alternatives for a hybrid search system. +Given a query, generate: +1. lexicalQuery: Alternative keywords using synonyms (for BM25 keyword search) +2. vectorQuery: Semantically rephrased query (for vector/embedding search) +3. hyde: A hypothetical document excerpt that would answer the query (50-100 words) + +Keep proper nouns exactly as written. Be concise.` + : `You expand search queries for semantic search. +Given a query, generate: +1. vectorQuery: Semantically rephrased query capturing the full intent +2. hyde: A hypothetical document excerpt that would answer the query (50-100 words) + +Keep proper nouns exactly as written. Be concise. Set lexicalQuery to empty string.`; + + const prompt = `Query: "${query}" + +Generate the structured expansion:`; + + const { LlamaChatSession } = await import("node-llama-cpp"); + const session = new LlamaChatSession({ + contextSequence: context.getSequence(), + systemPrompt, + }); + + try { + const result = await session.prompt(prompt, { + grammar, + maxTokens: 300, + temperature: 0, + }); + + const parsed = grammar.parse(result) as { + lexicalQuery?: string; + vectorQuery: string; + hyde: string; + }; + + return { + lexicalQuery: includeLexical && parsed.lexicalQuery ? parsed.lexicalQuery : null, + vectorQuery: parsed.vectorQuery || query, + hyde: parsed.hyde || "", + }; + } catch (error) { + console.error("Structured query expansion failed:", error); + // Fallback to original query + return { + lexicalQuery: includeLexical ? query : null, + vectorQuery: query, + hyde: "", + }; + } finally { + await session.dispose(); + } + } + async rerank( query: string, documents: RerankDocument[], - options: RerankOptions + options: RerankOptions = {} ): Promise { - const results = await this.rerankerLogprobsCheck(query, documents, options); + try { + const context = await this.ensureRerankContext(); - return { - results: results.sort((a, b) => b.score - a.score), - model: options.model || this.defaultRerankModel, - }; - } + // Build a map from document text to original indices (for lookup after sorting) + const textToDoc = new Map(); + documents.forEach((doc, index) => { + textToDoc.set(doc.text, { file: doc.file, index }); + }); - async rerankerLogprobsCheck( - query: string, - documents: RerankDocument[], - options: RerankOptions - ): Promise { - const model = options.model || this.defaultRerankModel; - const batchSize = options.batchSize || 5; + // Extract just the text for ranking + const texts = documents.map((doc) => doc.text); - const results: RerankDocumentResult[] = []; + // Use the proper ranking API - returns [{document: string, score: number}] sorted by score + const ranked = await context.rankAndSort(query, texts); - // Process in batches - for (let i = 0; i < documents.length; i += batchSize) { - const batch = documents.slice(i, i + batchSize); - const batchResults = await Promise.all( - batch.map((doc) => this.rerankSingle(query, doc, model)) - ); - results.push(...batchResults); - } + // Map back to our result format using the text-to-doc map + const results: RerankDocumentResult[] = ranked.map((item) => { + const docInfo = textToDoc.get(item.document)!; + return { + file: docInfo.file, + score: item.score, + index: docInfo.index, + }; + }); - return results; - } - - /** - * Rerank a single document - internal helper - */ - private async rerankSingle( - query: string, - doc: RerankDocument, - model: string - ): Promise { - const systemPrompt = `Judge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".`; - - const instruct = `Given a search query, determine if the following document is relevant to the query. Consider both direct matches and related concepts.`; - - const docTitle = doc.title || doc.file.split("/").pop()?.replace(/\.md$/, "") || doc.file; - const docPreview = doc.text.length > 4000 ? doc.text.substring(0, 4000) + "..." : doc.text; - - // Qwen3-reranker prompt format with empty think tags - const prompt = `<|im_start|>system -${systemPrompt}<|im_end|> -<|im_start|>user -: ${instruct} -: ${query} -: ${docTitle} -: ${docPreview}<|im_end|> -<|im_start|>assistant - - - - -`; - - const result = await this.generate(prompt, { - model, - maxTokens: 1, - temperature: 0, - logprobs: true, - raw: true, - }); - - if (!result) { return { - file: doc.file, - relevant: false, - confidence: 0, - score: 0, - rawToken: "", - logprob: 0, + results, + model: this.rerankModelUri, + }; + } catch (error) { + console.error("Rerank error:", error); + // Return documents in original order with zero scores on error + return { + results: documents.map((doc, index) => ({ + file: doc.file, + score: 0, + index, + })), + model: this.rerankModelUri, }; } - - return this.parseRerankResponse(doc.file, result); } - /** - * Parse rerank response into structured result - */ - private parseRerankResponse(file: string, result: GenerateResult): RerankDocumentResult { - const token = result.text.toLowerCase().trim(); - const logprob = result.logprobs?.[0]?.logprob ?? 0; - const confidence = Math.exp(logprob); - - let relevant: boolean; - let score: number; - - if (token.startsWith("yes")) { - relevant = true; - // Score: 0.5 base + up to 0.5 from confidence - score = 0.5 + 0.5 * confidence; - } else if (token.startsWith("no")) { - relevant = false; - // Score: up to 0.5 based on uncertainty (1 - confidence) - score = 0.5 * (1 - confidence); - } else { - // Unknown token - neutral score - relevant = false; - score = 0.3; + async dispose(): Promise { + // Dispose contexts + if (this.embedContext) { + await this.embedContext.dispose(); + this.embedContext = null; + } + if (this.generateContext) { + await this.generateContext.dispose(); + this.generateContext = null; + } + if (this.rerankContext) { + await this.rerankContext.dispose(); + this.rerankContext = null; } - return { - file, - relevant, - confidence, - score, - rawToken: result.logprobs?.[0]?.token ?? token, - logprob, - }; + // Dispose models + if (this.embedModel) { + await this.embedModel.dispose(); + this.embedModel = null; + } + if (this.generateModel) { + await this.generateModel.dispose(); + this.generateModel = null; + } + if (this.rerankModel) { + await this.rerankModel.dispose(); + this.rerankModel = null; + } + + // Dispose llama + if (this.llama) { + await this.llama.dispose(); + this.llama = null; + } } } // ============================================================================= -// Singleton for default Ollama instance +// Singleton for default LlamaCpp instance // ============================================================================= -let defaultOllama: Ollama | null = null; +let defaultLlamaCpp: LlamaCpp | null = null; /** - * Get the default Ollama instance (creates one if needed) + * Get the default LlamaCpp instance (creates one if needed) */ -export function getDefaultOllama(): Ollama { - if (!defaultOllama) { - defaultOllama = new Ollama(); +export function getDefaultLlamaCpp(): LlamaCpp { + if (!defaultLlamaCpp) { + defaultLlamaCpp = new LlamaCpp(); } - return defaultOllama; + return defaultLlamaCpp; } /** - * Set a custom default Ollama instance (useful for testing) + * Set a custom default LlamaCpp instance (useful for testing) */ -export function setDefaultOllama(ollama: Ollama | null): void { - defaultOllama = ollama; +export function setDefaultLlamaCpp(llm: LlamaCpp | null): void { + defaultLlamaCpp = llm; +} + +/** + * Dispose the default LlamaCpp instance if it exists. + * Call this before process exit to prevent NAPI crashes. + */ +export async function disposeDefaultLlamaCpp(): Promise { + if (defaultLlamaCpp) { + await defaultLlamaCpp.dispose(); + defaultLlamaCpp = null; + } +} + +// ============================================================================= +// Legacy exports for backwards compatibility +// ============================================================================= + +// Keep Ollama as an alias for now during transition +export { LlamaCpp as Ollama }; +export type { LlamaCppConfig as OllamaConfig }; + +export function getDefaultOllama(): LlamaCpp { + return getDefaultLlamaCpp(); +} + +export function setDefaultOllama(llm: LlamaCpp | null): void { + setDefaultLlamaCpp(llm); } diff --git a/src/mcp.test.ts b/src/mcp.test.ts index 0e7a0f8..7d1031b 100644 --- a/src/mcp.test.ts +++ b/src/mcp.test.ts @@ -10,68 +10,13 @@ import { Database } from "bun:sqlite"; import * as sqliteVec from "sqlite-vec"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { setDefaultOllama, Ollama } from "./llm"; +import { setDefaultLlamaCpp, LlamaCpp } from "./llm"; import { mkdtemp, writeFile, readdir, unlink, rmdir } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import YAML from "yaml"; import type { CollectionConfig } from "./collections"; -// ============================================================================= -// Mock Ollama -// ============================================================================= - -const OLLAMA_URL = "http://localhost:11434"; -const originalFetch = globalThis.fetch; - -const mockOllamaResponses: Record Response> = { - "/api/embed": () => { - const embedding = Array(768).fill(0).map(() => Math.random()); - return new Response(JSON.stringify({ embeddings: [embedding] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }, - "/api/generate": (body: unknown) => { - const reqBody = body as { prompt?: string; logprobs?: boolean }; - if (reqBody.prompt?.includes("Judge") || reqBody.prompt?.includes("Document")) { - // Return format matching Ollama API - return new Response(JSON.stringify({ - response: "yes", - done: true, - logprobs: reqBody.logprobs ? { tokens: ["yes"], token_logprobs: [-0.1] } : undefined - }), { status: 200, headers: { "Content-Type": "application/json" } }); - } else { - return new Response(JSON.stringify({ - response: "expanded query variation 1\nexpanded query variation 2", - done: true, - }), { status: 200, headers: { "Content-Type": "application/json" } }); - } - }, - "/api/show": () => { - return new Response(JSON.stringify({ size: 1000000 }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }, -}; - -function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const url = typeof input === "string" ? input : input.toString(); - - if (url.startsWith(OLLAMA_URL)) { - const path = url.replace(OLLAMA_URL, ""); - const handler = mockOllamaResponses[path]; - if (handler) { - const body = init?.body ? JSON.parse(init.body as string) : {}; - return Promise.resolve(handler(body)); - } - throw new Error(`Unmocked Ollama endpoint: ${path}`); - } - - throw new Error(`Unexpected fetch call to: ${url}`); -} - // ============================================================================= // Test Database Setup // ============================================================================= @@ -114,7 +59,7 @@ function initTestDatabase(db: Database): void { db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(hash)`); db.exec(` - CREATE TABLE IF NOT EXISTS ollama_cache ( + CREATE TABLE IF NOT EXISTS llm_cache ( hash TEXT PRIMARY KEY, result TEXT NOT NULL, created_at TEXT NOT NULL @@ -151,7 +96,7 @@ function initTestDatabase(db: Database): void { `); // Create vector table - db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[768])`); + db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[768] distance_metric=cosine)`); } function seedTestData(db: Database): void { @@ -251,8 +196,8 @@ import type { RankedResult } from "./store"; describe("MCP Server", () => { beforeAll(async () => { - globalThis.fetch = mockFetch as typeof fetch; - setDefaultOllama(new Ollama({ baseUrl: OLLAMA_URL })); + // LlamaCpp uses node-llama-cpp for local model inference (no HTTP mocking needed) + setDefaultLlamaCpp(new LlamaCpp()); // Set up test config directory const configPrefix = join(tmpdir(), `qmd-mcp-config-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -280,8 +225,7 @@ describe("MCP Server", () => { }); afterAll(async () => { - globalThis.fetch = originalFetch; - setDefaultOllama(null); + setDefaultLlamaCpp(null); testDb.close(); try { require("fs").unlinkSync(testDbPath); @@ -373,9 +317,10 @@ describe("MCP Server", () => { describe("qmd_query tool", () => { test("expands query with variations", async () => { const queries = await expandQuery("api documentation", DEFAULT_QUERY_MODEL, testDb); - expect(queries.length).toBeGreaterThan(1); + // Always returns at least the original query, may have more if generation succeeds + expect(queries.length).toBeGreaterThanOrEqual(1); expect(queries[0]).toBe("api documentation"); - }); + }, 30000); // 30s timeout for model loading test("performs RRF fusion on multiple result lists", () => { const list1: RankedResult[] = [ diff --git a/src/qmd.ts b/src/qmd.ts index 77c39c8..fbff1a8 100755 --- a/src/qmd.ts +++ b/src/qmd.ts @@ -35,6 +35,7 @@ import { formatDocForEmbedding, formatQueryForEmbedding, chunkDocument, + chunkDocumentByTokens, ensureVecTable, clearCache, getCacheKey, @@ -54,7 +55,7 @@ import { deactivateDocument, getActiveDocumentPaths, cleanupOrphanedContent, - deleteOllamaCache, + deleteLLMCache, deleteInactiveDocuments, cleanupOrphanedVectors, cleanupDuplicateCollections, @@ -62,13 +63,13 @@ import { getCollectionsWithoutContext, getTopLevelPathsWithoutContext, handelize, - OLLAMA_URL, DEFAULT_EMBED_MODEL, DEFAULT_QUERY_MODEL, DEFAULT_RERANK_MODEL, DEFAULT_GLOB, DEFAULT_MULTI_GET_MAX_BYTES, } from "./store.js"; +import { getDefaultLlamaCpp, disposeDefaultLlamaCpp, type RerankDocument, type ExpandedQuery } from "./llm.js"; import type { SearchResult, RankedResult } from "./store.js"; import { formatSearchResults, @@ -86,9 +87,6 @@ import { listAllContexts, } from "./collections.js"; -// Chunking: ~2000 tokens per chunk, ~3 bytes/token = 6KB -const CHUNK_BYTE_SIZE = 6 * 1024; - // Terminal colors (respects NO_COLOR env) const useColor = !process.env.NO_COLOR && process.stdout.isTTY; const c = { @@ -192,185 +190,26 @@ function computeDisplayPath( return filepath; } -// Auto-pull model if not found -async function ensureModelAvailable(model: string): Promise { - try { - const response = await fetch(`${OLLAMA_URL}/api/show`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: model }), - }); - if (response.ok) return; - } catch { - // Continue to pull attempt - } +// Rerank documents using node-llama-cpp cross-encoder model +async function rerank(query: string, documents: { file: string; text: string }[], _model: string = DEFAULT_RERANK_MODEL, _db?: Database): Promise<{ file: string; score: number }[]> { + if (documents.length === 0) return []; - console.log(`Model ${model} not found. Pulling...`); - progress.indeterminate(); - - const pullResponse = await fetch(`${OLLAMA_URL}/api/pull`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: model, stream: false }), - }); - - if (!pullResponse.ok) { - progress.error(); - throw new Error(`Failed to pull model ${model}: ${pullResponse.status} - ${await pullResponse.text()}`); - } - - progress.clear(); - console.log(`Model ${model} pulled successfully.`); -} - -async function getEmbedding(text: string, model: string, isQuery: boolean = false, title?: string, retried: boolean = false): Promise { - const input = isQuery ? formatQueryForEmbedding(text) : formatDocForEmbedding(text, title); - - const response = await fetch(`${OLLAMA_URL}/api/embed`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model, input }), - }); - if (!response.ok) { - const errorText = await response.text(); - if (!retried && (errorText.includes("not found") || errorText.includes("does not exist"))) { - await ensureModelAvailable(model); - return getEmbedding(text, model, isQuery, title, true); - } - throw new Error(`Ollama API error: ${response.status} - ${errorText}`); - } - const data = await response.json() as { embeddings: number[][] }; - return data.embeddings[0]; -} - -// Qwen3-Reranker prompt format (trained for yes/no relevance classification) -const RERANK_SYSTEM = `Judge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".`; - -function formatRerankPrompt(query: string, title: string, doc: string): string { - return `: Determine if this document from a Shopify knowledge base is relevant to the search query. The query may reference specific Shopify programs, competitions, features, or named concepts (e.g., "Build a Business" competition, "Shop Pay", "Polaris"). Match documents that discuss the queried topic, even if phrasing differs. -: ${query} -: ${title} -: ${doc}`; -} - -type LogProb = { token: string; logprob: number }; -type RerankResponse = { - response: string; - logprobs?: LogProb[]; -}; - -function parseRerankResponse(data: RerankResponse): number { - if (!data.logprobs || data.logprobs.length === 0) { - throw new Error("Reranker response missing logprobs"); - } - - const firstToken = data.logprobs[0]; - const token = firstToken.token.toLowerCase().trim(); - const confidence = Math.exp(firstToken.logprob); - - if (token === "yes") { - return confidence; - } - if (token === "no") { - return (1 - confidence) * 0.3; - } - - throw new Error(`Unexpected reranker token: "${token}"`); -} - -async function rerankSingle(prompt: string, model: string, db?: Database, retried: boolean = false): Promise { - // Use generate with raw template for qwen3-reranker format - // Include empty tags as per HuggingFace reference implementation - const fullPrompt = `<|im_start|>system -${RERANK_SYSTEM}<|im_end|> -<|im_start|>user -${prompt}<|im_end|> -<|im_start|>assistant - - - - -`; - - const requestBody = { - model, - prompt: fullPrompt, - raw: true, - stream: false, - logprobs: true, - options: { num_predict: 1 }, - }; - - // Check cache - const cacheKey = db ? getCacheKey(`${OLLAMA_URL}/api/generate`, requestBody) : ""; - if (db) { - const cached = getCachedResult(db, cacheKey); - if (cached) { - const data = JSON.parse(cached) as RerankResponse; - return parseRerankResponse(data); - } - } - - const response = await fetch(`${OLLAMA_URL}/api/generate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (!retried && (errorText.includes("not found") || errorText.includes("does not exist"))) { - await ensureModelAvailable(model); - return rerankSingle(prompt, model, db, true); - } - throw new Error(`Ollama API error: ${response.status} - ${errorText}`); - } - - const data = await response.json() as RerankResponse; - - // Cache the result - if (db) { - setCachedResult(db, cacheKey, JSON.stringify(data)); - } - - return parseRerankResponse(data); -} - -async function rerank(query: string, documents: { file: string; text: string }[], model: string = DEFAULT_RERANK_MODEL, db?: Database): Promise<{ file: string; score: number }[]> { - const results: { file: string; score: number }[] = []; const total = documents.length; - const PARALLEL = 5; - - process.stderr.write(`Reranking ${total} documents with ${model} (parallel: ${PARALLEL})...\n`); + process.stderr.write(`Reranking ${total} documents...\n`); progress.indeterminate(); - // Process in parallel batches - for (let i = 0; i < documents.length; i += PARALLEL) { - const batch = documents.slice(i, i + PARALLEL); - const batchResults = await Promise.all( - batch.map(async (doc) => { - try { - // Extract title from filename for reranker context - const title = doc.file.split('/').pop()?.replace(/\.md$/, '') || doc.file; - const prompt = formatRerankPrompt(query, title, doc.text.slice(0, 4000)); - const score = await rerankSingle(prompt, model, db); - return { file: doc.file, score }; - } catch (err) { - return { file: doc.file, score: 0 }; - } - }) - ); - results.push(...batchResults); + const llm = getDefaultLlamaCpp(); + const rerankDocs: RerankDocument[] = documents.map((doc) => ({ + file: doc.file, + text: doc.text.slice(0, 4000), // Truncate to context limit + })); - const processed = Math.min(i + PARALLEL, total); - progress.set((processed / total) * 100); - process.stderr.write(`\rReranking: ${processed}/${total}`); - } + const result = await llm.rerank(query, rerankDocs); progress.clear(); process.stderr.write("\n"); - return results.sort((a, b) => b.score - a.score); + return result.results.map((r) => ({ file: r.file, score: r.score })); } function formatTimeAgo(date: Date): string { @@ -1593,10 +1432,12 @@ async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean = } // Prepare documents with chunks - type ChunkItem = { hash: string; title: string; text: string; seq: number; pos: number; bytes: number; displayName: string }; + type ChunkItem = { hash: string; title: string; text: string; seq: number; pos: number; tokens: number; bytes: number; displayName: string }; const allChunks: ChunkItem[] = []; let multiChunkDocs = 0; + // Chunk all documents using actual token counts + process.stderr.write(`Chunking ${hashesToEmbed.length} documents by token count...\n`); for (const item of hashesToEmbed) { const encoder = new TextEncoder(); const bodyBytes = encoder.encode(item.body).length; @@ -1604,7 +1445,7 @@ async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean = const title = extractTitle(item.body, item.path); const displayName = item.path; - const chunks = chunkDocument(item.body, CHUNK_BYTE_SIZE); + const chunks = await chunkDocumentByTokens(item.body); // Uses actual tokenizer if (chunks.length > 1) multiChunkDocs++; @@ -1615,6 +1456,7 @@ async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean = text: chunks[seq].text, seq, pos: chunks[seq].pos, + tokens: chunks[seq].tokens, bytes: encoder.encode(chunks[seq].text).length, displayName, }); @@ -1642,29 +1484,64 @@ async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean = // Get embedding dimensions from first chunk progress.indeterminate(); - const firstEmbedding = await getEmbedding(allChunks[0].text, model, false, allChunks[0].title); - ensureVecTable(db, firstEmbedding.length); + const llm = getDefaultLlamaCpp(); + const firstText = formatDocForEmbedding(allChunks[0].text, allChunks[0].title); + const firstResult = await llm.embed(firstText); + if (!firstResult) { + throw new Error("Failed to get embedding dimensions from first chunk"); + } + ensureVecTable(db, firstResult.embedding.length); let chunksEmbedded = 0, errors = 0, bytesProcessed = 0; const startTime = Date.now(); - // Insert first chunk - insertEmbedding(db, allChunks[0].hash, allChunks[0].seq, allChunks[0].pos, new Float32Array(firstEmbedding), model, now); - chunksEmbedded++; - bytesProcessed += allChunks[0].bytes; + // Batch embedding for better throughput + // Process in batches of 32 to balance memory usage and efficiency + const BATCH_SIZE = 32; + + for (let batchStart = 0; batchStart < allChunks.length; batchStart += BATCH_SIZE) { + const batchEnd = Math.min(batchStart + BATCH_SIZE, allChunks.length); + const batch = allChunks.slice(batchStart, batchEnd); + + // Format texts for embedding + const texts = batch.map(chunk => formatDocForEmbedding(chunk.text, chunk.title)); - for (let i = 1; i < allChunks.length; i++) { - const chunk = allChunks[i]; try { - const embedding = await getEmbedding(chunk.text, model, false, chunk.title); - insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(embedding), model, now); - chunksEmbedded++; - bytesProcessed += chunk.bytes; + // Batch embed all texts at once + const embeddings = await llm.embedBatch(texts); + + // Insert each embedding + for (let i = 0; i < batch.length; i++) { + const chunk = batch[i]; + const embedding = embeddings[i]; + + if (embedding) { + insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(embedding.embedding), model, now); + chunksEmbedded++; + } else { + errors++; + console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}${c.reset}`); + } + bytesProcessed += chunk.bytes; + } } catch (err) { - errors++; - bytesProcessed += chunk.bytes; - progress.error(); - console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}: ${err}${c.reset}`); + // If batch fails, try individual embeddings as fallback + for (const chunk of batch) { + try { + const text = formatDocForEmbedding(chunk.text, chunk.title); + const result = await llm.embed(text); + if (result) { + insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(result.embedding), model, now); + chunksEmbedded++; + } else { + errors++; + } + } catch (innerErr) { + errors++; + console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}: ${innerErr}${c.reset}`); + } + bytesProcessed += chunk.bytes; + } } const percent = (bytesProcessed / totalBytes) * 100; @@ -2046,17 +1923,25 @@ async function vectorSearch(query: string, opts: OutputOptions, model: string = // Check index health and warn about issues checkIndexHealth(db); - // Expand query to multiple variations (with caching) - const queries = await expandQuery(query, DEFAULT_QUERY_MODEL, db); - process.stderr.write(`Searching with ${queries.length} query variations...\n`); + // Expand query using structured output (no lexical for vector-only search) + const expanded = await expandQueryStructured(query, false); + + // Build list of queries for vector search: original, vectorQuery, and hyde + const vectorQueries: string[] = [query]; + if (expanded.vectorQuery && expanded.vectorQuery !== query) { + vectorQueries.push(expanded.vectorQuery); + } + if (expanded.hyde && expanded.hyde.length > 20) { + vectorQueries.push(expanded.hyde); + } + + process.stderr.write(`${c.dim}Searching ${vectorQueries.length} vector queries...${c.reset}\n`); // Collect results from all query variations - // For --all, fetch more results per query const perQueryLimit = opts.all ? 500 : 20; const allResults = new Map(); - for (const q of queries) { - // searchVec accepts collection name as number parameter for legacy reasons (will be fixed in store.ts) + for (const q of vectorQueries) { const vecResults = await searchVec(db, q, model, perQueryLimit, collectionName as any); for (const r of vecResults) { const existing = allResults.get(r.filepath); @@ -2081,71 +1966,51 @@ async function vectorSearch(query: string, opts: OutputOptions, model: string = outputResults(results, query, { ...opts, limit: results.length }); // Already limited } -async function expandQuery(query: string, model: string = DEFAULT_QUERY_MODEL, db?: Database): Promise { - process.stderr.write("Generating query variations...\n"); +// Expand query using structured output with JSON schema grammar +async function expandQueryStructured(query: string, includeLexical: boolean = true): Promise { + process.stderr.write(`${c.dim}Expanding query...${c.reset}\n`); - const prompt = `You are a search query expander. Given a search query, generate 2 alternative queries that would help find relevant documents. + const llm = getDefaultLlamaCpp(); + const expanded = await llm.expandQueryStructured(query, includeLexical); -Rules: -- Use synonyms and related terminology (e.g., "craft" → "craftsmanship", "quality", "excellence") -- Rephrase to capture different angles (e.g., "engineering culture" → "technical excellence", "developer practices") -- Keep proper nouns and named concepts exactly as written (e.g., "Build a Business", "Stripe", "Shopify") -- Each variation should be 3-8 words, natural search terms -- Do NOT just append words like "search" or "find" or "documents" + // Log the expansion as a tree, starting with original query + const lines: string[] = []; + const bothLabel = includeLexical ? ' · (lexical+vector)' : ' · (vector)'; + lines.push(`${c.dim}├─ ${query}${bothLabel}${c.reset}`); -Query: "${query}" - -Output exactly 2 variations, one per line, no numbering or bullets:`; - - const requestBody = { - model, - prompt, - stream: false, - think: false, - options: { num_predict: 150 }, - }; - - // Check cache - const cacheDb = db || getDb(); - const cacheKey = getCacheKey(`${OLLAMA_URL}/api/generate`, requestBody); - const cached = getCachedResult(cacheDb, cacheKey); - - let responseText: string; - if (cached) { - responseText = cached; - } else { - const response = await fetch(`${OLLAMA_URL}/api/generate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (errorText.includes("not found") || errorText.includes("does not exist")) { - await ensureModelAvailable(model); - if (!db) cacheDb.close(); - return expandQuery(query, model, db); - } - if (!db) cacheDb.close(); - return [query]; - } - - const data = await response.json() as { response: string }; - responseText = data.response; - setCachedResult(cacheDb, cacheKey, responseText); + if (expanded.lexicalQuery && expanded.lexicalQuery !== query) { + lines.push(`${c.dim}├─ ${expanded.lexicalQuery} · (lexical)${c.reset}`); + } + if (expanded.vectorQuery && expanded.vectorQuery !== query) { + lines.push(`${c.dim}├─ ${expanded.vectorQuery} · (vector)${c.reset}`); + } + if (expanded.hyde && expanded.hyde.length > 20) { + // Truncate hyde to first ~60 chars for display + const hydePreview = expanded.hyde.length > 60 + ? expanded.hyde.substring(0, 60).replace(/\n/g, ' ') + '...' + : expanded.hyde.replace(/\n/g, ' '); + lines.push(`${c.dim}├─ ${hydePreview} · (vector)${c.reset}`); } - if (!db) cacheDb.close(); + // Fix last item to use └─ instead of ├─ + if (lines.length > 0) { + lines[lines.length - 1] = lines[lines.length - 1].replace('├─', '└─'); + } - const lines = responseText.trim().split('\n') - .map(l => l.replace(/^[\d\.\-\*\"\s]+/, '').replace(/["\s]+$/, '').trim()) - .filter(l => l.length > 2 && l.length < 100 && !l.startsWith('<') && !l.toLowerCase().includes('variation')) - .slice(0, 2); + for (const line of lines) { + process.stderr.write(line + '\n'); + } - const allQueries = [query, ...lines]; - process.stderr.write(`${c.dim}Queries: ${allQueries.join(' | ')}${c.reset}\n`); - return allQueries; + return expanded; +} + +// Legacy wrapper for backward compatibility +async function expandQuery(query: string, _model: string = DEFAULT_QUERY_MODEL, _db?: Database): Promise { + const expanded = await expandQueryStructured(query, true); + const queries = [query]; + if (expanded.lexicalQuery && expanded.lexicalQuery !== query) queries.push(expanded.lexicalQuery); + if (expanded.vectorQuery && expanded.vectorQuery !== query) queries.push(expanded.vectorQuery); + return queries; } async function querySearch(query: string, opts: OutputOptions, embedModel: string = DEFAULT_EMBED_MODEL, rerankModel: string = DEFAULT_RERANK_MODEL): Promise { @@ -2166,9 +2031,24 @@ async function querySearch(query: string, opts: OutputOptions, embedModel: strin // Check index health and warn about issues checkIndexHealth(db); - // Expand query to multiple variations (with caching) - const queries = await expandQuery(query, DEFAULT_QUERY_MODEL, db); - process.stderr.write(`Searching with ${queries.length} query variations...\n`); + // Expand query using structured output + const expanded = await expandQueryStructured(query, true); + + // Build query lists for each retrieval type + const ftsQueries: string[] = [query]; + if (expanded.lexicalQuery && expanded.lexicalQuery !== query) { + ftsQueries.push(expanded.lexicalQuery); + } + + const vectorQueries: string[] = [query]; + if (expanded.vectorQuery && expanded.vectorQuery !== query) { + vectorQueries.push(expanded.vectorQuery); + } + if (expanded.hyde && expanded.hyde.length > 20) { + vectorQueries.push(expanded.hyde); + } + + process.stderr.write(`${c.dim}Searching ${ftsQueries.length} lexical + ${vectorQueries.length} vector queries...${c.reset}\n`); // Collect ranked result lists for RRF fusion const rankedLists: RankedResult[][] = []; @@ -2177,18 +2057,18 @@ async function querySearch(query: string, opts: OutputOptions, embedModel: strin // Map to store hash by filepath for final results const hashMap = new Map(); - for (const q of queries) { - // FTS search - get ranked results - // searchFTS accepts collection name as number parameter for legacy reasons (will be fixed in store.ts) + // FTS searches with lexical queries + for (const q of ftsQueries) { const ftsResults = searchFTS(db, q, 20, collectionName as any); if (ftsResults.length > 0) { for (const r of ftsResults) hashMap.set(r.filepath, r.hash); rankedLists.push(ftsResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score }))); } + } - // Vector search - get ranked results - if (hasVectors) { - // searchVec accepts collection name as number parameter for legacy reasons (will be fixed in store.ts) + // Vector searches with semantic queries + hyde + if (hasVectors) { + for (const q of vectorQueries) { const vecResults = await searchVec(db, q, embedModel, 20, collectionName as any); if (vecResults.length > 0) { for (const r of vecResults) hashMap.set(r.filepath, r.hash); @@ -2209,10 +2089,39 @@ async function querySearch(query: string, opts: OutputOptions, embedModel: strin return; } - // Rerank with the original query (with caching) + // Rerank chunks, not full documents + // For each candidate, extract the most relevant chunk to rerank + const chunksToRerank: { file: string; text: string; chunkIdx: number }[] = []; + const docChunkMap = new Map(); + + for (const c of candidates) { + const chunks = chunkDocument(c.body); + if (chunks.length === 1) { + // Small document - use entire body + chunksToRerank.push({ file: c.file, text: chunks[0].text, chunkIdx: 0 }); + docChunkMap.set(c.file, { chunks, bestChunkIdx: 0 }); + } else { + // Find the chunk that best matches the query terms (simple keyword heuristic) + const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2); + let bestIdx = 0; + let bestScore = 0; + for (let i = 0; i < chunks.length; i++) { + const chunkLower = chunks[i].text.toLowerCase(); + const score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 1 : 0), 0); + if (score > bestScore) { + bestScore = score; + bestIdx = i; + } + } + chunksToRerank.push({ file: c.file, text: chunks[bestIdx].text, chunkIdx: bestIdx }); + docChunkMap.set(c.file, { chunks, bestChunkIdx: bestIdx }); + } + } + + // Rerank the focused chunks (with caching) const reranked = await rerank( query, - candidates.map(c => ({ file: c.file, text: c.body })), + chunksToRerank.map(c => ({ file: c.file, text: c.text })), rerankModel, db ); @@ -2239,11 +2148,16 @@ async function querySearch(query: string, opts: OutputOptions, embedModel: strin const rrfScore = 1 / rrfRank; // Position-based: 1, 0.5, 0.33... const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score; const candidate = candidateMap.get(r.file); + // Use the best chunk's text for the body (better for snippets) + const chunkInfo = docChunkMap.get(r.file); + const chunkBody = chunkInfo ? chunkInfo.chunks[chunkInfo.bestChunkIdx].text : candidate?.body || ""; + const chunkPos = chunkInfo ? chunkInfo.chunks[chunkInfo.bestChunkIdx].pos : 0; return { file: r.file, displayPath: candidate?.displayPath || "", title: candidate?.title || "", - body: candidate?.body || "", + body: chunkBody, + chunkPos, score: blendedScore, context: getContextForFile(db, r.file), hash: hashMap.get(r.file) || "", @@ -2341,7 +2255,7 @@ function showHelp(): void { console.log(" qmd multi-get [-l N] [--max-bytes N] - Get multiple docs by glob or comma-separated list"); console.log(" qmd status - Show index status and collections"); console.log(" qmd update [--pull] - Re-index all collections (--pull: git pull first)"); - console.log(" qmd embed [-f] - Create vector embeddings (chunks ~6KB each)"); + console.log(" qmd embed [-f] - Create vector embeddings (800 tokens/chunk, 15% overlap)"); console.log(" qmd cleanup - Remove cache and orphaned data, vacuum DB"); console.log(" qmd search - Full-text search (BM25)"); console.log(" qmd vsearch - Vector similarity search"); @@ -2369,12 +2283,10 @@ function showHelp(): void { console.log(" --max-bytes - Skip files larger than N bytes (default: 10240)"); console.log(" --json/--csv/--md/--xml/--files - Output format (same as search)"); console.log(""); - console.log("Environment:"); - console.log(" OLLAMA_URL - Ollama server URL (default: http://localhost:11434)"); - console.log(""); - console.log("Models:"); - console.log(` Embedding: ${DEFAULT_EMBED_MODEL}`); - console.log(` Reranking: ${DEFAULT_RERANK_MODEL}`); + console.log("Models (auto-downloaded from HuggingFace):"); + console.log(" Embedding: embeddinggemma-300M-Q8_0"); + console.log(" Reranking: qwen3-reranker-0.6b-q8_0"); + console.log(" Generation: Qwen3-0.6B-Q8_0"); console.log(""); console.log(`Index: ${getDbPath()}`); } @@ -2617,8 +2529,8 @@ switch (cli.command) { case "cleanup": { const db = getDb(); - // 1. Clear ollama_cache - const cacheCount = deleteOllamaCache(db); + // 1. Clear llm_cache + const cacheCount = deleteLLMCache(db); console.log(`${c.green}✓${c.reset} Cleared ${cacheCount} cached API responses`); // 2. Remove orphaned vectors @@ -2648,4 +2560,8 @@ switch (cli.command) { console.error("Run 'qmd --help' for usage."); process.exit(1); } + +// Cleanup LlamaCpp instance to prevent NAPI crash on exit +await disposeDefaultLlamaCpp(); + } // end if (import.meta.main) diff --git a/src/store.test.ts b/src/store.test.ts index 3d7d5eb..e405572 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -3,7 +3,7 @@ * * Run with: bun test store.test.ts * - * Ollama is mocked - tests will fail if any real Ollama calls are made. + * LLM operations use LlamaCpp with local GGUF models (node-llama-cpp). */ import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach, mock, spyOn } from "bun:test"; @@ -24,6 +24,7 @@ import { formatQueryForEmbedding, formatDocForEmbedding, chunkDocument, + chunkDocumentByTokens, reciprocalRankFusion, extractSnippet, getCacheKey, @@ -31,7 +32,6 @@ import { normalizeVirtualPath, isVirtualPath, parseVirtualPath, - OLLAMA_URL, type Store, type DocumentResult, type SearchResult, @@ -40,91 +40,11 @@ import { import type { CollectionConfig } from "./collections.js"; // ============================================================================= -// Ollama Mocking +// LlamaCpp Setup // ============================================================================= -// Track original fetch -const originalFetch = globalThis.fetch; - -// Mock responses for different Ollama endpoints -const mockOllamaResponses: Record Response> = { - "/api/embed": (body: unknown) => { - // Return mock embeddings (768 dimensions) - const embedding = Array(768).fill(0).map(() => Math.random()); - return new Response(JSON.stringify({ embeddings: [embedding] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }, - "/api/generate": (body: unknown) => { - const reqBody = body as { prompt?: string }; - // Check if this is a rerank request or query expansion - if (reqBody.prompt?.includes("yes") || reqBody.prompt?.includes("no") || reqBody.prompt?.includes("Judge")) { - // Rerank response - return new Response(JSON.stringify({ - response: "yes", - logprobs: [{ token: "yes", logprob: -0.1 }], - }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } else { - // Query expansion response - return new Response(JSON.stringify({ - response: "expanded query variation 1\nexpanded query variation 2", - }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - }, - "/api/show": () => { - // Model exists - return new Response(JSON.stringify({ modelfile: "exists" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }, -}; - -// Install mock fetch that intercepts Ollama calls -function installOllamaMock(): void { - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - - // Check if this is an Ollama URL - if (url.startsWith(OLLAMA_URL)) { - const path = url.replace(OLLAMA_URL, ""); - const mockHandler = mockOllamaResponses[path]; - - if (mockHandler) { - const body = init?.body ? JSON.parse(init.body as string) : {}; - return mockHandler(body); - } - - // Unknown Ollama endpoint - fail the test - throw new Error(`TEST ERROR: Unmocked Ollama endpoint called: ${path}`); - } - - // Non-Ollama URLs fail (we shouldn't be making other network calls in tests) - throw new Error(`TEST ERROR: Unexpected network call to: ${url}`); - }; -} - -// Restore original fetch -function restoreOllamaMock(): void { - globalThis.fetch = originalFetch; -} - -// Install mock before all tests -beforeAll(() => { - installOllamaMock(); -}); - -// Restore after all tests -afterAll(() => { - restoreOllamaMock(); -}); +// Note: LlamaCpp uses node-llama-cpp for local GGUF model inference. +// No HTTP mocking needed - tests use real LlamaCpp calls for integration tests. // ============================================================================= // Test Utilities @@ -483,7 +403,7 @@ describe("Store Creation", () => { expect(tableNames).toContain("documents"); expect(tableNames).toContain("documents_fts"); expect(tableNames).toContain("content_vectors"); - expect(tableNames).toContain("ollama_cache"); + expect(tableNames).toContain("llm_cache"); // Note: path_contexts table removed in favor of YAML-based context storage await cleanupTestDb(store); @@ -580,7 +500,7 @@ describe("Embedding Formatting", () => { describe("Document Chunking", () => { test("chunkDocument returns single chunk for small documents", () => { const content = "Small document content"; - const chunks = chunkDocument(content, 1000); + const chunks = chunkDocument(content, 1000, 0); expect(chunks).toHaveLength(1); expect(chunks[0].text).toBe(content); expect(chunks[0].pos).toBe(0); @@ -588,7 +508,7 @@ describe("Document Chunking", () => { test("chunkDocument splits large documents", () => { const content = "A".repeat(10000); - const chunks = chunkDocument(content, 1000); + const chunks = chunkDocument(content, 1000, 0); expect(chunks.length).toBeGreaterThan(1); // All chunks should have correct positions @@ -600,9 +520,26 @@ describe("Document Chunking", () => { } }); + test("chunkDocument with overlap creates overlapping chunks", () => { + const content = "A".repeat(3000); + const chunks = chunkDocument(content, 1000, 150); // 15% overlap + expect(chunks.length).toBeGreaterThan(1); + + // With overlap, positions should be closer together than without + // Each new chunk starts 150 chars before where the previous one ended + for (let i = 1; i < chunks.length; i++) { + const prevEnd = chunks[i - 1].pos + chunks[i - 1].text.length; + const currentStart = chunks[i].pos; + // Current chunk should start before the previous chunk ended (overlap) + expect(currentStart).toBeLessThan(prevEnd); + // But should still make forward progress + expect(currentStart).toBeGreaterThan(chunks[i - 1].pos); + } + }); + test("chunkDocument prefers paragraph breaks", () => { const content = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.".repeat(50); - const chunks = chunkDocument(content, 500); + const chunks = chunkDocument(content, 500, 0); // Chunks should end at paragraph breaks when possible for (const chunk of chunks.slice(0, -1)) { @@ -617,13 +554,82 @@ describe("Document Chunking", () => { test("chunkDocument handles UTF-8 characters correctly", () => { const content = "こんにちは世界".repeat(500); // Japanese text - const chunks = chunkDocument(content, 1000); + const chunks = chunkDocument(content, 1000, 0); // Should not split in the middle of a multi-byte character for (const chunk of chunks) { expect(() => new TextEncoder().encode(chunk.text)).not.toThrow(); } }); + + test("chunkDocument with default params uses 800-token chunks", () => { + // Default is CHUNK_SIZE_CHARS (3200 chars) with CHUNK_OVERLAP_CHARS (480 chars) + const content = "Word ".repeat(2000); // ~10000 chars + const chunks = chunkDocument(content); + expect(chunks.length).toBeGreaterThan(1); + // Each chunk should be around 3200 chars (except last) + expect(chunks[0].text.length).toBeGreaterThan(2500); + expect(chunks[0].text.length).toBeLessThanOrEqual(3200); + }); +}); + +describe("Token-based Chunking", () => { + test("chunkDocumentByTokens returns single chunk for small documents", async () => { + const content = "This is a small document."; + const chunks = await chunkDocumentByTokens(content, 800, 120); + expect(chunks).toHaveLength(1); + expect(chunks[0].text).toBe(content); + expect(chunks[0].pos).toBe(0); + expect(chunks[0].tokens).toBeGreaterThan(0); + expect(chunks[0].tokens).toBeLessThan(800); + }); + + test("chunkDocumentByTokens splits large documents", async () => { + // Create a document that's definitely more than 800 tokens + const content = "The quick brown fox jumps over the lazy dog. ".repeat(200); + const chunks = await chunkDocumentByTokens(content, 800, 120); + + expect(chunks.length).toBeGreaterThan(1); + + // Each chunk should have ~800 tokens or less + for (const chunk of chunks) { + expect(chunk.tokens).toBeLessThanOrEqual(850); // Allow slight overage + expect(chunk.tokens).toBeGreaterThan(0); + } + + // Chunks should have correct positions + for (let i = 0; i < chunks.length; i++) { + expect(chunks[i].pos).toBeGreaterThanOrEqual(0); + if (i > 0) { + expect(chunks[i].pos).toBeGreaterThan(chunks[i - 1].pos); + } + } + }); + + test("chunkDocumentByTokens creates overlapping chunks", async () => { + const content = "Word ".repeat(500); // ~500 tokens + const chunks = await chunkDocumentByTokens(content, 200, 30); // 15% overlap + + expect(chunks.length).toBeGreaterThan(1); + + // With overlap, consecutive chunks should have overlapping positions + for (let i = 1; i < chunks.length; i++) { + const prevEnd = chunks[i - 1].pos + chunks[i - 1].text.length; + const currentStart = chunks[i].pos; + // Current chunk should start before the previous chunk ended (overlap) + expect(currentStart).toBeLessThan(prevEnd); + } + }); + + test("chunkDocumentByTokens returns actual token counts", async () => { + const content = "Hello world, this is a test."; + const chunks = await chunkDocumentByTokens(content); + + expect(chunks).toHaveLength(1); + // The token count should be reasonable (not 0, not equal to char count) + expect(chunks[0].tokens).toBeGreaterThan(0); + expect(chunks[0].tokens).toBeLessThan(content.length); // Tokens < chars for English + }); }); // ============================================================================= @@ -1842,10 +1848,10 @@ describe("Legacy Compatibility", () => { }); // ============================================================================= -// Ollama Integration Tests (using mocked Ollama) +// LlamaCpp Integration Tests (using real local models) // ============================================================================= -describe("Ollama Integration (Mocked)", () => { +describe("LlamaCpp Integration", () => { test("searchVec returns empty when no vector index", async () => { const store = await createTestStore(); const collectionName = await createTestCollection(); @@ -1895,7 +1901,7 @@ describe("Ollama Integration (Mocked)", () => { const queries = await store.expandQuery("test query"); expect(queries).toContain("test query"); expect(queries[0]).toBe("test query"); - // Mock returns 2 variations + // LlamaCpp returns original + variations expect(queries.length).toBeGreaterThanOrEqual(1); await cleanupTestDb(store); @@ -1924,7 +1930,7 @@ describe("Ollama Integration (Mocked)", () => { const results = await store.rerank("topic", docs); expect(results).toHaveLength(2); - // Mock returns "yes" with high confidence + // LlamaCpp reranker returns relevance scores expect(results[0].score).toBeGreaterThan(0); await cleanupTestDb(store); diff --git a/src/store.ts b/src/store.ts index bad2804..5f2a9a3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -15,8 +15,8 @@ import { Database } from "bun:sqlite"; import { Glob } from "bun"; import * as sqliteVec from "sqlite-vec"; import { - Ollama, - getDefaultOllama, + LlamaCpp, + getDefaultLlamaCpp, formatQueryForEmbedding, formatDocForEmbedding, type RerankDocument, @@ -47,11 +47,12 @@ export const DEFAULT_QUERY_MODEL = "qwen3:0.6b"; export const DEFAULT_GLOB = "**/*.md"; export const DEFAULT_MULTI_GET_MAX_BYTES = 10 * 1024; // 10KB -// Re-export OLLAMA_URL for backwards compatibility -export const OLLAMA_URL = getDefaultOllama().getBaseUrl(); - -// Chunking: ~2000 tokens per chunk, ~3 bytes/token = 6KB -const CHUNK_BYTE_SIZE = 6 * 1024; +// Chunking: 800 tokens per chunk with 15% overlap +export const CHUNK_SIZE_TOKENS = 800; +export const CHUNK_OVERLAP_TOKENS = Math.floor(CHUNK_SIZE_TOKENS * 0.15); // 120 tokens (15% overlap) +// Fallback char-based approximation for sync chunking (~4 chars per token) +export const CHUNK_SIZE_CHARS = CHUNK_SIZE_TOKENS * 4; // 3200 chars +export const CHUNK_OVERLAP_CHARS = CHUNK_OVERLAP_TOKENS * 4; // 480 chars // ============================================================================= // Path utilities @@ -292,9 +293,9 @@ function initializeDatabase(db: Database): void { db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(hash)`); db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_path ON documents(path, active)`); - // Cache table for Ollama API calls + // Cache table for LLM API calls (table name kept for backwards compatibility) db.exec(` - CREATE TABLE IF NOT EXISTS ollama_cache ( + CREATE TABLE IF NOT EXISTS llm_cache ( hash TEXT PRIMARY KEY, result TEXT NOT NULL, created_at TEXT NOT NULL @@ -372,10 +373,12 @@ function ensureVecTableInternal(db: Database, dimensions: number): void { if (tableInfo) { const match = tableInfo.sql.match(/float\[(\d+)\]/); const hasHashSeq = tableInfo.sql.includes('hash_seq'); - if (match && parseInt(match[1]) === dimensions && hasHashSeq) return; + const hasCosine = tableInfo.sql.includes('distance_metric=cosine'); + if (match && parseInt(match[1]) === dimensions && hasHashSeq && hasCosine) return; + // Table exists but wrong schema - need to rebuild db.exec("DROP TABLE IF EXISTS vectors_vec"); } - db.exec(`CREATE VIRTUAL TABLE vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[${dimensions}])`); + db.exec(`CREATE VIRTUAL TABLE vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[${dimensions}] distance_metric=cosine)`); } // ============================================================================= @@ -400,7 +403,7 @@ export type Store = { clearCache: () => void; // Cleanup and maintenance - deleteOllamaCache: () => number; + deleteLLMCache: () => number; deleteInactiveDocuments: () => number; cleanupOrphanedContent: () => number; cleanupOrphanedVectors: () => number; @@ -488,7 +491,7 @@ export function createStore(dbPath?: string): Store { clearCache: () => clearCache(db), // Cleanup and maintenance - deleteOllamaCache: () => deleteOllamaCache(db), + deleteLLMCache: () => deleteLLMCache(db), deleteInactiveDocuments: () => deleteInactiveDocuments(db), cleanupOrphanedContent: () => cleanupOrphanedContent(db), cleanupOrphanedVectors: () => cleanupOrphanedVectors(db), @@ -776,20 +779,20 @@ export function getCacheKey(url: string, body: object): string { } export function getCachedResult(db: Database, cacheKey: string): string | null { - const row = db.prepare(`SELECT result FROM ollama_cache WHERE hash = ?`).get(cacheKey) as { result: string } | null; + const row = db.prepare(`SELECT result FROM llm_cache WHERE hash = ?`).get(cacheKey) as { result: string } | null; return row?.result || null; } export function setCachedResult(db: Database, cacheKey: string, result: string): void { const now = new Date().toISOString(); - db.prepare(`INSERT OR REPLACE INTO ollama_cache (hash, result, created_at) VALUES (?, ?, ?)`).run(cacheKey, result, now); + db.prepare(`INSERT OR REPLACE INTO llm_cache (hash, result, created_at) VALUES (?, ?, ?)`).run(cacheKey, result, now); if (Math.random() < 0.01) { - db.exec(`DELETE FROM ollama_cache WHERE hash NOT IN (SELECT hash FROM ollama_cache ORDER BY created_at DESC LIMIT 1000)`); + db.exec(`DELETE FROM llm_cache WHERE hash NOT IN (SELECT hash FROM llm_cache ORDER BY created_at DESC LIMIT 1000)`); } } export function clearCache(db: Database): void { - db.exec(`DELETE FROM ollama_cache`); + db.exec(`DELETE FROM llm_cache`); } // ============================================================================= @@ -797,11 +800,11 @@ export function clearCache(db: Database): void { // ============================================================================= /** - * Delete cached Ollama API responses. + * Delete cached LLM API responses. * Returns the number of cached responses deleted. */ -export function deleteOllamaCache(db: Database): number { - const result = db.prepare(`DELETE FROM ollama_cache`).run(); +export function deleteLLMCache(db: Database): number { + const result = db.prepare(`DELETE FROM llm_cache`).run(); return result.changes; } @@ -1007,11 +1010,8 @@ export function getActiveDocumentPaths(db: Database, collectionName: string): st // Re-export from llm.ts for backwards compatibility export { formatQueryForEmbedding, formatDocForEmbedding }; -export function chunkDocument(content: string, maxBytes: number = CHUNK_BYTE_SIZE): { text: string; pos: number }[] { - const encoder = new TextEncoder(); - const totalBytes = encoder.encode(content).length; - - if (totalBytes <= maxBytes) { +export function chunkDocument(content: string, maxChars: number = CHUNK_SIZE_CHARS, overlapChars: number = CHUNK_OVERLAP_CHARS): { text: string; pos: number }[] { + if (content.length <= maxChars) { return [{ text: content, pos: 0 }]; } @@ -1019,52 +1019,174 @@ export function chunkDocument(content: string, maxBytes: number = CHUNK_BYTE_SIZ let charPos = 0; while (charPos < content.length) { - let endPos = charPos; - let byteCount = 0; + // Calculate end position for this chunk + let endPos = Math.min(charPos + maxChars, content.length); - while (endPos < content.length && byteCount < maxBytes) { - const charBytes = encoder.encode(content[endPos]).length; - if (byteCount + charBytes > maxBytes) break; - byteCount += charBytes; - endPos++; - } - - if (endPos < content.length && endPos > charPos) { + // If not at the end, try to find a good break point + if (endPos < content.length) { const slice = content.slice(charPos, endPos); - const paragraphBreak = slice.lastIndexOf('\n\n'); - const sentenceEnd = Math.max( - slice.lastIndexOf('. '), - slice.lastIndexOf('.\n'), - slice.lastIndexOf('? '), - slice.lastIndexOf('?\n'), - slice.lastIndexOf('! '), - slice.lastIndexOf('!\n') - ); - const lineBreak = slice.lastIndexOf('\n'); - const spaceBreak = slice.lastIndexOf(' '); - let breakPoint = -1; - if (paragraphBreak > slice.length * 0.5) { - breakPoint = paragraphBreak + 2; - } else if (sentenceEnd > slice.length * 0.5) { - breakPoint = sentenceEnd + 2; - } else if (lineBreak > slice.length * 0.3) { - breakPoint = lineBreak + 1; - } else if (spaceBreak > slice.length * 0.3) { - breakPoint = spaceBreak + 1; + // Look for break points in the last 30% of the chunk + const searchStart = Math.floor(slice.length * 0.7); + const searchSlice = slice.slice(searchStart); + + // Priority: paragraph > sentence > line > word + let breakOffset = -1; + const paragraphBreak = searchSlice.lastIndexOf('\n\n'); + if (paragraphBreak >= 0) { + breakOffset = searchStart + paragraphBreak + 2; + } else { + const sentenceEnd = Math.max( + searchSlice.lastIndexOf('. '), + searchSlice.lastIndexOf('.\n'), + searchSlice.lastIndexOf('? '), + searchSlice.lastIndexOf('?\n'), + searchSlice.lastIndexOf('! '), + searchSlice.lastIndexOf('!\n') + ); + if (sentenceEnd >= 0) { + breakOffset = searchStart + sentenceEnd + 2; + } else { + const lineBreak = searchSlice.lastIndexOf('\n'); + if (lineBreak >= 0) { + breakOffset = searchStart + lineBreak + 1; + } else { + const spaceBreak = searchSlice.lastIndexOf(' '); + if (spaceBreak >= 0) { + breakOffset = searchStart + spaceBreak + 1; + } + } + } } - if (breakPoint > 0) { - endPos = charPos + breakPoint; + if (breakOffset > 0) { + endPos = charPos + breakOffset; } } + // Ensure we make progress if (endPos <= charPos) { - endPos = charPos + 1; + endPos = Math.min(charPos + maxChars, content.length); } chunks.push({ text: content.slice(charPos, endPos), pos: charPos }); - charPos = endPos; + + // Move forward, but overlap with previous chunk + // For last chunk, don't overlap (just go to the end) + if (endPos >= content.length) { + break; + } + charPos = endPos - overlapChars; + if (charPos <= chunks[chunks.length - 1].pos) { + // Prevent infinite loop - move forward at least a bit + charPos = endPos; + } + } + + return chunks; +} + +/** + * Chunk a document by actual token count using the LLM tokenizer. + * More accurate than character-based chunking but requires async. + */ +export async function chunkDocumentByTokens( + content: string, + maxTokens: number = CHUNK_SIZE_TOKENS, + overlapTokens: number = CHUNK_OVERLAP_TOKENS +): Promise<{ text: string; pos: number; tokens: number }[]> { + const llm = getDefaultLlamaCpp(); + + // For small documents, check if we need chunking at all + const totalTokens = await llm.countTokens(content); + if (totalTokens <= maxTokens) { + return [{ text: content, pos: 0, tokens: totalTokens }]; + } + + const chunks: { text: string; pos: number; tokens: number }[] = []; + let charPos = 0; + + while (charPos < content.length) { + // Binary search to find the right chunk end position + // Start with an estimate based on average tokens per char + const avgCharsPerToken = content.length / totalTokens; + let estimatedEnd = Math.min(charPos + Math.floor(maxTokens * avgCharsPerToken * 1.1), content.length); + + // Get token count for this slice + let slice = content.slice(charPos, estimatedEnd); + let sliceTokens = await llm.countTokens(slice); + + // Adjust until we're close to maxTokens + while (sliceTokens > maxTokens && estimatedEnd > charPos + 100) { + // Reduce by ~10% + estimatedEnd = charPos + Math.floor((estimatedEnd - charPos) * 0.9); + slice = content.slice(charPos, estimatedEnd); + sliceTokens = await llm.countTokens(slice); + } + + // If we're under, try to expand (but not past content end) + while (sliceTokens < maxTokens * 0.9 && estimatedEnd < content.length) { + const newEnd = Math.min(estimatedEnd + Math.floor((estimatedEnd - charPos) * 0.1), content.length); + if (newEnd === estimatedEnd) break; + const newSlice = content.slice(charPos, newEnd); + const newTokens = await llm.countTokens(newSlice); + if (newTokens > maxTokens) break; + estimatedEnd = newEnd; + slice = newSlice; + sliceTokens = newTokens; + } + + // Find a good break point in the last 30% of the chunk + if (estimatedEnd < content.length) { + const searchStart = charPos + Math.floor((estimatedEnd - charPos) * 0.7); + const searchSlice = content.slice(searchStart, estimatedEnd); + + let breakOffset = -1; + const paragraphBreak = searchSlice.lastIndexOf('\n\n'); + if (paragraphBreak >= 0) { + breakOffset = paragraphBreak + 2; + } else { + const sentenceEnd = Math.max( + searchSlice.lastIndexOf('. '), + searchSlice.lastIndexOf('.\n'), + searchSlice.lastIndexOf('? '), + searchSlice.lastIndexOf('?\n'), + searchSlice.lastIndexOf('! '), + searchSlice.lastIndexOf('!\n') + ); + if (sentenceEnd >= 0) { + breakOffset = sentenceEnd + 2; + } else { + const lineBreak = searchSlice.lastIndexOf('\n'); + if (lineBreak >= 0) { + breakOffset = lineBreak + 1; + } else { + const spaceBreak = searchSlice.lastIndexOf(' '); + if (spaceBreak >= 0) { + breakOffset = spaceBreak + 1; + } + } + } + } + + if (breakOffset >= 0) { + estimatedEnd = searchStart + breakOffset; + slice = content.slice(charPos, estimatedEnd); + sliceTokens = await llm.countTokens(slice); + } + } + + chunks.push({ text: slice, pos: charPos, tokens: sliceTokens }); + + // Move forward with overlap + if (estimatedEnd >= content.length) break; + + // Calculate overlap in characters based on token ratio + const overlapChars = Math.floor(overlapTokens * (slice.length / sliceTokens)); + charPos = estimatedEnd - overlapChars; + if (charPos <= chunks[chunks.length - 1].pos) { + charPos = estimatedEnd; // Prevent infinite loop + } } return chunks; @@ -1675,7 +1797,7 @@ export async function searchVec(db: Database, query: string, model: string, limi bodyLength: row.body.length, body: row.body, context: getContextForFile(db, row.filepath), - score: 1 / (1 + row.distance), + score: 1 - row.distance, // Cosine similarity = 1 - cosine distance source: "vec" as const, chunkPos: row.pos, }; @@ -1687,8 +1809,10 @@ export async function searchVec(db: Database, query: string, model: string, limi // ============================================================================= async function getEmbedding(text: string, model: string, isQuery: boolean): Promise { - const ollama = getDefaultOllama(); - const result = await ollama.embed(text, { model, isQuery }); + const llm = getDefaultLlamaCpp(); + // Format text using the appropriate prompt template + const formattedText = isQuery ? formatQueryForEmbedding(text) : formatDocForEmbedding(text); + const result = await llm.embed(formattedText, { model, isQuery }); return result?.embedding || null; } @@ -1750,8 +1874,9 @@ export async function expandQuery(query: string, model: string = DEFAULT_QUERY_M return [query, ...lines.slice(0, 2)]; } - const ollama = getDefaultOllama(); - const results = await ollama.expandQuery(query, model, 2); + const llm = getDefaultLlamaCpp(); + // Note: LlamaCpp uses hardcoded model, model parameter is ignored + const results = await llm.expandQuery(query, 2); // Cache the expanded queries (excluding original) if (results.length > 1) { @@ -1780,10 +1905,10 @@ export async function rerank(query: string, documents: { file: string; text: str } } - // Rerank uncached documents using Ollama + // Rerank uncached documents using LlamaCpp if (uncachedDocs.length > 0) { - const ollama = getDefaultOllama(); - const rerankResult = await ollama.rerank(query, uncachedDocs, { model }); + const llm = getDefaultLlamaCpp(); + const rerankResult = await llm.rerank(query, uncachedDocs, { model }); // Cache results for (const result of rerankResult.results) {