From 70878b0f4f32b52720f218acb833e96588c9f5d9 Mon Sep 17 00:00:00 2001 From: Oded Falik Date: Tue, 14 Apr 2026 17:39:21 -0700 Subject: [PATCH 1/7] Replace ChromaDB with Cloudflare Vectorize for shared vector search Architecture: local embeddings (MPS/CUDA) + cloud vector storage - New worker/: thin Cloudflare Worker (~85 lines) proxying Vectorize - POST /upsert, /query, /delete with Bearer token auth - New vectorize_client.py: replaces chromadb_client.py - Local HuggingFace embeddings (bge-small-en-v1.5) - HTTP calls to Worker for vector storage/retrieval - query_all_papers() for cross-paper RAG search - Updated embed.py: embed locally, store in Vectorize - Updated search.py: RAG search via Vectorize, grep stays local - Removed chromadb, llama-index-vector-stores-chroma deps --- .gitignore | 1 + paper_intelligence/tools/embed.py | 75 +- paper_intelligence/tools/search.py | 62 +- paper_intelligence/utils/__init__.py | 4 +- paper_intelligence/utils/vectorize_client.py | 210 +++ pyproject.toml | 3 +- worker/package-lock.json | 1530 ++++++++++++++++++ worker/package.json | 14 + worker/src/index.ts | 89 + worker/tsconfig.json | 13 + worker/wrangler.jsonc | 25 + 11 files changed, 1930 insertions(+), 96 deletions(-) create mode 100644 paper_intelligence/utils/vectorize_client.py create mode 100644 worker/package-lock.json create mode 100644 worker/package.json create mode 100644 worker/src/index.ts create mode 100644 worker/tsconfig.json create mode 100644 worker/wrangler.jsonc diff --git a/.gitignore b/.gitignore index b3d7f94..cd89666 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ Thumbs.db # Model cache (large files) .cache/ *.onnx +worker/node_modules/ diff --git a/paper_intelligence/tools/embed.py b/paper_intelligence/tools/embed.py index d3767e8..70b4f62 100644 --- a/paper_intelligence/tools/embed.py +++ b/paper_intelligence/tools/embed.py @@ -1,12 +1,8 @@ -"""Document embedding tool using LlamaIndex with ChromaDB. - -Embeddings are stored in each paper's directory for self-containment. -""" +"""Document embedding tool using local HuggingFace model + Cloudflare Vectorize.""" from pathlib import Path -from typing import Optional -from ..utils.chromadb_client import RAGClient, create_documents_from_markdown +from ..utils.vectorize_client import VectorizeClient, create_documents_from_markdown def embed_document( @@ -14,10 +10,7 @@ def embed_document( chunk_size: int = 512, chunk_overlap: int = 50, ) -> dict: - """Create embeddings for a markdown document and store in local ChromaDB. - - Embeddings are stored in the same directory as the markdown file - under a 'chroma/' subdirectory for self-containment. + """Embed a markdown document locally and store vectors in Cloudflare Vectorize. Args: markdown_path: Path to the markdown file @@ -25,66 +18,46 @@ def embed_document( chunk_overlap: Overlap between chunks Returns: - Dictionary with: - - db_path: Path to the ChromaDB storage - - num_chunks: Number of chunks embedded - - success: Boolean - - message: Status message + Dictionary with num_chunks, success, message, device """ md_path = Path(markdown_path).expanduser().resolve() if not md_path.exists(): return { - "db_path": None, "num_chunks": 0, "success": False, "message": f"Markdown file not found: {markdown_path}", } - # Store embeddings in the paper's directory - paper_dir = md_path.parent - chroma_dir = paper_dir / "chroma" + paper_name = md_path.parent.name try: - # Create RAG client with paper-local storage - rag_client = RAGClient( - persist_directory=chroma_dir, + client = VectorizeClient( + paper_name=paper_name, chunk_size=chunk_size, chunk_overlap=chunk_overlap, ) - # Create pre-chunked documents with line number metadata documents = create_documents_from_markdown( md_path, chunk_size=chunk_size, chunk_overlap=chunk_overlap, ) - # Use a fixed collection name since each paper has its own DB - collection_name = "paper" - - # Create index (skip chunking since documents are pre-chunked with line metadata) - rag_client.create_index(collection_name, documents, pre_chunked=True) - - # Get chunk count - num_chunks = rag_client.get_collection_count(collection_name) + num_chunks = client.embed_and_store(documents) - # Update metadata from ..metadata import update_metadata_steps - - update_metadata_steps(paper_dir, "embed") + update_metadata_steps(md_path.parent, "embed") return { - "db_path": str(chroma_dir), "num_chunks": num_chunks, "success": True, - "message": f"Successfully embedded document into {num_chunks} chunks", - "device": rag_client.device, + "message": f"Embedded {num_chunks} chunks (device={client.device})", + "device": client.device, } except Exception as e: return { - "db_path": None, "num_chunks": 0, "success": False, "message": f"Embedding failed: {str(e)}", @@ -96,30 +69,12 @@ def query_paper( query: str, top_k: int = 5, ) -> dict: - """Query a paper's embeddings for similar content. - - Args: - paper_dir: Path to the paper's directory (containing chroma/) - query: Search query - top_k: Number of results to return - - Returns: - Dictionary with search results - """ - paper_path = Path(paper_dir).expanduser().resolve() - chroma_dir = paper_path / "chroma" - - if not chroma_dir.exists(): - return { - "results": [], - "num_results": 0, - "success": False, - "message": f"No embeddings found in {paper_dir}", - } + """Query a paper's embeddings via Vectorize.""" + paper_name = Path(paper_dir).expanduser().resolve().name try: - rag_client = RAGClient(persist_directory=chroma_dir) - results = rag_client.query("paper", query, top_k) + client = VectorizeClient(paper_name=paper_name) + results = client.query(query, top_k) return { "results": results, diff --git a/paper_intelligence/tools/search.py b/paper_intelligence/tools/search.py index da818ac..8c49a17 100644 --- a/paper_intelligence/tools/search.py +++ b/paper_intelligence/tools/search.py @@ -10,7 +10,7 @@ from typing import Literal, Optional from ..metadata import check_version_compatibility -from ..utils.chromadb_client import RAGClient +from ..utils.vectorize_client import query_all_papers from ..utils.markdown_parser import MarkdownParser @@ -271,50 +271,48 @@ def rag_search( paper_dirs: list[Path], top_k: int = 5, ) -> list[dict]: - """Perform semantic RAG search using embeddings. + """Perform semantic RAG search via Cloudflare Vectorize. + + Searches across all papers in the shared vector index. + paper_dirs is used to scope results to the requested papers. Args: query: Search query paper_dirs: List of paper directories to search - top_k: Number of results per paper + top_k: Number of results Returns: List of matches with content, score, and metadata """ - results = [] - - for paper_dir in paper_dirs: - chroma_dir = paper_dir / "chroma" + paper_names = {d.name for d in paper_dirs} - if not chroma_dir.exists(): - continue + try: + raw_results = query_all_papers(query, top_k=top_k * 2) + except Exception: + return [] - try: - rag_client = RAGClient(persist_directory=chroma_dir) - raw_results = rag_client.query("paper", query, top_k) - - for r in raw_results: - metadata = r.get("metadata", {}) - result_entry = { - "paper_dir": str(paper_dir), - "paper_name": paper_dir.name, - "source": metadata.get("source", str(paper_dir / "paper.md")), - "content": r.get("text", ""), - "score": r.get("score", 0.0), - "header_context": metadata.get("header_path", ""), - "match_type": "rag", - } - # Include line numbers if available (from pre-chunked documents) - if "start_line" in metadata: - result_entry["start_line"] = metadata["start_line"] - if "end_line" in metadata: - result_entry["end_line"] = metadata["end_line"] - results.append(result_entry) + results = [] + for r in raw_results: + metadata = r.get("metadata", {}) + paper_name = metadata.get("paper_name", "") - except Exception: + # Scope to requested papers + if paper_names and paper_name not in paper_names: continue - # Sort by score + result_entry = { + "paper_name": paper_name, + "content": r.get("text", ""), + "score": r.get("score", 0.0), + "header_context": metadata.get("header_path", ""), + "match_type": "rag", + } + if "start_line" in metadata: + result_entry["start_line"] = metadata["start_line"] + if "end_line" in metadata: + result_entry["end_line"] = metadata["end_line"] + results.append(result_entry) + results.sort(key=lambda x: x.get("score", 0), reverse=True) return results[:top_k] diff --git a/paper_intelligence/utils/__init__.py b/paper_intelligence/utils/__init__.py index 9f67495..230b96a 100644 --- a/paper_intelligence/utils/__init__.py +++ b/paper_intelligence/utils/__init__.py @@ -1,4 +1,4 @@ -from .chromadb_client import RAGClient, create_documents_from_markdown +from .vectorize_client import VectorizeClient, create_documents_from_markdown from .markdown_parser import MarkdownParser -__all__ = ["RAGClient", "create_documents_from_markdown", "MarkdownParser"] +__all__ = ["VectorizeClient", "create_documents_from_markdown", "MarkdownParser"] diff --git a/paper_intelligence/utils/vectorize_client.py b/paper_intelligence/utils/vectorize_client.py new file mode 100644 index 0000000..cc0f851 --- /dev/null +++ b/paper_intelligence/utils/vectorize_client.py @@ -0,0 +1,210 @@ +"""RAG client with local HuggingFace embeddings and Cloudflare Vectorize backend.""" + +import json +import os +import subprocess +from pathlib import Path +from typing import Optional + +import requests +from llama_index.core import Document +from llama_index.embeddings.huggingface import HuggingFaceEmbedding + + +def get_device() -> str: + """Get the best available device for embeddings.""" + try: + import torch + if torch.backends.mps.is_available(): + return "mps" + elif torch.cuda.is_available(): + return "cuda" + except ImportError: + pass + return "cpu" + + +WORKER_URL = os.environ.get( + "PAPER_INTELLIGENCE_URL", + "https://paper-intelligence.daiyue0531.workers.dev", +) + +_token_cache: str = "" + + +def _get_token() -> str: + global _token_cache + if _token_cache: + return _token_cache + _token_cache = os.environ.get("PAPER_INTELLIGENCE_TOKEN", "") + if _token_cache: + return _token_cache + try: + _token_cache = subprocess.check_output( + ["op", "read", "op://CLI Secrets/Paper Intelligence Cloud API Token/credential"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except Exception: + pass + return _token_cache + + +def _worker_request(path: str, body: dict) -> dict: + """Make an authenticated POST to the Worker.""" + resp = requests.post( + f"{WORKER_URL}{path}", + json=body, + headers={"Authorization": f"Bearer {_get_token()}"}, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +class VectorizeClient: + """RAG client: local embeddings + Cloudflare Vectorize storage.""" + + DEFAULT_MODEL = "BAAI/bge-small-en-v1.5" + DEFAULT_CHUNK_SIZE = 512 + DEFAULT_CHUNK_OVERLAP = 50 + + def __init__( + self, + paper_name: str, + model_name: Optional[str] = None, + chunk_size: int = DEFAULT_CHUNK_SIZE, + chunk_overlap: int = DEFAULT_CHUNK_OVERLAP, + ): + self.paper_name = paper_name + self.model_name = model_name or self.DEFAULT_MODEL + self.chunk_size = chunk_size + self.chunk_overlap = chunk_overlap + self.device = get_device() + + self.embed_model = HuggingFaceEmbedding( + model_name=self.model_name, + device=self.device, + ) + + def embed_and_store(self, documents: list[Document]) -> int: + """Embed documents locally and store vectors in Vectorize. + + Returns the number of chunks stored. + """ + vectors = [] + for i, doc in enumerate(documents): + text = doc.get_content() + embedding = self.embed_model.get_text_embedding(text) + chunk_index = doc.metadata.get("chunk_index", i) + vectors.append({ + "id": f"{self.paper_name}_{chunk_index}", + "values": embedding, + "metadata": { + "paper_name": self.paper_name, + "text": text[:8000], # Vectorize 10KB metadata limit + "header_path": doc.metadata.get("header_path", ""), + "start_line": doc.metadata.get("start_line", 0), + "end_line": doc.metadata.get("end_line", 0), + "chunk_index": chunk_index, + }, + }) + + if vectors: + _worker_request("/upsert", {"vectors": vectors}) + + return len(vectors) + + def query(self, query_text: str, top_k: int = 5) -> list[dict]: + """Embed query locally, search Vectorize for similar chunks. + + Returns list of {text, score, metadata}. + """ + query_embedding = self.embed_model.get_query_embedding(query_text) + + result = _worker_request("/query", { + "vector": query_embedding, + "top_k": top_k, + "filter": {"paper_name": self.paper_name}, + }) + + return [ + { + "text": m["metadata"].get("text", ""), + "score": m["score"], + "metadata": m["metadata"], + } + for m in result.get("matches", []) + ] + + def delete(self, num_chunks: int) -> bool: + """Delete all vectors for this paper.""" + ids = [f"{self.paper_name}_{i}" for i in range(num_chunks)] + if ids: + _worker_request("/delete", {"ids": ids}) + return True + + +def query_all_papers( + query_text: str, + top_k: int = 5, + model_name: Optional[str] = None, +) -> list[dict]: + """Search across ALL papers in Vectorize (no paper_name filter). + + Used for cross-paper RAG search. + """ + device = get_device() + embed_model = HuggingFaceEmbedding( + model_name=model_name or VectorizeClient.DEFAULT_MODEL, + device=device, + ) + query_embedding = embed_model.get_query_embedding(query_text) + + result = _worker_request("/query", { + "vector": query_embedding, + "top_k": top_k, + }) + + return [ + { + "text": m["metadata"].get("text", ""), + "score": m["score"], + "metadata": m["metadata"], + } + for m in result.get("matches", []) + ] + + +def create_documents_from_markdown( + markdown_path: str | Path, + metadata: Optional[dict] = None, + chunk_size: int = 512, + chunk_overlap: int = 50, +) -> list[Document]: + """Create LlamaIndex Documents from a markdown file with line number metadata. + + Pre-chunks the markdown using header-aware chunking to preserve line numbers. + """ + from .markdown_parser import MarkdownParser + + path = Path(markdown_path) + parser = MarkdownParser.from_file(path) + chunks = parser.chunk_text(chunk_size=chunk_size, chunk_overlap=chunk_overlap) + + documents = [] + for chunk in chunks: + doc_metadata = { + "source": str(path), + "filename": path.name, + "start_line": chunk["start_line"], + "end_line": chunk["end_line"], + "header_path": chunk.get("header_path", ""), + } + if "chunk_index" in chunk: + doc_metadata["chunk_index"] = chunk["chunk_index"] + if metadata: + doc_metadata.update(metadata) + documents.append(Document(text=chunk["text"], metadata=doc_metadata)) + + return documents diff --git a/pyproject.toml b/pyproject.toml index f6d0adc..ac9540e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,11 +25,10 @@ classifiers = [ dependencies = [ "mcp[cli]>=1.0.0", "marker-pdf>=1.0.0", - "chromadb>=0.4.0", "llama-index-core>=0.10.0", "llama-index-embeddings-huggingface>=0.2.0", - "llama-index-vector-stores-chroma>=0.1.0", "pydantic>=2.0.0", + "requests>=2.28.0", "torch>=2.0.0", ] diff --git a/worker/package-lock.json b/worker/package-lock.json new file mode 100644 index 0000000..337bbdc --- /dev/null +++ b/worker/package-lock.json @@ -0,0 +1,1530 @@ +{ + "name": "paper-intelligence-worker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "paper-intelligence-worker", + "version": "0.1.0", + "devDependencies": { + "@cloudflare/workers-types": "^4.20250327.0", + "typescript": "^5.7.0", + "wrangler": "^4.0.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz", + "integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260410.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260410.1.tgz", + "integrity": "sha512-0sh6xPmCKUfv/lUklP1dfyeKxCuEZGS0HeduxnucL8ECxSgAdWTOD42h/lQTwZCIiWtyHB+ZNB9hsS2Mlf0tMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260410.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260410.1.tgz", + "integrity": "sha512-r2On29gPvlk/eiH/OpeUT23xoB8W8D1PHr8lul5nyxElLqvh3yNxZUnJWrbcOl+ubfrvw7+jFwgopMe17xyf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260410.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260410.1.tgz", + "integrity": "sha512-qWORRcAzPZeHJjrcYBNZTN6Y9l+iZQUz4KBdWbNrM6My4CpNrXS5kErPR373vG//5QPaDGwMXgBqyn9xfzarJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260410.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260410.1.tgz", + "integrity": "sha512-jQfuHL4mnGDFyomSS3JNs9TpTvCu6Vzz2QSNCfJRstMzTICUFLMc4Vp/xKK+M5xkb0PoAu/G0hHx7jrxB2j+OQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260410.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260410.1.tgz", + "integrity": "sha512-h8q/nbheDqpknY7AAOz19MuQkZAR1/bnoZnKipyeUPXt5No+y6HlTtva9Bohx5Fhc1MW2CX2MQVdb55qtkkqZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260414.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260414.1.tgz", + "integrity": "sha512-E2wgYT1ywoM1M68nmVpxKdKzXsZm5vOu2plsqUixlK7YIydqsw31dZ+EjwXnAsdEjLaYC6XfsJayil8AEhyaBQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peer": true + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260410.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260410.0.tgz", + "integrity": "sha512-94LEU8d+XPVGp18eW4+bu1v7Tnq7srhqWMIsrx2jhSkdbTnGqg1I613R0GKY4eygBYl9MbqXEhzK/bczJb6uMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.4", + "workerd": "1.20260410.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260410.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260410.1.tgz", + "integrity": "sha512-T/GRD6Y5vN9g4CnGmOlfST1w7bj+1IjRFvX0K7CodZPJuPVPNPGhz8Wppah0WdT6A7I8Kad3zgZ2OkDdWtENrg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260410.1", + "@cloudflare/workerd-darwin-arm64": "1.20260410.1", + "@cloudflare/workerd-linux-64": "1.20260410.1", + "@cloudflare/workerd-linux-arm64": "1.20260410.1", + "@cloudflare/workerd-windows-64": "1.20260410.1" + } + }, + "node_modules/wrangler": { + "version": "4.82.2", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.82.2.tgz", + "integrity": "sha512-SKfW21sTJUkM/Qd8zc9oc8TBkAWHRsXuTxE6XdToC55Ct84pR+IfRdaTjCTuC0dL+KYvauSvSn2rtqS2Ae+Dcw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.16.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260410.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260410.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.3.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260410.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 0000000..b6ba05f --- /dev/null +++ b/worker/package.json @@ -0,0 +1,14 @@ +{ + "name": "paper-intelligence-worker", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250327.0", + "wrangler": "^4.0.0", + "typescript": "^5.7.0" + } +} diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 0000000..5913ea2 --- /dev/null +++ b/worker/src/index.ts @@ -0,0 +1,89 @@ +/** + * Paper Intelligence Worker — thin Vectorize proxy. + * + * POST /upsert — store vectors with metadata + * POST /query — similarity search + * POST /delete — delete vectors by paper ID prefix + */ + +interface Env { + VECTORIZE: VectorizeIndex; + API_TOKEN: string; +} + +export default { + async fetch(request: Request, env: Env): Promise { + // Auth + if (request.method === "OPTIONS") { + return new Response(null, { status: 204 }); + } + const auth = request.headers.get("Authorization"); + if (auth !== `Bearer ${env.API_TOKEN}`) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + + if (url.pathname === "/upsert" && request.method === "POST") { + return handleUpsert(request, env); + } + if (url.pathname === "/query" && request.method === "POST") { + return handleQuery(request, env); + } + if (url.pathname === "/delete" && request.method === "POST") { + return handleDelete(request, env); + } + + return Response.json({ error: "Not found" }, { status: 404 }); + }, +} satisfies ExportedHandler; + +async function handleUpsert(request: Request, env: Env): Promise { + const body = (await request.json()) as { + vectors: { + id: string; + values: number[]; + metadata?: Record; + }[]; + }; + + // Vectorize upsert batch limit is 100 + for (let i = 0; i < body.vectors.length; i += 100) { + await env.VECTORIZE.upsert(body.vectors.slice(i, i + 100)); + } + + return Response.json({ success: true, count: body.vectors.length }); +} + +async function handleQuery(request: Request, env: Env): Promise { + const body = (await request.json()) as { + vector: number[]; + top_k?: number; + filter?: Record; + }; + + const result = await env.VECTORIZE.query(body.vector, { + topK: body.top_k || 5, + filter: body.filter, + returnMetadata: "all", + }); + + return Response.json({ + matches: result.matches.map((m) => ({ + id: m.id, + score: m.score, + metadata: m.metadata, + })), + }); +} + +async function handleDelete(request: Request, env: Env): Promise { + const body = (await request.json()) as { ids: string[] }; + + // Vectorize deleteByIds limit is 100 + for (let i = 0; i < body.ids.length; i += 100) { + await env.VECTORIZE.deleteByIds(body.ids.slice(i, i + 100)); + } + + return Response.json({ success: true, deleted: body.ids.length }); +} diff --git a/worker/tsconfig.json b/worker/tsconfig.json new file mode 100644 index 0000000..e869836 --- /dev/null +++ b/worker/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/worker/wrangler.jsonc b/worker/wrangler.jsonc new file mode 100644 index 0000000..7ce3b94 --- /dev/null +++ b/worker/wrangler.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "paper-intelligence", + "main": "src/index.ts", + "compatibility_date": "2026-04-14", + + // Clean up old DOs from previous cloud-mcp-server branch + "migrations": [ + { "new_sqlite_classes": ["PaperIntelligenceMCP"], "tag": "v1" }, + { "new_sqlite_classes": ["MarkerContainer"], "tag": "v2" }, + { "deleted_classes": ["MarkerContainer"], "tag": "v3" }, + { "new_sqlite_classes": ["MarkerContainer"], "tag": "v4" }, + { "deleted_classes": ["PaperIntelligenceMCP"], "tag": "v5" }, + { "deleted_classes": ["MarkerContainer"], "tag": "v6" } + ], + + "vectorize": [ + { + "binding": "VECTORIZE", + "index_name": "paper-intelligence" + } + ], + + "observability": { "enabled": true } +} From e95498adeedc801317775339b925cd378cc7d73b Mon Sep 17 00:00:00 2001 From: Oded Falik Date: Tue, 14 Apr 2026 17:46:42 -0700 Subject: [PATCH 2/7] Fix vector ID length: use sha256 hash prefix for Vectorize 64-byte limit --- paper_intelligence/utils/vectorize_client.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/paper_intelligence/utils/vectorize_client.py b/paper_intelligence/utils/vectorize_client.py index cc0f851..4a7a451 100644 --- a/paper_intelligence/utils/vectorize_client.py +++ b/paper_intelligence/utils/vectorize_client.py @@ -1,5 +1,6 @@ """RAG client with local HuggingFace embeddings and Cloudflare Vectorize backend.""" +import hashlib import json import os import subprocess @@ -87,6 +88,13 @@ def __init__( device=self.device, ) + @staticmethod + def _vector_id(paper_name: str, chunk_index: int) -> str: + """Generate a vector ID that fits Vectorize's 64-byte limit.""" + # Use a short hash of the paper name + chunk index + h = hashlib.sha256(paper_name.encode()).hexdigest()[:16] + return f"{h}_{chunk_index}" + def embed_and_store(self, documents: list[Document]) -> int: """Embed documents locally and store vectors in Vectorize. @@ -98,7 +106,7 @@ def embed_and_store(self, documents: list[Document]) -> int: embedding = self.embed_model.get_text_embedding(text) chunk_index = doc.metadata.get("chunk_index", i) vectors.append({ - "id": f"{self.paper_name}_{chunk_index}", + "id": self._vector_id(self.paper_name, chunk_index), "values": embedding, "metadata": { "paper_name": self.paper_name, @@ -139,7 +147,7 @@ def query(self, query_text: str, top_k: int = 5) -> list[dict]: def delete(self, num_chunks: int) -> bool: """Delete all vectors for this paper.""" - ids = [f"{self.paper_name}_{i}" for i in range(num_chunks)] + ids = [self._vector_id(self.paper_name, i) for i in range(num_chunks)] if ids: _worker_request("/delete", {"ids": ids}) return True From 74710eb9c3f3e8b8f082649ebf46f494f5eed8be Mon Sep 17 00:00:00 2001 From: Oded Falik Date: Tue, 14 Apr 2026 18:03:26 -0700 Subject: [PATCH 3/7] =?UTF-8?q?Add=20PDF=20hash=20dedup=20=E2=80=94=20skip?= =?UTF-8?q?=20processing=20if=20same=20content=20already=20exists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- paper_intelligence/tools/convert.py | 45 ++++++++++++++++++++++++++++- paper_intelligence/tools/search.py | 8 ++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/paper_intelligence/tools/convert.py b/paper_intelligence/tools/convert.py index 01b4fa9..e511cb6 100644 --- a/paper_intelligence/tools/convert.py +++ b/paper_intelligence/tools/convert.py @@ -1,5 +1,6 @@ """PDF to Markdown conversion tool using Marker.""" +import hashlib import os from pathlib import Path from typing import Optional @@ -16,6 +17,47 @@ pass +def pdf_hash(pdf_path: Path) -> str: + """Compute SHA-256 hash of a PDF file.""" + h = hashlib.sha256() + with open(pdf_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +def find_duplicate(pdf_path: str | Path, papers_dir: str | Path | None = None) -> Optional[str]: + """Check if a PDF already exists in the papers library by content hash. + + Args: + pdf_path: Path to the PDF to check + papers_dir: Directory containing paper subdirectories (default: parent of pdf_path) + + Returns: + Name of the existing paper if duplicate found, None otherwise + """ + from ..metadata import read_metadata + + pdf_path = Path(pdf_path).expanduser().resolve() + if not pdf_path.exists(): + return None + + new_hash = pdf_hash(pdf_path) + + if papers_dir is None: + papers_dir = pdf_path.parent + papers_dir = Path(papers_dir).expanduser().resolve() + + for paper_dir in papers_dir.iterdir(): + if not paper_dir.is_dir(): + continue + meta = read_metadata(paper_dir) + if meta and meta.get("pdf_hash") == new_hash: + return paper_dir.name + + return None + + def get_output_dir(pdf_path: Path) -> Path: """Generate output directory for a PDF file. @@ -114,11 +156,12 @@ def convert_pdf( from ..metadata import write_metadata image_count = len(images) if images else 0 + content_hash = pdf_hash(pdf_path) write_metadata( paper_dir=out_dir, source_pdf=pdf_path.name, steps_completed=["convert"], - extra={"image_count": image_count}, + extra={"image_count": image_count, "pdf_hash": content_hash}, ) return { diff --git a/paper_intelligence/tools/search.py b/paper_intelligence/tools/search.py index 8c49a17..d7d2380 100644 --- a/paper_intelligence/tools/search.py +++ b/paper_intelligence/tools/search.py @@ -27,12 +27,18 @@ def _process_paper_if_needed( Returns: Tuple of (paper_dir, error_message). If error, paper_dir is None. """ - from .convert import convert_pdf, get_output_dir + from .convert import convert_pdf, find_duplicate, get_output_dir from .embed import embed_document from .index import index_markdown # Handle PDF files if path.is_file() and path.suffix.lower() == ".pdf": + # Check for duplicate by content hash + dup = find_duplicate(path, papers_dir=path.parent) + if dup: + dup_dir = path.parent / dup + return dup_dir, None + paper_dir = get_output_dir(path) # Check if already processed and compatible From fa05bb438941b3ba29a8c03477bfba81987d10d8 Mon Sep 17 00:00:00 2001 From: Oded Falik Date: Tue, 14 Apr 2026 18:41:50 -0700 Subject: [PATCH 4/7] Revert to ChromaDB backend, add rclone R2 sync for shared access - Restore ChromaDB as the vector store (revert Vectorize experiment) - Add sync.py: rclone-based R2 sync (pull before search, push after process) - Keep PDF hash dedup from earlier work - Architecture: fully local MCP server + R2 as shared file store via rclone - No Worker, no Vectorize, no remote MCP - Both users mount same R2 bucket via rclone - ChromaDB indexes sync alongside papers --- paper_intelligence/server.py | 15 +++++- paper_intelligence/sync.py | 56 +++++++++++++++++++++ paper_intelligence/tools/embed.py | 75 ++++++++++++++++++++++------ paper_intelligence/tools/search.py | 65 ++++++++++++------------ paper_intelligence/utils/__init__.py | 4 +- pyproject.toml | 3 +- 6 files changed, 167 insertions(+), 51 deletions(-) create mode 100644 paper_intelligence/sync.py diff --git a/paper_intelligence/server.py b/paper_intelligence/server.py index 5ea7f30..2dbb188 100644 --- a/paper_intelligence/server.py +++ b/paper_intelligence/server.py @@ -3,6 +3,8 @@ Provides AI agents with efficient, searchable access to PDF documents. PDFs are automatically processed on first search (1-3 minutes), then all subsequent searches are instant. + +Papers are synced to/from Cloudflare R2 via rclone for shared access. """ from typing import Literal @@ -50,9 +52,13 @@ def search( Returns: Search results with content, location, and relevance scores """ + from .sync import pull from .tools.search import search as _search - return _search( + # Pull latest from R2 before searching + pull() + + result = _search( query=query, sources=sources, mode=mode, @@ -63,6 +69,13 @@ def search( use_llm=use_llm, ) + # If any papers were auto-processed, push to R2 + if result.get("processing_notes"): + from .sync import push + push() + + return result + @mcp.tool() def get_paper_info(paper_dir: str) -> dict: diff --git a/paper_intelligence/sync.py b/paper_intelligence/sync.py new file mode 100644 index 0000000..a2cc497 --- /dev/null +++ b/paper_intelligence/sync.py @@ -0,0 +1,56 @@ +"""R2 sync via rclone for shared paper storage.""" + +import logging +import subprocess + +logger = logging.getLogger(__name__) + +RCLONE_REMOTE = "r2:paper-intelligence" +LOCAL_DIR = "~/Documents/papers" + + +def _run_rclone(args: list[str]) -> bool: + """Run an rclone command. Returns True on success.""" + cmd = ["rclone"] + args + try: + subprocess.run( + cmd, + check=True, + capture_output=True, + text=True, + timeout=300, + ) + return True + except FileNotFoundError: + logger.warning("rclone not installed — skipping sync") + return False + except subprocess.CalledProcessError as e: + logger.warning(f"rclone failed: {e.stderr.strip()}") + return False + except subprocess.TimeoutExpired: + logger.warning("rclone timed out") + return False + + +def pull(): + """Pull latest papers from R2 (additive only — won't delete local files).""" + return _run_rclone([ + "copy", RCLONE_REMOTE, LOCAL_DIR, + "--transfers", "8", + "--checkers", "16", + ]) + + +def push(): + """Push local papers to R2 (additive only — won't delete remote files).""" + return _run_rclone([ + "copy", LOCAL_DIR, RCLONE_REMOTE, + "--transfers", "8", + "--checkers", "16", + ]) + + +def sync_bidirectional(): + """Bidirectional sync — both sides get all files, nothing deleted.""" + pull() + push() diff --git a/paper_intelligence/tools/embed.py b/paper_intelligence/tools/embed.py index 70b4f62..d3767e8 100644 --- a/paper_intelligence/tools/embed.py +++ b/paper_intelligence/tools/embed.py @@ -1,8 +1,12 @@ -"""Document embedding tool using local HuggingFace model + Cloudflare Vectorize.""" +"""Document embedding tool using LlamaIndex with ChromaDB. + +Embeddings are stored in each paper's directory for self-containment. +""" from pathlib import Path +from typing import Optional -from ..utils.vectorize_client import VectorizeClient, create_documents_from_markdown +from ..utils.chromadb_client import RAGClient, create_documents_from_markdown def embed_document( @@ -10,7 +14,10 @@ def embed_document( chunk_size: int = 512, chunk_overlap: int = 50, ) -> dict: - """Embed a markdown document locally and store vectors in Cloudflare Vectorize. + """Create embeddings for a markdown document and store in local ChromaDB. + + Embeddings are stored in the same directory as the markdown file + under a 'chroma/' subdirectory for self-containment. Args: markdown_path: Path to the markdown file @@ -18,46 +25,66 @@ def embed_document( chunk_overlap: Overlap between chunks Returns: - Dictionary with num_chunks, success, message, device + Dictionary with: + - db_path: Path to the ChromaDB storage + - num_chunks: Number of chunks embedded + - success: Boolean + - message: Status message """ md_path = Path(markdown_path).expanduser().resolve() if not md_path.exists(): return { + "db_path": None, "num_chunks": 0, "success": False, "message": f"Markdown file not found: {markdown_path}", } - paper_name = md_path.parent.name + # Store embeddings in the paper's directory + paper_dir = md_path.parent + chroma_dir = paper_dir / "chroma" try: - client = VectorizeClient( - paper_name=paper_name, + # Create RAG client with paper-local storage + rag_client = RAGClient( + persist_directory=chroma_dir, chunk_size=chunk_size, chunk_overlap=chunk_overlap, ) + # Create pre-chunked documents with line number metadata documents = create_documents_from_markdown( md_path, chunk_size=chunk_size, chunk_overlap=chunk_overlap, ) - num_chunks = client.embed_and_store(documents) + # Use a fixed collection name since each paper has its own DB + collection_name = "paper" + + # Create index (skip chunking since documents are pre-chunked with line metadata) + rag_client.create_index(collection_name, documents, pre_chunked=True) + + # Get chunk count + num_chunks = rag_client.get_collection_count(collection_name) + # Update metadata from ..metadata import update_metadata_steps - update_metadata_steps(md_path.parent, "embed") + + update_metadata_steps(paper_dir, "embed") return { + "db_path": str(chroma_dir), "num_chunks": num_chunks, "success": True, - "message": f"Embedded {num_chunks} chunks (device={client.device})", - "device": client.device, + "message": f"Successfully embedded document into {num_chunks} chunks", + "device": rag_client.device, } except Exception as e: return { + "db_path": None, "num_chunks": 0, "success": False, "message": f"Embedding failed: {str(e)}", @@ -69,12 +96,30 @@ def query_paper( query: str, top_k: int = 5, ) -> dict: - """Query a paper's embeddings via Vectorize.""" - paper_name = Path(paper_dir).expanduser().resolve().name + """Query a paper's embeddings for similar content. + + Args: + paper_dir: Path to the paper's directory (containing chroma/) + query: Search query + top_k: Number of results to return + + Returns: + Dictionary with search results + """ + paper_path = Path(paper_dir).expanduser().resolve() + chroma_dir = paper_path / "chroma" + + if not chroma_dir.exists(): + return { + "results": [], + "num_results": 0, + "success": False, + "message": f"No embeddings found in {paper_dir}", + } try: - client = VectorizeClient(paper_name=paper_name) - results = client.query(query, top_k) + rag_client = RAGClient(persist_directory=chroma_dir) + results = rag_client.query("paper", query, top_k) return { "results": results, diff --git a/paper_intelligence/tools/search.py b/paper_intelligence/tools/search.py index d7d2380..793e320 100644 --- a/paper_intelligence/tools/search.py +++ b/paper_intelligence/tools/search.py @@ -10,7 +10,7 @@ from typing import Literal, Optional from ..metadata import check_version_compatibility -from ..utils.vectorize_client import query_all_papers +from ..utils.chromadb_client import RAGClient from ..utils.markdown_parser import MarkdownParser @@ -36,8 +36,7 @@ def _process_paper_if_needed( # Check for duplicate by content hash dup = find_duplicate(path, papers_dir=path.parent) if dup: - dup_dir = path.parent / dup - return dup_dir, None + return path.parent / dup, None paper_dir = get_output_dir(path) @@ -277,48 +276,50 @@ def rag_search( paper_dirs: list[Path], top_k: int = 5, ) -> list[dict]: - """Perform semantic RAG search via Cloudflare Vectorize. - - Searches across all papers in the shared vector index. - paper_dirs is used to scope results to the requested papers. + """Perform semantic RAG search using embeddings. Args: query: Search query paper_dirs: List of paper directories to search - top_k: Number of results + top_k: Number of results per paper Returns: List of matches with content, score, and metadata """ - paper_names = {d.name for d in paper_dirs} - - try: - raw_results = query_all_papers(query, top_k=top_k * 2) - except Exception: - return [] - results = [] - for r in raw_results: - metadata = r.get("metadata", {}) - paper_name = metadata.get("paper_name", "") - # Scope to requested papers - if paper_names and paper_name not in paper_names: + for paper_dir in paper_dirs: + chroma_dir = paper_dir / "chroma" + + if not chroma_dir.exists(): continue - result_entry = { - "paper_name": paper_name, - "content": r.get("text", ""), - "score": r.get("score", 0.0), - "header_context": metadata.get("header_path", ""), - "match_type": "rag", - } - if "start_line" in metadata: - result_entry["start_line"] = metadata["start_line"] - if "end_line" in metadata: - result_entry["end_line"] = metadata["end_line"] - results.append(result_entry) + try: + rag_client = RAGClient(persist_directory=chroma_dir) + raw_results = rag_client.query("paper", query, top_k) + + for r in raw_results: + metadata = r.get("metadata", {}) + result_entry = { + "paper_dir": str(paper_dir), + "paper_name": paper_dir.name, + "source": metadata.get("source", str(paper_dir / "paper.md")), + "content": r.get("text", ""), + "score": r.get("score", 0.0), + "header_context": metadata.get("header_path", ""), + "match_type": "rag", + } + # Include line numbers if available (from pre-chunked documents) + if "start_line" in metadata: + result_entry["start_line"] = metadata["start_line"] + if "end_line" in metadata: + result_entry["end_line"] = metadata["end_line"] + results.append(result_entry) + + except Exception: + continue + # Sort by score results.sort(key=lambda x: x.get("score", 0), reverse=True) return results[:top_k] diff --git a/paper_intelligence/utils/__init__.py b/paper_intelligence/utils/__init__.py index 230b96a..9f67495 100644 --- a/paper_intelligence/utils/__init__.py +++ b/paper_intelligence/utils/__init__.py @@ -1,4 +1,4 @@ -from .vectorize_client import VectorizeClient, create_documents_from_markdown +from .chromadb_client import RAGClient, create_documents_from_markdown from .markdown_parser import MarkdownParser -__all__ = ["VectorizeClient", "create_documents_from_markdown", "MarkdownParser"] +__all__ = ["RAGClient", "create_documents_from_markdown", "MarkdownParser"] diff --git a/pyproject.toml b/pyproject.toml index ac9540e..f6d0adc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,10 +25,11 @@ classifiers = [ dependencies = [ "mcp[cli]>=1.0.0", "marker-pdf>=1.0.0", + "chromadb>=0.4.0", "llama-index-core>=0.10.0", "llama-index-embeddings-huggingface>=0.2.0", + "llama-index-vector-stores-chroma>=0.1.0", "pydantic>=2.0.0", - "requests>=2.28.0", "torch>=2.0.0", ] From 0fac62c8418d8a30f9375a06b30cb7f5794975b3 Mon Sep 17 00:00:00 2001 From: Oded Falik Date: Tue, 14 Apr 2026 19:32:44 -0700 Subject: [PATCH 5/7] Auto-setup rclone: install via brew + configure from 1Password on first use --- paper_intelligence/sync.py | 87 +++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 7 deletions(-) diff --git a/paper_intelligence/sync.py b/paper_intelligence/sync.py index a2cc497..938a75c 100644 --- a/paper_intelligence/sync.py +++ b/paper_intelligence/sync.py @@ -1,12 +1,87 @@ -"""R2 sync via rclone for shared paper storage.""" +"""R2 sync via rclone for shared paper storage. + +Auto-configures rclone on first use from 1Password credentials. +Auto-installs rclone via brew if not found. +""" import logging +import os +import shutil import subprocess +from pathlib import Path logger = logging.getLogger(__name__) RCLONE_REMOTE = "r2:paper-intelligence" LOCAL_DIR = "~/Documents/papers" +RCLONE_CONF = Path.home() / ".config" / "rclone" / "rclone.conf" + +# 1Password paths for R2 credentials +OP_ITEM = "R2 Paper Intelligence" +OP_VAULT = "CLI Secrets" + + +def _ensure_rclone_installed() -> bool: + """Install rclone via brew if not found.""" + if shutil.which("rclone"): + return True + logger.info("Installing rclone...") + try: + subprocess.run( + ["brew", "install", "rclone"], + check=True, capture_output=True, timeout=120, + ) + return True + except Exception: + logger.warning("Could not install rclone — install manually: brew install rclone") + return False + + +def _ensure_rclone_configured() -> bool: + """Configure rclone for R2 from 1Password if not already set up.""" + if RCLONE_CONF.exists() and "r2" in RCLONE_CONF.read_text(): + return True + + logger.info("Configuring rclone for R2 from 1Password...") + try: + access_key = subprocess.check_output( + ["op", "read", f"op://{OP_VAULT}/{OP_ITEM}/Access Key ID"], + text=True, stderr=subprocess.DEVNULL, + ).strip() + secret_key = subprocess.check_output( + ["op", "read", f"op://{OP_VAULT}/{OP_ITEM}/Secret Access Key"], + text=True, stderr=subprocess.DEVNULL, + ).strip() + endpoint = subprocess.check_output( + ["op", "read", f"op://{OP_VAULT}/{OP_ITEM}/Endpoint"], + text=True, stderr=subprocess.DEVNULL, + ).strip() + except Exception: + logger.warning("Could not read R2 credentials from 1Password — skipping sync") + return False + + RCLONE_CONF.parent.mkdir(parents=True, exist_ok=True) + + # Append r2 config (preserve existing config if any) + existing = RCLONE_CONF.read_text() if RCLONE_CONF.exists() else "" + if "[r2]" not in existing: + with open(RCLONE_CONF, "a") as f: + f.write(f""" +[r2] +type = s3 +provider = Cloudflare +access_key_id = {access_key} +secret_access_key = {secret_key} +endpoint = {endpoint} +acl = private +no_check_bucket = true +""") + return True + + +def _ensure_setup() -> bool: + """Ensure rclone is installed and configured. Returns True if ready.""" + return _ensure_rclone_installed() and _ensure_rclone_configured() def _run_rclone(args: list[str]) -> bool: @@ -34,6 +109,8 @@ def _run_rclone(args: list[str]) -> bool: def pull(): """Pull latest papers from R2 (additive only — won't delete local files).""" + if not _ensure_setup(): + return False return _run_rclone([ "copy", RCLONE_REMOTE, LOCAL_DIR, "--transfers", "8", @@ -43,14 +120,10 @@ def pull(): def push(): """Push local papers to R2 (additive only — won't delete remote files).""" + if not _ensure_setup(): + return False return _run_rclone([ "copy", LOCAL_DIR, RCLONE_REMOTE, "--transfers", "8", "--checkers", "16", ]) - - -def sync_bidirectional(): - """Bidirectional sync — both sides get all files, nothing deleted.""" - pull() - push() From 7fdeb1cfc1e3dd64ee2c80066f98c8f571bfe74d Mon Sep 17 00:00:00 2001 From: Oded Falik Date: Tue, 14 Apr 2026 19:34:17 -0700 Subject: [PATCH 6/7] Bump to v0.4.0, remove dead vectorize_client.py --- paper_intelligence/utils/vectorize_client.py | 218 ------------------- pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 219 deletions(-) delete mode 100644 paper_intelligence/utils/vectorize_client.py diff --git a/paper_intelligence/utils/vectorize_client.py b/paper_intelligence/utils/vectorize_client.py deleted file mode 100644 index 4a7a451..0000000 --- a/paper_intelligence/utils/vectorize_client.py +++ /dev/null @@ -1,218 +0,0 @@ -"""RAG client with local HuggingFace embeddings and Cloudflare Vectorize backend.""" - -import hashlib -import json -import os -import subprocess -from pathlib import Path -from typing import Optional - -import requests -from llama_index.core import Document -from llama_index.embeddings.huggingface import HuggingFaceEmbedding - - -def get_device() -> str: - """Get the best available device for embeddings.""" - try: - import torch - if torch.backends.mps.is_available(): - return "mps" - elif torch.cuda.is_available(): - return "cuda" - except ImportError: - pass - return "cpu" - - -WORKER_URL = os.environ.get( - "PAPER_INTELLIGENCE_URL", - "https://paper-intelligence.daiyue0531.workers.dev", -) - -_token_cache: str = "" - - -def _get_token() -> str: - global _token_cache - if _token_cache: - return _token_cache - _token_cache = os.environ.get("PAPER_INTELLIGENCE_TOKEN", "") - if _token_cache: - return _token_cache - try: - _token_cache = subprocess.check_output( - ["op", "read", "op://CLI Secrets/Paper Intelligence Cloud API Token/credential"], - text=True, - stderr=subprocess.DEVNULL, - ).strip() - except Exception: - pass - return _token_cache - - -def _worker_request(path: str, body: dict) -> dict: - """Make an authenticated POST to the Worker.""" - resp = requests.post( - f"{WORKER_URL}{path}", - json=body, - headers={"Authorization": f"Bearer {_get_token()}"}, - timeout=30, - ) - resp.raise_for_status() - return resp.json() - - -class VectorizeClient: - """RAG client: local embeddings + Cloudflare Vectorize storage.""" - - DEFAULT_MODEL = "BAAI/bge-small-en-v1.5" - DEFAULT_CHUNK_SIZE = 512 - DEFAULT_CHUNK_OVERLAP = 50 - - def __init__( - self, - paper_name: str, - model_name: Optional[str] = None, - chunk_size: int = DEFAULT_CHUNK_SIZE, - chunk_overlap: int = DEFAULT_CHUNK_OVERLAP, - ): - self.paper_name = paper_name - self.model_name = model_name or self.DEFAULT_MODEL - self.chunk_size = chunk_size - self.chunk_overlap = chunk_overlap - self.device = get_device() - - self.embed_model = HuggingFaceEmbedding( - model_name=self.model_name, - device=self.device, - ) - - @staticmethod - def _vector_id(paper_name: str, chunk_index: int) -> str: - """Generate a vector ID that fits Vectorize's 64-byte limit.""" - # Use a short hash of the paper name + chunk index - h = hashlib.sha256(paper_name.encode()).hexdigest()[:16] - return f"{h}_{chunk_index}" - - def embed_and_store(self, documents: list[Document]) -> int: - """Embed documents locally and store vectors in Vectorize. - - Returns the number of chunks stored. - """ - vectors = [] - for i, doc in enumerate(documents): - text = doc.get_content() - embedding = self.embed_model.get_text_embedding(text) - chunk_index = doc.metadata.get("chunk_index", i) - vectors.append({ - "id": self._vector_id(self.paper_name, chunk_index), - "values": embedding, - "metadata": { - "paper_name": self.paper_name, - "text": text[:8000], # Vectorize 10KB metadata limit - "header_path": doc.metadata.get("header_path", ""), - "start_line": doc.metadata.get("start_line", 0), - "end_line": doc.metadata.get("end_line", 0), - "chunk_index": chunk_index, - }, - }) - - if vectors: - _worker_request("/upsert", {"vectors": vectors}) - - return len(vectors) - - def query(self, query_text: str, top_k: int = 5) -> list[dict]: - """Embed query locally, search Vectorize for similar chunks. - - Returns list of {text, score, metadata}. - """ - query_embedding = self.embed_model.get_query_embedding(query_text) - - result = _worker_request("/query", { - "vector": query_embedding, - "top_k": top_k, - "filter": {"paper_name": self.paper_name}, - }) - - return [ - { - "text": m["metadata"].get("text", ""), - "score": m["score"], - "metadata": m["metadata"], - } - for m in result.get("matches", []) - ] - - def delete(self, num_chunks: int) -> bool: - """Delete all vectors for this paper.""" - ids = [self._vector_id(self.paper_name, i) for i in range(num_chunks)] - if ids: - _worker_request("/delete", {"ids": ids}) - return True - - -def query_all_papers( - query_text: str, - top_k: int = 5, - model_name: Optional[str] = None, -) -> list[dict]: - """Search across ALL papers in Vectorize (no paper_name filter). - - Used for cross-paper RAG search. - """ - device = get_device() - embed_model = HuggingFaceEmbedding( - model_name=model_name or VectorizeClient.DEFAULT_MODEL, - device=device, - ) - query_embedding = embed_model.get_query_embedding(query_text) - - result = _worker_request("/query", { - "vector": query_embedding, - "top_k": top_k, - }) - - return [ - { - "text": m["metadata"].get("text", ""), - "score": m["score"], - "metadata": m["metadata"], - } - for m in result.get("matches", []) - ] - - -def create_documents_from_markdown( - markdown_path: str | Path, - metadata: Optional[dict] = None, - chunk_size: int = 512, - chunk_overlap: int = 50, -) -> list[Document]: - """Create LlamaIndex Documents from a markdown file with line number metadata. - - Pre-chunks the markdown using header-aware chunking to preserve line numbers. - """ - from .markdown_parser import MarkdownParser - - path = Path(markdown_path) - parser = MarkdownParser.from_file(path) - chunks = parser.chunk_text(chunk_size=chunk_size, chunk_overlap=chunk_overlap) - - documents = [] - for chunk in chunks: - doc_metadata = { - "source": str(path), - "filename": path.name, - "start_line": chunk["start_line"], - "end_line": chunk["end_line"], - "header_path": chunk.get("header_path", ""), - } - if "chunk_index" in chunk: - doc_metadata["chunk_index"] = chunk["chunk_index"] - if metadata: - doc_metadata.update(metadata) - documents.append(Document(text=chunk["text"], metadata=doc_metadata)) - - return documents diff --git a/pyproject.toml b/pyproject.toml index f6d0adc..4e03f92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paper-intelligence" -version = "0.3.0" +version = "0.4.0" description = "MCP server enabling AI agents to efficiently search and understand PDF documents" readme = "README.md" requires-python = ">=3.10" From 05d490f6a6c3e2ad63d8dbef611e1765419f0717 Mon Sep 17 00:00:00 2001 From: Oded Falik Date: Wed, 6 May 2026 12:45:54 -0700 Subject: [PATCH 7/7] Fix sync.py: expand ~ in LOCAL_DIR before passing to rclone LOCAL_DIR was a literal "~/Documents/papers" string passed to rclone via subprocess (no shell). rclone doesn't expand ~ on its own and treats it as a literal dirname relative to cwd. When the MCP server ran from /Users/oded, pull() materialized everything at /Users/oded/~/Documents/papers/ (1.5 GB stray tree). push() had the same flaw in reverse. Bump version to 0.4.1. --- paper_intelligence/sync.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/paper_intelligence/sync.py b/paper_intelligence/sync.py index 938a75c..58a39f1 100644 --- a/paper_intelligence/sync.py +++ b/paper_intelligence/sync.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) RCLONE_REMOTE = "r2:paper-intelligence" -LOCAL_DIR = "~/Documents/papers" +LOCAL_DIR = str(Path("~/Documents/papers").expanduser()) RCLONE_CONF = Path.home() / ".config" / "rclone" / "rclone.conf" # 1Password paths for R2 credentials diff --git a/pyproject.toml b/pyproject.toml index 4e03f92..2a9787d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paper-intelligence" -version = "0.4.0" +version = "0.4.1" description = "MCP server enabling AI agents to efficiently search and understand PDF documents" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 4a77061..fa480ae 100644 --- a/uv.lock +++ b/uv.lock @@ -2875,7 +2875,7 @@ wheels = [ [[package]] name = "paper-intelligence" -version = "0.2.0" +version = "0.4.1" source = { editable = "." } dependencies = [ { name = "chromadb" },