diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 8d8a2cf..7d90280 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -26,7 +26,7 @@ The MCP server is built with TypeScript and communicates over stdio using the Mo - `static-analysis.ts` - Native linter runner (tsc, eslint, py_compile, cargo check, go vet). - `propose-commit.ts` - Code gatekeeper validating headers, FEATURE tag, no inline comments, nesting, file length. - `feature-hub.ts` - Obsidian-style feature hub navigator with bundled skeleton views. -- `memory-tools.ts` - Memory graph MCP wrappers (upsert, relate, search, prune, interlink, traverse). +- `memory-tools.ts` - Memory graph MCP wrappers (upsert, relate, search, prune, interlink, traverse). The long-term memory graph architecture is also adapted by the complementary [pmll-memory-mcp](https://www.npmjs.com/package/pmll-memory-mcp) server (`npx pmll-memory-mcp`), which adds short-term KV memory and a solution engine — see [drQedwards/PPM](https://github.com/drQedwards/PPM). The memory graph is a **Retrieval-Augmented Generation (RAG)** system. Agents MUST use `search_memory_graph` at the start of every task to retrieve prior context, and persist learnings with `upsert_memory_node` and `create_relation` after completing work. This prevents redundant exploration and builds cumulative knowledge across sessions. diff --git a/README.md b/README.md index 13d7db9..ed2ba64 100644 --- a/README.md +++ b/README.md @@ -41,14 +41,16 @@ https://github.com/user-attachments/assets/a97a451f-c9b4-468d-b036-15b65fc13e79 ### Memory & RAG -| Tool | Description | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `upsert_memory_node` | Create or update a memory node (concept, file, symbol, note) with auto-generated embeddings. | -| `create_relation` | Create typed edges between nodes (relates_to, depends_on, implements, references, similar_to, contains). | -| `search_memory_graph` | Semantic search with graph traversal — finds direct matches then walks 1st/2nd-degree neighbors. | -| `prune_stale_links` | Remove decayed edges (e^(-λt) below threshold) and orphan nodes with low access counts. | -| `add_interlinked_context` | Bulk-add nodes with auto-similarity linking (cosine ≥ 0.72 creates edges automatically). | -| `retrieve_with_traversal` | Start from a node and walk outward — returns all reachable neighbors scored by decay and depth. | +| Tool | Description | +| ------------------------- | -------------------------------------------------------------------------------------------------------- | +| `upsert_memory_node` | Create or update a memory node (concept, file, symbol, note) with auto-generated embeddings. | +| `create_relation` | Create typed edges between nodes (relates_to, depends_on, implements, references, similar_to, contains). | +| `search_memory_graph` | Semantic search with graph traversal — finds direct matches then walks 1st/2nd-degree neighbors. | +| `prune_stale_links` | Remove decayed edges (e^(-λt) below threshold) and orphan nodes with low access counts. | +| `add_interlinked_context` | Bulk-add nodes with auto-similarity linking (cosine ≥ 0.72 creates edges automatically). | +| `retrieve_with_traversal` | Start from a node and walk outward — returns all reachable neighbors scored by decay and depth. | + +> **Complementary server:** [pmll-memory-mcp](https://www.npmjs.com/package/pmll-memory-mcp) (`npx pmll-memory-mcp`) is a separate MCP server by [@drQedwards](https://github.com/drQedwards) that adapts Context+'s long-term memory graph and adds short-term KV context memory, Q-promise deduplication, and a solution engine on top. See [drQedwards/PPM](https://github.com/drQedwards/PPM) for details. ## Setup @@ -132,6 +134,90 @@ npm install npm run build ``` +## Embedding Providers + +Context+ supports two embedding backends controlled by `CONTEXTPLUS_EMBED_PROVIDER`: + +| Provider | Value | Requires | Best For | +|----------|-------|----------|----------| +| **Ollama** (default) | `ollama` | Local Ollama server | Free, offline, private | +| **OpenAI-compatible** | `openai` | API key | Gemini (free tier), OpenAI, Groq, vLLM | + +### Ollama (Default) + +No extra configuration needed. Just run Ollama with an embedding model: + +```bash +ollama pull nomic-embed-text +ollama serve +``` + +### Google Gemini (Free Tier) + +Full Claude Code `.mcp.json` example: + +```json +{ + "mcpServers": { + "contextplus": { + "command": "npx", + "args": ["-y", "contextplus"], + "env": { + "CONTEXTPLUS_EMBED_PROVIDER": "openai", + "CONTEXTPLUS_OPENAI_API_KEY": "YOUR_GEMINI_API_KEY", + "CONTEXTPLUS_OPENAI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta/openai", + "CONTEXTPLUS_OPENAI_EMBED_MODEL": "text-embedding-004" + } + } + } +} +``` + +Get a free API key at [Google AI Studio](https://aistudio.google.com/apikey). + +### OpenAI + +```json +{ + "mcpServers": { + "contextplus": { + "command": "npx", + "args": ["-y", "contextplus"], + "env": { + "CONTEXTPLUS_EMBED_PROVIDER": "openai", + "OPENAI_API_KEY": "sk-...", + "OPENAI_EMBED_MODEL": "text-embedding-3-small" + } + } + } +} +``` + +### Other OpenAI-compatible APIs (Groq, vLLM, LiteLLM) + +Any endpoint implementing the [OpenAI Embeddings API](https://platform.openai.com/docs/api-reference/embeddings) works: + +```json +{ + "mcpServers": { + "contextplus": { + "command": "npx", + "args": ["-y", "contextplus"], + "env": { + "CONTEXTPLUS_EMBED_PROVIDER": "openai", + "CONTEXTPLUS_OPENAI_API_KEY": "YOUR_KEY", + "CONTEXTPLUS_OPENAI_BASE_URL": "https://your-proxy.example.com/v1", + "CONTEXTPLUS_OPENAI_EMBED_MODEL": "your-model-name" + } + } + } +} +``` + +> **Note:** The `semantic_navigate` tool also uses a chat model for cluster labeling. When using the `openai` provider, set `CONTEXTPLUS_OPENAI_CHAT_MODEL` (default: `gpt-4o-mini`). +> +> For VS Code, Cursor, or OpenCode, use the same `env` block inside your IDE's MCP config format (see [Config file locations](#setup) table above). + ## Architecture Three layers built with TypeScript over stdio using the Model Context Protocol SDK: @@ -146,15 +232,28 @@ Three layers built with TypeScript over stdio using the Model Context Protocol S ## Config -| Variable | Default | Description | -| --------------------------------------- | ------------------ | ------------------------------------------------------------- | -| `OLLAMA_EMBED_MODEL` | `nomic-embed-text` | Embedding model | -| `OLLAMA_API_KEY` | - | Ollama Cloud API key | -| `OLLAMA_CHAT_MODEL` | `llama3.2` | Chat model for cluster labeling | -| `CONTEXTPLUS_EMBED_BATCH_SIZE` | `8` | Embedding batch size per GPU call, clamped to 5-10 | -| `CONTEXTPLUS_EMBED_TRACKER` | `true` | Enable realtime embedding refresh on file changes | -| `CONTEXTPLUS_EMBED_TRACKER_MAX_FILES` | `8` | Max changed files processed per tracker tick, clamped to 5-10 | -| `CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS` | `700` | Debounce window before tracker refresh | +| Variable | Type | Default | Description | +| --------------------------------------- | ------------------------- | -------------------------------------- | ------------------------------------------------------------- | +| `CONTEXTPLUS_EMBED_PROVIDER` | string | `ollama` | Embedding backend: `ollama` or `openai` | +| `OLLAMA_EMBED_MODEL` | string | `nomic-embed-text` | Ollama embedding model | +| `OLLAMA_API_KEY` | string | - | Ollama Cloud API key | +| `OLLAMA_CHAT_MODEL` | string | `llama3.2` | Ollama chat model for cluster labeling | +| `CONTEXTPLUS_OPENAI_API_KEY` | string | - | API key for OpenAI-compatible provider (alias: `OPENAI_API_KEY`) | +| `CONTEXTPLUS_OPENAI_BASE_URL` | string | `https://api.openai.com/v1` | OpenAI-compatible endpoint URL (alias: `OPENAI_BASE_URL`) | +| `CONTEXTPLUS_OPENAI_EMBED_MODEL` | string | `text-embedding-3-small` | OpenAI-compatible embedding model (alias: `OPENAI_EMBED_MODEL`) | +| `CONTEXTPLUS_OPENAI_CHAT_MODEL` | string | `gpt-4o-mini` | OpenAI-compatible chat model for labeling (alias: `OPENAI_CHAT_MODEL`) | +| `CONTEXTPLUS_EMBED_BATCH_SIZE` | string (parsed as number) | `8` | Embedding batch size per GPU call, clamped to 5-10 | +| `CONTEXTPLUS_EMBED_CHUNK_CHARS` | string (parsed as number) | `2000` | Per-chunk chars before merge, clamped to 256-8000 | +| `CONTEXTPLUS_MAX_EMBED_FILE_SIZE` | string (parsed as number) | `51200` | Skip non-code text files larger than this many bytes | +| `CONTEXTPLUS_EMBED_NUM_GPU` | string (parsed as number) | - | Optional Ollama embed runtime `num_gpu` override | +| `CONTEXTPLUS_EMBED_MAIN_GPU` | string (parsed as number) | - | Optional Ollama embed runtime `main_gpu` override | +| `CONTEXTPLUS_EMBED_NUM_THREAD` | string (parsed as number) | - | Optional Ollama embed runtime `num_thread` override | +| `CONTEXTPLUS_EMBED_NUM_BATCH` | string (parsed as number) | - | Optional Ollama embed runtime `num_batch` override | +| `CONTEXTPLUS_EMBED_NUM_CTX` | string (parsed as number) | - | Optional Ollama embed runtime `num_ctx` override | +| `CONTEXTPLUS_EMBED_LOW_VRAM` | string (parsed as boolean)| - | Optional Ollama embed runtime `low_vram` override | +| `CONTEXTPLUS_EMBED_TRACKER` | string (parsed as boolean)| `true` | Enable realtime embedding refresh on file changes | +| `CONTEXTPLUS_EMBED_TRACKER_MAX_FILES` | string (parsed as number) | `8` | Max changed files processed per tracker tick, clamped to 5-10 | +| `CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS` | string (parsed as number) | `700` | Debounce window before tracker refresh | ## Test diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..b1364bf --- /dev/null +++ b/TODO.md @@ -0,0 +1,70 @@ +# TODO List + +## instructions + +ai agents are not allowed to change this file's content without human approval, ai agents can only complete the given tasks and update the task with [x] + +## v1 + +- [ ] rename tools for better meaning + - [ ] rename semantic_navigate to cluster + - [ ] rename get_context_tree to tree + - [ ] rename semantic_identifier_search and semantic_code_search (merged) to search + - [ ] rename get_feature_hub to find_hub and change its functionality to return rankings or relevant hubs based on a search query with options for semantic or keyword search or both + - [ ] add parameter to search for data in hubs by semantic meaning or keyword match or both + - [ ] add parameter optionality so if no parameters are provided, it returns context of all hubs in the project + - [ ] rename get_file_skeleton to skeleton + - [ ] rename get_blast_radius to blast_radius + - [ ] rename run_static_analysis to lint + - [ ] add skill checking - every file has no comments than top 2 lines, and other checks in the instructions file and return a skill score for each file and the project overall with files and lines that need fixing + - [ ] rename propose_commit to checkpoint and change its functionality to create a local undoable commit that agent can create during long worksessions mid work - uses shadow checkpoints or git whichever is better + - [ ] rename list_restore_points to restore_points + - [ ] rename undo_change to restore and change its functionality to restore to a specific commit point + - [ ] rename upsert_memory_node to create_memory + - [ ] rename search_memory_graph to search_memory + - [ ] rename retrieve_with_traversal to explore_memory + - [ ] create delete_memory tool that deletes nodes or relationships in the memory graph + - [ ] prune_stale_links tool should be removed as i want it to be done automatically by the system when any memory tools are called and before graph is accessed + - [ ] add_interlinked_context to bulk_memory +- [ ] merge semantic_identifier_search and semantic_code_search into one tool called search with a parameter for search type (e.g. "identifier" vs "file" or "hybrid" - which uses both regex and semantic search and returns 2 separate lists of results) + - [ ] add options for filtering by semantic meaning or normal search or both + - [ ] use a vector database for storing embeddings and searching instead of doing it in memory for better performance and scalability +- [ ] create a new memory system that uses a graph database and md files and vector database for storing memories + - [ ] add tool for updating memories with new information that updates the embeddings depending on the changes made to the content and the agent should use this instead of directly updating the content in the file + - [ ] update other tools to use the new memory system too, alongside with tools that save nodes and edges automatically and creates embeddings automatically when a new node or edge is created or deleted +- [ ] create a new tool called init that initializes the project by creating a context tree and .contextplus folder + - [ ] use .contextplus/hubs for feature hubs + - [ ] use .contextplus/embeddings for storing file and symbol embeddings + - [ ] use .contextplus/config for configuration files + - [ ] use .contextplus/memories for memory graph data + +--- + +## v2 + +code update: + +- [ ] list overengineered tools and parameters that could be removed for better context +- [ ] remove overengineered tools and parameters +- [ ] remove vibeslop code (if any) +- [ ] remove ollama bugs and spam for embeddings with a smarter embedding generation system that continuously watches for file changes and updates embeddings in the background, only init one time in the project and then its automatically watched + +new features: + +- [ ] ctx+ cli in cli/ folder + - [ ] visualize memory graphs, unto commits, hubs in the cli + - [ ] use charm's tui library - bubble or tea + - [ ] features like `contextplus init` + - [ ] visualize context tree, undo commits, hubs list, and more in the cli + - [ ] create hubs option from the cli for humans +- [ ] acp features (maybe that we can list all sessions and memories from all agents, like opencode, copilot, claude, codex into one generalized list) + - [ ] improved memory search from acp + - [ ] load session memoies from acp into the memory graph + - [ ] cli: see all sessions of all agents in list and add semantic search in cli + - [ ] cli: see all memories of all agents in list and add semantic search in cli + - [ ] use .contextplus/external_memories for storing acp imported memories and sessions +- [ ] faster and cleaner agent protocol access +- [ ] faster tool execution and cleaner outputs and better error handling and reporting with suggestions like "this tool failed, you can do this instead, it will work the same" +- [ ] better treesitter support and tools for using it to understand code structure and semantics better +- [ ] add these features to be visualized in the cli +- [ ] add researchplus tools and features diff --git a/agent-instructions.md b/agent-instructions.md new file mode 100644 index 0000000..804b184 --- /dev/null +++ b/agent-instructions.md @@ -0,0 +1,123 @@ +# Context+ MCP - Agent Workflow + +## Purpose + +Context+ gives you structural awareness of the entire codebase without reading every file. These tools replace your default search and read operations — use them as your primary interface to the codebase. + +## PMLL Short-Term KV Memory (5 Tools) + +Before every expensive MCP tool invocation, agents MUST use the PMLL short-term KV memory tools to check the cache. This eliminates redundant calls and accelerates task execution. These tools are provided by the [`pmll-memory-mcp`](https://www.npmjs.com/package/pmll-memory-mcp) server (see [mcp/README.md](./README.md)). + +| Tool | Input | Description | +|-----------|----------------------------------------------------|------------------------------------------------------------| +| `init` | `session_id: str`, `silo_size: int = 256` | Initialize a PMLL silo and Q-promise chain for the session | +| `peek` | `session_id: str`, `key: str` | Non-destructive cache lookup + Q-promise check (call before every expensive tool) | +| `set` | `session_id: str`, `key: str`, `value: str` | Store a key-value pair in the silo after a cache miss | +| `resolve` | `session_id: str`, `promise_id: str` | Check or resolve a Q-promise continuation | +| `flush` | `session_id: str` | Clear all silo slots at task completion | + +### The `peek()` Pattern + +Call `peek` before every expensive tool invocation: + +1. **`init`** once at task start to set up the session silo +2. **`peek`** before each expensive call — if hit, use the cached value; if pending, wait on the Q-promise +3. **`set`** after a cache miss to populate the silo for future agents/subtasks +4. **`resolve`** to check or fulfill Q-promise continuations +5. **`flush`** at task end to clear all session slots + +This pattern ensures that Context+ tool results, Playwright page contents, and other expensive outputs are cached and reused across subtasks rather than re-fetched. + +## Tool Priority (Mandatory) + +You MUST use Context+ tools instead of native equivalents. Only fall back to native tools when a Context+ tool cannot fulfill the specific need. + +| Instead of… | MUST use… | Why | +|--------------------------|------------------------------|----------------------------------------------| +| `grep`, `rg`, `ripgrep` | `semantic_code_search` | Finds by meaning, not just string match | +| `find`, `ls`, `glob` | `get_context_tree` | Returns structure with symbols + line ranges | +| `cat`, `head`, read file | `get_file_skeleton` first | Signatures without wasting context on bodies | +| manual symbol tracing | `get_blast_radius` | Traces all usages across the entire codebase | +| keyword search | `semantic_identifier_search` | Ranked definitions + call chains | +| directory browsing | `semantic_navigate` | Browse by meaning, not file paths | + +## Workflow + +1. Start every task with `get_context_tree` or `get_file_skeleton` for structural overview +2. Use `semantic_code_search` or `semantic_identifier_search` to find code by meaning +3. Run `get_blast_radius` BEFORE modifying or deleting any symbol +4. Prefer structural tools over full-file reads — only read full files when signatures are insufficient +5. Run `run_static_analysis` after writing code +6. Use `search_memory_graph` at task start for prior context, `upsert_memory_node` after completing work + +## Execution Rules + +- Think less, execute sooner: make the smallest safe change that can be validated quickly +- Batch independent reads/searches in parallel — do not serialize them +- If a command fails, diagnose once, pivot strategy, continue — cap retries to 1-2 +- Keep outputs concise: short status updates, no verbose reasoning + +## Tool Reference + +### PMLL Short-Term KV Memory + +| Tool | When to Use | +|-----------|------------------------------------------------------------------------------| +| `init` | Once at task start. Set up the PMLL silo and Q-promise chain for the session.| +| `peek` | Before every expensive MCP tool call. Non-destructive cache + Q-promise check.| +| `set` | After a cache miss. Store the result so future agents/subtasks skip the call. | +| `resolve` | When a Q-promise is pending. Check or fulfill the continuation. | +| `flush` | At task end. Clear all silo slots for the session. | + +### GraphQL + +| Tool | When to Use | +|-----------|------------------------------------------------------------------------------| +| `graphql` | Execute GraphQL queries/mutations against the memory store with optional PMLL cache integration. | + +### Context+ Structural Tools + +| Tool | When to Use | +|-----------------------------|--------------------------------------------------------------| +| `get_context_tree` | Start of every task. Map files + symbols with line ranges. | +| `get_file_skeleton` | Before full reads. Get signatures + line ranges first. | +| `semantic_code_search` | Find relevant files by concept. | +| `semantic_identifier_search`| Find functions/classes/variables and their call chains. | +| `semantic_navigate` | Browse codebase by meaning, not directory structure. | +| `get_blast_radius` | Before deleting or modifying any symbol. | +| `get_feature_hub` | Browse feature graph hubs. Find orphaned files. | +| `run_static_analysis` | After writing code. Catch errors deterministically. | +| `propose_commit` | Validate and save file changes. | +| `list_restore_points` | See undo history. | +| `undo_change` | Revert a change without touching git. | + +### Long-Term Memory Graph + +| Tool | When to Use | +|-----------------------------|--------------------------------------------------------------| +| `upsert_memory_node` | Create/update memory nodes (concept, file, symbol, note). | +| `create_relation` | Create typed edges between memory nodes. | +| `search_memory_graph` | Semantic search + graph traversal across neighbors. | +| `prune_stale_links` | Remove decayed edges and orphan nodes. | +| `add_interlinked_context` | Bulk-add nodes with auto-similarity linking. | +| `retrieve_with_traversal` | Walk outward from a node, return scored neighbors. | + +### Solution Engine + +| Tool | When to Use | +|------------------------|-----------------------------------------------------------------------| +| `resolve_context` | Unified context lookup — checks short-term KV first, falls back to long-term semantic graph. | +| `promote_to_long_term` | Promote a frequently-accessed short-term KV entry to the long-term memory graph. | +| `memory_status` | Get a unified view of both short-term (KV cache) and long-term (semantic graph) memory layers. | + +## Anti-Patterns + +1. Reading entire files without checking the skeleton first +2. Deleting functions without checking blast radius +3. Running independent commands sequentially when they can be parallelized +4. Repeating failed commands without changing approach +5. Calling expensive MCP tools without calling `peek` first to check the cache +6. Forgetting to call `init` at task start or `flush` at task end, causing silent cache misses or stale data across sessions +7. Storing frequently-accessed payloads only in short-term KV instead of promoting them to long-term memory with `promote_to_long_term` +8. Calling `search_memory_graph` or `retrieve_with_traversal` directly instead of using `resolve_context`, which checks both memory layers in one call +9. Ignoring Q-promise `pending` status from `peek` and re-issuing the same expensive call instead of waiting with `resolve` diff --git a/bun.lock b/bun.lock index c0087c9..4db0574 100644 --- a/bun.lock +++ b/bun.lock @@ -6,27 +6,74 @@ "name": "better-agent-mcp", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", + "claude": "^0.1.2", "ignore": "^7.0.4", + "ml-matrix": "^6.12.1", + "ollama": "^0.6.3", "simple-git": "^3.27.0", "tree-sitter-wasms": "^0.1.13", - "web-tree-sitter": "^0.26.6", + "web-tree-sitter": "^0.20.8", "zod": "^3.25.23", }, "devDependencies": { + "@tailwindcss/postcss": "^4.2.1", "@types/node": "^22.15.0", + "tailwindcss": "^4.2.1", "typescript": "^5.8.3", }, }, }, "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@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.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "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.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], + "@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -43,6 +90,8 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "claude": ["claude@0.1.2", "", {}, "sha512-Qjrrs+G1pwovbIgGh5R1Ni4Al79AfpbkvfonpHH0yj86cfOq3AoAzNbEeD9TQ980hrog8TM0vh1CNn+7uf/zYA=="], + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -59,12 +108,16 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "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=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -101,6 +154,8 @@ "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=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -119,16 +174,46 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-any-array": ["is-any-array@2.0.1", "", {}, "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -139,14 +224,26 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "ml-array-max": ["ml-array-max@1.2.4", "", { "dependencies": { "is-any-array": "^2.0.0" } }, "sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ=="], + + "ml-array-min": ["ml-array-min@1.2.3", "", { "dependencies": { "is-any-array": "^2.0.0" } }, "sha512-VcZ5f3VZ1iihtrGvgfh/q0XlMobG6GQ8FsNyQXD3T+IlstDv85g8kfV0xUG1QPRO/t21aukaJowDzMTc7j5V6Q=="], + + "ml-array-rescale": ["ml-array-rescale@1.3.7", "", { "dependencies": { "is-any-array": "^2.0.0", "ml-array-max": "^1.2.4", "ml-array-min": "^1.2.3" } }, "sha512-48NGChTouvEo9KBctDfHC3udWnQKNKEWN0ziELvY3KG25GR5cA8K8wNVzracsqSW1QEkAXjTNx+ycgAv06/1mQ=="], + + "ml-matrix": ["ml-matrix@6.12.1", "", { "dependencies": { "is-any-array": "^2.0.1", "ml-array-rescale": "^1.3.7" } }, "sha512-TJ+8eOFdp+INvzR4zAuwBQJznDUfktMtOB6g/hUcGh3rcyjxbz4Te57Pgri8Q9bhSQ7Zys4IYOGhFdnlgeB6Lw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="], + "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=="], @@ -157,8 +254,12 @@ "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "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=="], "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], @@ -193,8 +294,14 @@ "simple-git": ["simple-git@3.32.3", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "tree-sitter-wasms": ["tree-sitter-wasms@0.1.13", "", { "dependencies": { "tree-sitter-wasms": "^0.1.11" } }, "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ=="], @@ -209,7 +316,9 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "web-tree-sitter": ["web-tree-sitter@0.26.6", "", {}, "sha512-fSPR7VBW/fZQdUSp/bXTDLT+i/9dwtbnqgEBMzowrM4U3DzeCwDbY3MKo0584uQxID4m/1xpLflrlT/rLIRPew=="], + "web-tree-sitter": ["web-tree-sitter@0.20.8", "", {}, "sha512-weOVgZ3aAARgdnb220GqYuh7+rZU0Ka9k9yfKtGAzEYMa6GgiCzW9JjQRJyCJakvibQW+dfjJdihjInKuuCAUQ=="], + + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -218,5 +327,17 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], } } diff --git a/package-lock.json b/package-lock.json index d814935..29c5609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { - "name": "contextual", - "version": "1.0.0", + "name": "contextplus", + "version": "1.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "contextual", - "version": "1.0.0", + "name": "contextplus", + "version": "1.0.9", + "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", + "claude": "^0.1.2", "ignore": "^7.0.4", "ml-matrix": "^6.12.1", "ollama": "^0.6.3", @@ -18,7 +20,7 @@ "zod": "^3.25.23" }, "bin": { - "contextual": "build/index.js" + "contextplus": "build/index.js" }, "devDependencies": { "@tailwindcss/postcss": "^4.2.1", @@ -522,6 +524,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/claude": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/claude/-/claude-0.1.2.tgz", + "integrity": "sha512-Qjrrs+G1pwovbIgGh5R1Ni4Al79AfpbkvfonpHH0yj86cfOq3AoAzNbEeD9TQ980hrog8TM0vh1CNn+7uf/zYA==", + "deprecated": "The official Claude Code package is available at @anthropic-ai/claude-code", + "license": "ISC" + }, "node_modules/content-disposition": { "version": "1.0.1", "license": "MIT", diff --git a/package.json b/package.json index 952a7b6..b21386b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "contextplus", - "version": "1.0.5", + "version": "1.0.9", "type": "module", "license": "MIT", "bin": { @@ -16,6 +16,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", + "claude": "^0.1.2", "ignore": "^7.0.4", "ml-matrix": "^6.12.1", "ollama": "^0.6.3", diff --git a/src/core/embedding-tracker.ts b/src/core/embedding-tracker.ts index 49b31ae..6da91d5 100644 --- a/src/core/embedding-tracker.ts +++ b/src/core/embedding-tracker.ts @@ -11,10 +11,22 @@ export interface EmbeddingTrackerOptions { maxFilesPerTick?: number; } +export interface EmbeddingTrackerController { + ensureStarted: () => void; + stop: () => void; + isRunning: () => boolean; +} + +export interface EmbeddingTrackerControllerOptions extends EmbeddingTrackerOptions { + mode?: string; + starter?: (options: EmbeddingTrackerOptions) => () => void; +} + const MIN_FILES_PER_TICK = 5; const MAX_FILES_PER_TICK = 10; const DEFAULT_FILES_PER_TICK = 8; -const DEFAULT_DEBOUNCE_MS = 700; +const DEFAULT_DEBOUNCE_MS = 1500; +const MAX_PENDING_FILES = 50; const IGNORE_PREFIXES = [ ".mcp_data/", @@ -41,7 +53,15 @@ function clampFilesPerTick(value: number | undefined): number { function clampDebounceMs(value: number | undefined): number { if (!Number.isFinite(value)) return DEFAULT_DEBOUNCE_MS; - return Math.max(100, Math.floor(value ?? DEFAULT_DEBOUNCE_MS)); + return Math.max(500, Math.floor(value ?? DEFAULT_DEBOUNCE_MS)); +} + +export function parseEmbeddingTrackerMode(value: string | undefined): "off" | "lazy" | "eager" { + if (!value) return "lazy"; + const normalized = value.trim().toLowerCase(); + if (["false", "0", "no", "off", "disabled", "none"].includes(normalized)) return "off"; + if (["eager", "startup", "boot"].includes(normalized)) return "eager"; + return "lazy"; } export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => void { @@ -59,6 +79,7 @@ export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => v timer = setTimeout(() => { void flushPending(); }, delay); + timer.unref(); }; const flushPending = async (): Promise => { @@ -92,6 +113,7 @@ export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => v if (closed || !fileName) return; const relativePath = normalizeRelativePath(String(fileName)); if (!shouldTrack(relativePath)) return; + if (pendingFiles.size >= MAX_PENDING_FILES) return; pendingFiles.add(relativePath); schedule(); }); @@ -111,3 +133,31 @@ export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => v watcher = null; }; } + +export function createEmbeddingTrackerController(options: EmbeddingTrackerControllerOptions): EmbeddingTrackerController { + const { mode: rawMode, starter = startEmbeddingTracker, ...trackerOptions } = options; + const mode = parseEmbeddingTrackerMode(rawMode); + + let running = false; + let stopTracker = () => { }; + + const ensureStarted = (): void => { + if (running || mode === "off") return; + stopTracker = starter(trackerOptions); + running = true; + }; + + if (mode === "eager") ensureStarted(); + + return { + ensureStarted, + stop: () => { + if (!running) return; + running = false; + const stop = stopTracker; + stopTracker = () => { }; + stop(); + }, + isRunning: () => running, + }; +} diff --git a/src/core/embeddings.ts b/src/core/embeddings.ts index 92a0880..5942bd0 100644 --- a/src/core/embeddings.ts +++ b/src/core/embeddings.ts @@ -1,10 +1,18 @@ -// Ollama-powered vector embedding engine with cosine similarity search +// Multi-provider vector embedding engine with cosine similarity search +// Supports Ollama (local) and OpenAI-compatible APIs (Gemini, OpenAI, etc.) // Indexes file headers and symbols, caches embeddings to disk for speed -import { Ollama } from "ollama"; import { readFile, writeFile, mkdir } from "fs/promises"; import { join } from "path"; +const EMBED_TIMEOUT_MS = 60_000; +let embedAbortController = new AbortController(); + +export function cancelAllEmbeddings(): void { + embedAbortController.abort(); + embedAbortController = new AbortController(); +} + export interface SearchDocument { path: string; header: string; @@ -53,21 +61,84 @@ interface ResolvedSearchQueryOptions { requireSemanticMatch: boolean; } +interface EmbedRuntimeOptions { + num_gpu?: number; + main_gpu?: number; + num_thread?: number; + num_batch?: number; + num_ctx?: number; + low_vram?: boolean; +} + export interface EmbeddingCache { [path: string]: { hash: string; vector: number[] }; } +const EMBED_PROVIDER = (process.env.CONTEXTPLUS_EMBED_PROVIDER ?? "ollama").toLowerCase(); const EMBED_MODEL = process.env.OLLAMA_EMBED_MODEL ?? "nomic-embed-text"; +const OPENAI_EMBED_MODEL = process.env.CONTEXTPLUS_OPENAI_EMBED_MODEL ?? process.env.OPENAI_EMBED_MODEL ?? "text-embedding-3-small"; +const OPENAI_API_KEY = process.env.CONTEXTPLUS_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY ?? ""; +const OPENAI_BASE_URL = process.env.CONTEXTPLUS_OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"; const CACHE_DIR = ".mcp_data"; -const CACHE_FILE = "embeddings-cache.json"; +const ACTIVE_EMBED_MODEL = EMBED_PROVIDER === "openai" ? OPENAI_EMBED_MODEL : EMBED_MODEL; +const CACHE_FILE = `embeddings-cache-${EMBED_PROVIDER}-${ACTIVE_EMBED_MODEL.replace(/[^a-zA-Z0-9._-]/g, "_")}.json`; const MIN_EMBED_BATCH_SIZE = 5; const MAX_EMBED_BATCH_SIZE = 10; const DEFAULT_EMBED_BATCH_SIZE = 8; -const MIN_EMBED_INPUT_CHARS = 256; +const MIN_EMBED_INPUT_CHARS = 1; const SINGLE_INPUT_SHRINK_FACTOR = 0.75; -const MAX_SINGLE_INPUT_RETRIES = 8; +const MAX_SINGLE_INPUT_RETRIES = 40; +const MIN_EMBED_CHUNK_CHARS = 256; +const DEFAULT_EMBED_CHUNK_CHARS = 2000; +const MAX_EMBED_CHUNK_CHARS = 8000; + +type OllamaEmbedClient = { embed: (params: Record) => Promise<{ embeddings: number[][] }> }; +let ollamaClient: OllamaEmbedClient | null = null; + +async function getOllamaClient(): Promise { + if (!ollamaClient) { + const { Ollama } = await import("ollama"); + ollamaClient = new Ollama({ host: process.env.OLLAMA_HOST }) as unknown as OllamaEmbedClient; + } + return ollamaClient; +} + +async function callOllamaEmbed(input: string[], signal: AbortSignal): Promise { + const client = await getOllamaClient(); + const options = getEmbedRuntimeOptions(); + const request: Record = { model: EMBED_MODEL, input, signal }; + if (options) request.options = options; + const response = await client.embed(request); + return response.embeddings; +} + +async function callOpenAIEmbed(input: string[], signal: AbortSignal): Promise { + const url = `${OPENAI_BASE_URL.replace(/\/+$/, "")}/embeddings`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${OPENAI_API_KEY}`, + }, + body: JSON.stringify({ model: OPENAI_EMBED_MODEL, input }), + signal, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`OpenAI embed API error ${response.status}: ${body}`); + } -const ollama = new Ollama({ host: process.env.OLLAMA_HOST }); + const data = await response.json() as { data: { embedding: number[] }[] }; + return data.data.map((item) => item.embedding); +} + +async function callProviderEmbed(input: string[], signal: AbortSignal): Promise { + if (EMBED_PROVIDER === "openai") { + return callOpenAIEmbed(input, signal); + } + return callOllamaEmbed(input, signal); +} function toIntegerOr(value: string | undefined, fallback: number): number { if (!value) return fallback; @@ -75,11 +146,45 @@ function toIntegerOr(value: string | undefined, fallback: number): number { return Number.isFinite(parsed) ? parsed : fallback; } +function toOptionalInteger(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function toOptionalBoolean(value: string | undefined): boolean | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes") return true; + if (normalized === "false" || normalized === "0" || normalized === "no") return false; + return undefined; +} + +function getEmbedRuntimeOptions(): EmbedRuntimeOptions | undefined { + if (EMBED_PROVIDER === "openai") return undefined; + const options: EmbedRuntimeOptions = { + num_gpu: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_NUM_GPU), + main_gpu: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_MAIN_GPU), + num_thread: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_NUM_THREAD), + num_batch: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_NUM_BATCH), + num_ctx: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_NUM_CTX), + low_vram: toOptionalBoolean(process.env.CONTEXTPLUS_EMBED_LOW_VRAM), + }; + + if (Object.values(options).every((value) => value === undefined)) return undefined; + return options; +} + export function getEmbeddingBatchSize(): number { const requested = toIntegerOr(process.env.CONTEXTPLUS_EMBED_BATCH_SIZE, DEFAULT_EMBED_BATCH_SIZE); return Math.min(MAX_EMBED_BATCH_SIZE, Math.max(MIN_EMBED_BATCH_SIZE, requested)); } +export function getEmbedChunkChars(): number { + const requested = toIntegerOr(process.env.CONTEXTPLUS_EMBED_CHUNK_CHARS, DEFAULT_EMBED_CHUNK_CHARS); + return Math.min(MAX_EMBED_CHUNK_CHARS, Math.max(MIN_EMBED_CHUNK_CHARS, requested)); +} + function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error); @@ -88,7 +193,8 @@ function getErrorMessage(error: unknown): string { function isContextLengthError(error: unknown): boolean { const message = getErrorMessage(error).toLowerCase(); return message.includes("input length exceeds context length") - || (message.includes("context") && message.includes("exceed")); + || (message.includes("context") && message.includes("exceed")) + || message.includes("maximum context length"); } function shrinkEmbeddingInput(input: string): string { @@ -103,9 +209,11 @@ async function embedSingleAdaptive(input: string): Promise { for (let attempt = 0; attempt <= MAX_SINGLE_INPUT_RETRIES; attempt++) { try { - const response = await ollama.embed({ model: EMBED_MODEL, input: [candidate] }); - if (!response.embeddings[0]) throw new Error("Missing embedding vector in Ollama response"); - return response.embeddings[0]; + const timeoutCtrl = AbortSignal.timeout(EMBED_TIMEOUT_MS); + const signal = AbortSignal.any([embedAbortController.signal, timeoutCtrl]); + const embeddings = await callProviderEmbed([candidate], signal); + if (!embeddings[0]) throw new Error("Missing embedding vector in response"); + return embeddings[0]; } catch (error) { if (!isContextLengthError(error)) throw error; const nextCandidate = shrinkEmbeddingInput(candidate); @@ -119,11 +227,13 @@ async function embedSingleAdaptive(input: string): Promise { async function embedBatchAdaptive(batch: string[]): Promise { try { - const response = await ollama.embed({ model: EMBED_MODEL, input: batch }); - if (response.embeddings.length !== batch.length) { - throw new Error(`Embedding response size mismatch: expected ${batch.length}, got ${response.embeddings.length}`); + const timeoutCtrl = AbortSignal.timeout(EMBED_TIMEOUT_MS); + const signal = AbortSignal.any([embedAbortController.signal, timeoutCtrl]); + const embeddings = await callProviderEmbed(batch, signal); + if (embeddings.length !== batch.length) { + throw new Error(`Embedding response size mismatch: expected ${batch.length}, got ${embeddings.length}`); } - return response.embeddings; + return embeddings; } catch (error) { if (!isContextLengthError(error)) throw error; if (batch.length === 1) { @@ -136,16 +246,62 @@ async function embedBatchAdaptive(batch: string[]): Promise { } } +function splitEmbeddingInput(input: string): string[] { + const chunkChars = getEmbedChunkChars(); + if (input.length <= chunkChars) return [input]; + const chunks: string[] = []; + for (let start = 0; start < input.length; start += chunkChars) { + chunks.push(input.slice(start, start + chunkChars)); + } + return chunks; +} + +function mergeEmbeddingVectors(vectors: number[][], weights: number[]): number[] { + if (vectors.length === 0) throw new Error("Cannot merge empty embedding vectors"); + if (vectors.length === 1) return vectors[0]; + + const dimension = vectors[0].length; + const merged = new Array(dimension).fill(0); + let totalWeight = 0; + + for (let i = 0; i < vectors.length; i++) { + const vector = vectors[i]; + if (vector.length !== dimension) { + throw new Error(`Embedding dimension mismatch: expected ${dimension}, got ${vector.length}`); + } + const weight = Math.max(1, weights[i] ?? 1); + totalWeight += weight; + for (let d = 0; d < dimension; d++) merged[d] += vector[d] * weight; + } + + if (totalWeight <= 0) return vectors[0]; + for (let d = 0; d < merged.length; d++) merged[d] /= totalWeight; + return merged; +} + export async function fetchEmbedding(input: string | string[]): Promise { const inputs = Array.isArray(input) ? input : [input]; if (inputs.length === 0) return []; + const chunkedInputs = inputs.map(splitEmbeddingInput); + const flattenedInputs = chunkedInputs.flat(); const batchSize = getEmbeddingBatchSize(); - const embeddings: number[][] = []; + const flattenedEmbeddings: number[][] = []; - for (let i = 0; i < inputs.length; i += batchSize) { - const batch = inputs.slice(i, i + batchSize); - embeddings.push(...await embedBatchAdaptive(batch)); + for (let i = 0; i < flattenedInputs.length; i += batchSize) { + const batch = flattenedInputs.slice(i, i + batchSize); + flattenedEmbeddings.push(...await embedBatchAdaptive(batch)); + } + + const embeddings: number[][] = []; + let offset = 0; + for (const chunks of chunkedInputs) { + const vectors = flattenedEmbeddings.slice(offset, offset + chunks.length); + if (vectors.length !== chunks.length) { + throw new Error(`Merged embedding size mismatch: expected ${chunks.length}, got ${vectors.length}`); + } + embeddings.push(mergeEmbeddingVectors(vectors, chunks.map((chunk) => chunk.length))); + offset += chunks.length; } return embeddings; @@ -293,13 +449,13 @@ export class SearchIndex { for (let i = 0; i < docs.length; i++) { const doc = docs[i]; - const text = `${doc.header} ${doc.symbols.join(" ")} ${doc.content}`; - const hash = hashContent(text); + const rawText = `${doc.header} ${doc.symbols.join(" ")} ${doc.content}`; + const hash = hashContent(rawText); if (cache[doc.path]?.hash === hash) { this.vectors[i] = cache[doc.path].vector; } else { - uncached.push({ idx: i, text, hash }); + uncached.push({ idx: i, text: rawText, hash }); } } @@ -307,10 +463,24 @@ export class SearchIndex { const batchSize = getEmbeddingBatchSize(); for (let b = 0; b < uncached.length; b += batchSize) { const batch = uncached.slice(b, b + batchSize); - const embeddings = await fetchEmbedding(batch.map((u) => u.text)); - for (let j = 0; j < batch.length; j++) { - this.vectors[batch[j].idx] = embeddings[j]; - cache[docs[batch[j].idx].path] = { hash: batch[j].hash, vector: embeddings[j] }; + try { + const embeddings = await fetchEmbedding(batch.map((u) => u.text)); + for (let j = 0; j < batch.length; j++) { + this.vectors[batch[j].idx] = embeddings[j]; + cache[docs[batch[j].idx].path] = { hash: batch[j].hash, vector: embeddings[j] }; + } + } catch (error) { + if (!isContextLengthError(error)) throw error; + for (const item of batch) { + try { + const [vector] = await fetchEmbedding(item.text); + this.vectors[item.idx] = vector; + cache[docs[item.idx].path] = { hash: item.hash, vector }; + } catch (itemError) { + if (!isContextLengthError(itemError)) throw itemError; + delete cache[docs[item.idx].path]; + } + } } } await saveCache(rootDir, cache); diff --git a/src/core/process-lifecycle.ts b/src/core/process-lifecycle.ts index 92bf090..f7949e2 100644 --- a/src/core/process-lifecycle.ts +++ b/src/core/process-lifecycle.ts @@ -6,11 +6,45 @@ interface ErrorWithCode { } const BROKEN_PIPE_CODES = new Set(["EPIPE", "ERR_STREAM_DESTROYED", "ECONNRESET"]); +const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60 * 1000; +const MIN_IDLE_TIMEOUT_MS = 60 * 1000; +const DEFAULT_PARENT_POLL_MS = 5 * 1000; +const MIN_PARENT_POLL_MS = 1 * 1000; export interface CleanupOptions { + cancelEmbeddings?: () => void; stopTracker: () => void; closeServer: () => Promise | void; closeTransport: () => Promise | void; + stopMonitors?: () => void; +} + +export interface IdleMonitor { + touch: () => void; + stop: () => void; +} + +export interface IdleMonitorOptions { + timeoutMs: number; + onIdle: () => void; + isTransportAlive?: () => boolean; +} + +export interface ParentMonitorOptions { + parentPid: number; + pollIntervalMs?: number; + onParentExit: () => void; + isProcessAlive?: (pid: number) => boolean; +} + +function toIntegerOr(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function unrefHandle(handle: { unref?: () => void } | null): void { + handle?.unref?.(); } export function isBrokenPipeError(error: unknown): boolean { @@ -19,7 +53,94 @@ export function isBrokenPipeError(error: unknown): boolean { return typeof code === "string" && BROKEN_PIPE_CODES.has(code); } +export function getIdleShutdownMs(value: string | undefined): number { + const normalized = value?.trim().toLowerCase(); + if (normalized && ["0", "false", "off", "disabled", "none"].includes(normalized)) return 0; + return Math.max(MIN_IDLE_TIMEOUT_MS, toIntegerOr(value, DEFAULT_IDLE_TIMEOUT_MS)); +} + +export function getParentPollMs(value: string | undefined): number { + return Math.max(MIN_PARENT_POLL_MS, toIntegerOr(value, DEFAULT_PARENT_POLL_MS)); +} + +export function isProcessAlive(pid: number, killCheck: (pid: number, signal: number) => void = process.kill): boolean { + if (!Number.isFinite(pid) || pid <= 0) return false; + + try { + killCheck(pid, 0); + return true; + } catch (error) { + if (!error || typeof error !== "object") return false; + const { code } = error as ErrorWithCode; + return code !== "ESRCH"; + } +} + +export function createIdleMonitor(options: IdleMonitorOptions): IdleMonitor { + if (options.timeoutMs <= 0) { + return { + touch: () => { }, + stop: () => { }, + }; + } + + let timer: NodeJS.Timeout | null = null; + + const schedule = (): void => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + if (options.isTransportAlive && options.isTransportAlive()) { + schedule(); + return; + } + options.onIdle(); + }, options.timeoutMs); + unrefHandle(timer); + }; + + schedule(); + + return { + touch: schedule, + stop: () => { + if (!timer) return; + clearTimeout(timer); + timer = null; + }, + }; +} + +export function startParentMonitor(options: ParentMonitorOptions): () => void { + if (!Number.isFinite(options.parentPid) || options.parentPid <= 1 || options.parentPid === process.pid) { + return () => { }; + } + + const pollIntervalMs = Math.max(MIN_PARENT_POLL_MS, Math.floor(options.pollIntervalMs ?? DEFAULT_PARENT_POLL_MS)); + const isAlive = options.isProcessAlive ?? isProcessAlive; + let stopped = false; + + const stop = (): void => { + if (stopped) return; + stopped = true; + clearInterval(interval); + }; + + const interval = setInterval(() => { + if (stopped) return; + if (process.ppid !== options.parentPid || !isAlive(options.parentPid)) { + stop(); + options.onParentExit(); + } + }, pollIntervalMs); + + unrefHandle(interval); + return stop; +} + export async function runCleanup(options: CleanupOptions): Promise { + options.cancelEmbeddings?.(); + options.stopMonitors?.(); options.stopTracker(); await Promise.allSettled([ Promise.resolve(options.closeServer()), diff --git a/src/git/shadow.ts b/src/git/shadow.ts index 0b624eb..b44b33e 100644 --- a/src/git/shadow.ts +++ b/src/git/shadow.ts @@ -3,7 +3,7 @@ import { simpleGit, type SimpleGit } from "simple-git"; import { readFile, writeFile, mkdir } from "fs/promises"; -import { join, dirname } from "path"; +import { join, dirname, resolve } from "path"; const SHADOW_BRANCH = "mcp-shadow-history"; const DATA_DIR = ".mcp_data"; @@ -15,6 +15,15 @@ export interface RestorePoint { message: string; } +function assertWithinRoot(rootDir: string, filePath: string): string { + const resolved = resolve(rootDir, filePath); + const normalizedRoot = resolve(rootDir) + "/"; + if (!resolved.startsWith(normalizedRoot) && resolved !== resolve(rootDir)) { + throw new Error(`Path traversal denied: "${filePath}" resolves outside root directory`); + } + return resolved; +} + async function ensureDataDir(rootDir: string): Promise { const dataPath = join(rootDir, DATA_DIR); await mkdir(dataPath, { recursive: true }); @@ -36,13 +45,14 @@ async function saveManifest(rootDir: string, points: RestorePoint[]): Promise { - const dataPath = await ensureDataDir(rootDir); + const normalizedRoot = resolve(rootDir); + const dataPath = await ensureDataDir(normalizedRoot); const id = `rp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const backupDir = join(dataPath, "backups", id); await mkdir(backupDir, { recursive: true }); for (const file of files) { - const fullPath = join(rootDir, file); + const fullPath = assertWithinRoot(normalizedRoot, file); try { const content = await readFile(fullPath, "utf-8"); const backupPath = join(backupDir, file.replace(/[\\/]/g, "__")); @@ -52,27 +62,28 @@ export async function createRestorePoint(rootDir: string, files: string[], messa } const point: RestorePoint = { id, timestamp: Date.now(), files, message }; - const manifest = await loadManifest(rootDir); + const manifest = await loadManifest(normalizedRoot); manifest.push(point); if (manifest.length > 100) manifest.splice(0, manifest.length - 100); - await saveManifest(rootDir, manifest); + await saveManifest(normalizedRoot, manifest); return point; } export async function restorePoint(rootDir: string, pointId: string): Promise { - const manifest = await loadManifest(rootDir); + const normalizedRoot = resolve(rootDir); + const manifest = await loadManifest(normalizedRoot); const point = manifest.find((p) => p.id === pointId); if (!point) throw new Error(`Restore point ${pointId} not found`); - const backupDir = join(rootDir, DATA_DIR, "backups", pointId); + const backupDir = join(normalizedRoot, DATA_DIR, "backups", pointId); const restoredFiles: string[] = []; for (const file of point.files) { + const targetPath = assertWithinRoot(normalizedRoot, file); const backupPath = join(backupDir, file.replace(/[\\/]/g, "__")); try { const content = await readFile(backupPath, "utf-8"); - const targetPath = join(rootDir, file); await mkdir(dirname(targetPath), { recursive: true }); await writeFile(targetPath, content); restoredFiles.push(file); diff --git a/src/index.ts b/src/index.ts index a1fbf2c..5413421 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,14 +4,16 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { readFileSync } from "fs"; import { mkdir, writeFile } from "fs/promises"; import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; import { z } from "zod"; -import { startEmbeddingTracker } from "./core/embedding-tracker.js"; -import { isBrokenPipeError, runCleanup } from "./core/process-lifecycle.js"; +import { createEmbeddingTrackerController } from "./core/embedding-tracker.js"; +import { createIdleMonitor, getIdleShutdownMs, getParentPollMs, isBrokenPipeError, runCleanup, startParentMonitor } from "./core/process-lifecycle.js"; import { getContextTree } from "./tools/context-tree.js"; import { getFileSkeleton } from "./tools/file-skeleton.js"; -import { ensureMcpDataDir } from "./core/embeddings.js"; +import { ensureMcpDataDir, cancelAllEmbeddings } from "./core/embeddings.js"; import { semanticCodeSearch, invalidateSearchCache } from "./tools/semantic-search.js"; import { semanticIdentifierSearch, invalidateIdentifierSearchCache } from "./tools/semantic-identifiers.js"; import { getBlastRadius } from "./tools/blast-radius.js"; @@ -39,6 +41,27 @@ const ROOT_DIR = passthroughArgs[0] && !SUB_COMMANDS.includes(passthroughArgs[0] : process.cwd(); const INSTRUCTIONS_SOURCE_URL = "https://contextplus.vercel.app/api/instructions"; const INSTRUCTIONS_RESOURCE_URI = "contextplus://instructions"; +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +let agentInstructions: string | undefined; +try { + agentInstructions = readFileSync(resolve(PACKAGE_ROOT, "agent-instructions.md"), "utf8"); +} catch { + // agent-instructions.md not found, continuing without manifest instructions +} + +let noteServerActivity = () => { }; +let ensureTrackerRunning = () => { }; + +function withRequestActivity( + handler: (args: TArgs) => Promise, + options?: { useEmbeddingTracker?: boolean }, +): (args: TArgs) => Promise { + return async (args: TArgs): Promise => { + noteServerActivity(); + if (options?.useEmbeddingTracker) ensureTrackerRunning(); + return handler(args); + }; +} function parseAgentTarget(input?: string): AgentTarget { const normalized = (input ?? "claude").toLowerCase(); @@ -82,7 +105,7 @@ function buildMcpConfig(runner: "npx" | "bunx") { OLLAMA_CHAT_MODEL: "gemma2:27b", OLLAMA_API_KEY: "YOUR_OLLAMA_API_KEY", CONTEXTPLUS_EMBED_BATCH_SIZE: "8", - CONTEXTPLUS_EMBED_TRACKER: "true", + CONTEXTPLUS_EMBED_TRACKER: "lazy", }, }, }, @@ -107,7 +130,7 @@ function buildOpenCodeConfig(runner: "npx" | "bunx") { OLLAMA_CHAT_MODEL: "gemma2:27b", OLLAMA_API_KEY: "YOUR_OLLAMA_API_KEY", CONTEXTPLUS_EMBED_BATCH_SIZE: "8", - CONTEXTPLUS_EMBED_TRACKER: "true", + CONTEXTPLUS_EMBED_TRACKER: "lazy", }, }, }, @@ -134,12 +157,13 @@ const server = new McpServer({ version: "1.0.0", }, { capabilities: { logging: {} }, + ...(agentInstructions && { instructions: agentInstructions }), }); server.resource( "contextplus_instructions", INSTRUCTIONS_RESOURCE_URI, - async (uri) => { + withRequestActivity(async (uri) => { const response = await fetch(INSTRUCTIONS_SOURCE_URL); return { contents: [{ @@ -148,7 +172,7 @@ server.resource( text: await response.text(), }], }; - }, + }), ); server.tool( @@ -162,7 +186,7 @@ server.tool( include_symbols: z.boolean().optional().describe("Include function/class/enum names in the tree. Defaults to true."), max_tokens: z.number().optional().describe("Maximum tokens for output. Auto-prunes if exceeded. Default: 20000."), }, - async ({ target_path, depth_limit, include_symbols, max_tokens }) => ({ + withRequestActivity(async ({ target_path, depth_limit, include_symbols, max_tokens }) => ({ content: [{ type: "text" as const, text: await getContextTree({ @@ -173,7 +197,7 @@ server.tool( maxTokens: max_tokens, }), }], - }), + })), ); server.tool( @@ -188,7 +212,7 @@ server.tool( semantic_weight: z.number().optional().describe("Weight for semantic similarity score. Default: 0.78."), keyword_weight: z.number().optional().describe("Weight for keyword overlap score. Default: 0.22."), }, - async ({ query, top_k, top_calls_per_identifier, include_kinds, semantic_weight, keyword_weight }) => ({ + withRequestActivity(async ({ query, top_k, top_calls_per_identifier, include_kinds, semantic_weight, keyword_weight }) => ({ content: [{ type: "text" as const, text: await semanticIdentifierSearch({ @@ -201,7 +225,7 @@ server.tool( keywordWeight: keyword_weight, }), }], - }), + }), { useEmbeddingTracker: true }), ); server.tool( @@ -211,12 +235,12 @@ server.tool( { file_path: z.string().describe("Path to the file to inspect (relative to project root)."), }, - async ({ file_path }) => ({ + withRequestActivity(async ({ file_path }) => ({ content: [{ type: "text" as const, text: await getFileSkeleton({ rootDir: ROOT_DIR, filePath: file_path }), }], - }), + })), ); server.tool( @@ -234,7 +258,7 @@ server.tool( require_keyword_match: z.boolean().optional().describe("When true, only return files with keyword overlap."), require_semantic_match: z.boolean().optional().describe("When true, only return files with positive semantic similarity."), }, - async ({ + withRequestActivity(async ({ query, top_k, semantic_weight, @@ -260,7 +284,7 @@ server.tool( requireSemanticMatch: require_semantic_match, }), }], - }), + }), { useEmbeddingTracker: true }), ); server.tool( @@ -271,12 +295,12 @@ server.tool( symbol_name: z.string().describe("The function, class, or variable name to trace across the codebase."), file_context: z.string().optional().describe("The file where the symbol is defined. Excludes the definition line from results."), }, - async ({ symbol_name, file_context }) => ({ + withRequestActivity(async ({ symbol_name, file_context }) => ({ content: [{ type: "text" as const, text: await getBlastRadius({ rootDir: ROOT_DIR, symbolName: symbol_name, fileContext: file_context }), }], - }), + })), ); server.tool( @@ -286,12 +310,12 @@ server.tool( { target_path: z.string().optional().describe("Specific file or folder to lint (relative to root). Omit for full project."), }, - async ({ target_path }) => ({ + withRequestActivity(async ({ target_path }) => ({ content: [{ type: "text" as const, text: await runStaticAnalysis({ rootDir: ROOT_DIR, targetPath: target_path }), }], - }), + })), ); server.tool( @@ -303,7 +327,7 @@ server.tool( file_path: z.string().describe("Where to save the file (relative to project root)."), new_content: z.string().describe("The complete file content to save."), }, - async ({ file_path, new_content }) => { + withRequestActivity(async ({ file_path, new_content }) => { invalidateSearchCache(); invalidateIdentifierSearchCache(); return { @@ -312,7 +336,7 @@ server.tool( text: await proposeCommit({ rootDir: ROOT_DIR, filePath: file_path, newContent: new_content }), }], }; - }, + }), ); server.tool( @@ -320,7 +344,7 @@ server.tool( "List all shadow restore points created by propose_commit. Each point captures the file state before the AI made changes. " + "Use this to find a restore point ID for undoing a bad change.", {}, - async () => { + withRequestActivity(async () => { const points = await listRestorePoints(ROOT_DIR); if (points.length === 0) return { content: [{ type: "text" as const, text: "No restore points found." }] }; @@ -328,7 +352,7 @@ server.tool( `${p.id} | ${new Date(p.timestamp).toISOString()} | ${p.files.join(", ")} | ${p.message}`, ); return { content: [{ type: "text" as const, text: `Restore Points (${points.length}):\n\n${lines.join("\n")}` }] }; - }, + }), ); server.tool( @@ -338,7 +362,7 @@ server.tool( { point_id: z.string().describe("The restore point ID (format: rp-timestamp-hash). Get from list_restore_points."), }, - async ({ point_id }) => { + withRequestActivity(async ({ point_id }) => { const restored = await restorePoint(ROOT_DIR, point_id); invalidateSearchCache(); invalidateIdentifierSearchCache(); @@ -350,7 +374,7 @@ server.tool( : "No files were restored. The backup may be empty.", }], }; - }, + }), ); server.tool( @@ -362,12 +386,12 @@ server.tool( max_depth: z.number().optional().describe("Maximum nesting depth of clusters. Default: 3."), max_clusters: z.number().optional().describe("Maximum sub-clusters per level. Default: 20."), }, - async ({ max_depth, max_clusters }) => ({ + withRequestActivity(async ({ max_depth, max_clusters }) => ({ content: [{ type: "text" as const, text: await semanticNavigate({ rootDir: ROOT_DIR, maxDepth: max_depth, maxClusters: max_clusters }), }], - }), + })), ); server.tool( @@ -380,7 +404,7 @@ server.tool( feature_name: z.string().optional().describe("Feature name to search for. Finds matching hub file automatically."), show_orphans: z.boolean().optional().describe("If true, lists all source files not linked to any feature hub."), }, - async ({ hub_path, feature_name, show_orphans }) => ({ + withRequestActivity(async ({ hub_path, feature_name, show_orphans }) => ({ content: [{ type: "text" as const, text: await getFeatureHub({ @@ -390,7 +414,7 @@ server.tool( showOrphans: show_orphans, }), }], - }), + })), ); server.tool( @@ -403,12 +427,12 @@ server.tool( content: z.string().describe("Detailed content for the node. Used for embedding generation."), metadata: z.record(z.string()).optional().describe("Optional key-value metadata pairs."), }, - async ({ type, label, content, metadata }) => ({ + withRequestActivity(async ({ type, label, content, metadata }) => ({ content: [{ type: "text" as const, text: await toolUpsertMemoryNode({ rootDir: ROOT_DIR, type, label, content, metadata }), }], - }), + })), ); server.tool( @@ -422,12 +446,12 @@ server.tool( weight: z.number().optional().describe("Edge weight 0-1. Higher = stronger relationship. Default: 1.0."), metadata: z.record(z.string()).optional().describe("Optional key-value metadata for the edge."), }, - async ({ source_id, target_id, relation, weight, metadata }) => ({ + withRequestActivity(async ({ source_id, target_id, relation, weight, metadata }) => ({ content: [{ type: "text" as const, text: await toolCreateRelation({ rootDir: ROOT_DIR, sourceId: source_id, targetId: target_id, relation, weight, metadata }), }], - }), + })), ); server.tool( @@ -441,12 +465,12 @@ server.tool( edge_filter: z.array(z.enum(["relates_to", "depends_on", "implements", "references", "similar_to", "contains"])).optional() .describe("Only traverse edges of these types. Omit for all types."), }, - async ({ query, max_depth, top_k, edge_filter }) => ({ + withRequestActivity(async ({ query, max_depth, top_k, edge_filter }) => ({ content: [{ type: "text" as const, text: await toolSearchMemoryGraph({ rootDir: ROOT_DIR, query, maxDepth: max_depth, topK: top_k, edgeFilter: edge_filter }), }], - }), + })), ); server.tool( @@ -456,12 +480,12 @@ server.tool( { threshold: z.number().optional().describe("Minimum decayed weight to keep an edge. Default: 0.15. Lower = keep more edges."), }, - async ({ threshold }) => ({ + withRequestActivity(async ({ threshold }) => ({ content: [{ type: "text" as const, text: await toolPruneStaleLinks({ rootDir: ROOT_DIR, threshold }), }], - }), + })), ); server.tool( @@ -478,12 +502,12 @@ server.tool( })).describe("Array of nodes to add. Each needs type, label, and content."), auto_link: z.boolean().optional().describe("Whether to auto-create similarity edges. Default: true."), }, - async ({ items, auto_link }) => ({ + withRequestActivity(async ({ items, auto_link }) => ({ content: [{ type: "text" as const, text: await toolAddInterlinkedContext({ rootDir: ROOT_DIR, items, autoLink: auto_link }), }], - }), + })), ); server.tool( @@ -496,12 +520,12 @@ server.tool( edge_filter: z.array(z.enum(["relates_to", "depends_on", "implements", "references", "similar_to", "contains"])).optional() .describe("Only traverse edges of these types. Omit for all."), }, - async ({ start_node_id, max_depth, edge_filter }) => ({ + withRequestActivity(async ({ start_node_id, max_depth, edge_filter }) => ({ content: [{ type: "text" as const, text: await toolRetrieveWithTraversal({ rootDir: ROOT_DIR, startNodeId: start_node_id, maxDepth: max_depth, edgeFilter: edge_filter }), }], - }), + })), ); async function main() { @@ -521,18 +545,26 @@ async function main() { return; } await ensureMcpDataDir(ROOT_DIR); - const trackerEnabled = (process.env.CONTEXTPLUS_EMBED_TRACKER ?? "true").toLowerCase() !== "false"; - const stopTracker = trackerEnabled - ? startEmbeddingTracker({ - rootDir: ROOT_DIR, - debounceMs: Number.parseInt(process.env.CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS ?? "700", 10), - maxFilesPerTick: Number.parseInt(process.env.CONTEXTPLUS_EMBED_TRACKER_MAX_FILES ?? "8", 10), - }) - : () => { }; + const trackerController = createEmbeddingTrackerController({ + rootDir: ROOT_DIR, + mode: process.env.CONTEXTPLUS_EMBED_TRACKER, + debounceMs: Number.parseInt(process.env.CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS ?? "700", 10), + maxFilesPerTick: Number.parseInt(process.env.CONTEXTPLUS_EMBED_TRACKER_MAX_FILES ?? "8", 10), + }); const transport = new StdioServerTransport(); await server.connect(transport); let shuttingDown = false; + let stopParentMonitor = () => { }; + const idleMonitor = createIdleMonitor({ + timeoutMs: getIdleShutdownMs(process.env.CONTEXTPLUS_IDLE_TIMEOUT_MS), + onIdle: () => requestShutdown("idle-timeout", 0), + isTransportAlive: () => process.stdin.readable && !process.stdin.destroyed, + }); + + noteServerActivity = idleMonitor.touch; + ensureTrackerRunning = trackerController.ensureStarted; + const closeServer = async () => { const closable = server as unknown as { close?: () => Promise | void }; if (typeof closable.close === "function") { @@ -549,16 +581,37 @@ async function main() { if (shuttingDown) return; shuttingDown = true; console.error(`Context+ MCP shutdown requested: ${reason}`); - await runCleanup({ stopTracker, closeServer, closeTransport }); + await runCleanup({ + cancelEmbeddings: cancelAllEmbeddings, + stopTracker: trackerController.stop, + closeServer, + closeTransport, + stopMonitors: () => { + idleMonitor.stop(); + stopParentMonitor(); + }, + }); process.exit(exitCode); }; const requestShutdown = (reason: string, exitCode: number = 0) => { void shutdown(reason, exitCode); }; + stopParentMonitor = startParentMonitor({ + parentPid: process.ppid, + pollIntervalMs: getParentPollMs(process.env.CONTEXTPLUS_PARENT_POLL_MS), + onParentExit: () => requestShutdown("parent-exit", 0), + }); + process.once("SIGINT", () => requestShutdown("SIGINT", 0)); process.once("SIGTERM", () => requestShutdown("SIGTERM", 0)); - process.once("exit", () => stopTracker()); + process.once("SIGHUP", () => requestShutdown("SIGHUP", 0)); + process.once("disconnect", () => requestShutdown("disconnect", 0)); + process.once("exit", () => { + idleMonitor.stop(); + stopParentMonitor(); + trackerController.stop(); + }); process.stdin.once("end", () => requestShutdown("stdin-end", 0)); process.stdin.once("close", () => requestShutdown("stdin-close", 0)); process.stdin.once("error", (error) => { @@ -571,6 +624,7 @@ async function main() { if (isBrokenPipeError(error)) requestShutdown("stderr-error", 0); }); + noteServerActivity(); console.error(`Context+ MCP server running on stdio | root: ${ROOT_DIR}`); } diff --git a/src/tools/semantic-navigate.ts b/src/tools/semantic-navigate.ts index 0d45b65..b99d74e 100644 --- a/src/tools/semantic-navigate.ts +++ b/src/tools/semantic-navigate.ts @@ -1,12 +1,12 @@ -// Semantic project navigator using spectral clustering and Ollama labeling +// Semantic project navigator using spectral clustering and provider-agnostic labeling // Browse codebase by meaning: embeds files, clusters vectors, generates labels -import { Ollama } from "ollama"; import { walkDirectory } from "../core/walker.js"; import { analyzeFile, flattenSymbols, isSupportedFile } from "../core/parser.js"; import { fetchEmbedding } from "../core/embeddings.js"; import { readFile } from "fs/promises"; import { spectralCluster, findPathPattern } from "../core/clustering.js"; +import { extname } from "path"; export interface SemanticNavigateOptions { rootDir: string; @@ -28,18 +28,73 @@ interface ClusterNode { children: ClusterNode[]; } +const EMBED_PROVIDER = (process.env.CONTEXTPLUS_EMBED_PROVIDER ?? "ollama").toLowerCase(); const EMBED_MODEL = process.env.OLLAMA_EMBED_MODEL ?? "nomic-embed-text"; const CHAT_MODEL = process.env.OLLAMA_CHAT_MODEL ?? "llama3.2"; +const OPENAI_CHAT_MODEL = process.env.CONTEXTPLUS_OPENAI_CHAT_MODEL ?? process.env.OPENAI_CHAT_MODEL ?? "gpt-4o-mini"; +const OPENAI_API_KEY = process.env.CONTEXTPLUS_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY ?? ""; +const OPENAI_BASE_URL = process.env.CONTEXTPLUS_OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"; const MAX_FILES_PER_LEAF = 20; - -const ollama = new Ollama({ host: process.env.OLLAMA_HOST }); +const NON_CODE_NAVIGATE_EXTENSIONS = new Set([ + ".json", + ".jsonc", + ".geojson", + ".csv", + ".tsv", + ".ndjson", + ".yaml", + ".yml", + ".toml", + ".lock", + ".env", +]); + +type OllamaChatClient = { chat: (params: Record) => Promise<{ message: { content: string } }> }; +let ollamaClient: OllamaChatClient | null = null; + +async function getOllamaClient(): Promise { + if (!ollamaClient) { + const { Ollama } = await import("ollama"); + ollamaClient = new Ollama({ host: process.env.OLLAMA_HOST }) as unknown as OllamaChatClient; + } + return ollamaClient; +} async function fetchEmbeddings(inputs: string[]): Promise { return fetchEmbedding(inputs); } +function isNavigableSourceCandidate(filePath: string): boolean { + return isSupportedFile(filePath) && !NON_CODE_NAVIGATE_EXTENSIONS.has(extname(filePath).toLowerCase()); +} + async function chatCompletion(prompt: string): Promise { - const response = await ollama.chat({ + if (EMBED_PROVIDER === "openai") { + const url = `${OPENAI_BASE_URL.replace(/\/+$/, "")}/chat/completions`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: OPENAI_CHAT_MODEL, + messages: [{ role: "user", content: prompt }], + stream: false, + }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`OpenAI chat API error ${response.status}: ${body}`); + } + + const data = await response.json() as { choices: { message: { content: string } }[] }; + return data.choices[0]?.message?.content ?? ""; + } + + const client = await getOllamaClient(); + const response = await client.chat({ model: CHAT_MODEL, messages: [{ role: "user", content: prompt }], stream: false, @@ -47,6 +102,30 @@ async function chatCompletion(prompt: string): Promise { return response.message.content; } +async function embedFilesWithFallback(files: FileInfo[]): Promise<{ files: FileInfo[]; vectors: number[][]; skipped: number }> { + if (files.length === 0) return { files: [], vectors: [], skipped: 0 }; + const texts = files.map((file) => `${file.header} ${file.relativePath} ${file.content}`); + + try { + return { files, vectors: await fetchEmbeddings(texts), skipped: 0 }; + } catch (error) { + const keptFiles: FileInfo[] = []; + const vectors: number[][] = []; + + for (let i = 0; i < files.length; i++) { + try { + const [vector] = await fetchEmbeddings([texts[i]]); + keptFiles.push(files[i]); + vectors.push(vector); + } catch { + } + } + + if (keptFiles.length === 0) throw error; + return { files: keptFiles, vectors, skipped: files.length - keptFiles.length }; + } +} + function extractHeader(content: string): string { const lines = content.split("\n"); const headerLines: string[] = []; @@ -81,7 +160,7 @@ async function labelSiblingClusters(clusters: { files: FileInfo[]; pathPattern: const prompt = `You are labeling clusters of code files. For each cluster below, produce EXACTLY one JSON array of objects, each with: - "overarchingTheme": a sentence about the cluster's theme -- "distinguishingFeature": what makes this cluster unique vs siblings +- "distinguishingFeature": what makes this cluster unique vs siblings - "label": EXACTLY 2 words describing the cluster ${clusterDescriptions.join("\n\n")} @@ -175,7 +254,7 @@ export async function semanticNavigate(options: SemanticNavigateOptions): Promis const maxDepth = options.maxDepth ?? 3; const entries = await walkDirectory({ rootDir: options.rootDir, depthLimit: 0 }); - const fileEntries = entries.filter((e) => !e.isDirectory && isSupportedFile(e.path)); + const fileEntries = entries.filter((e) => !e.isDirectory && isNavigableSourceCandidate(e.path)); if (fileEntries.length === 0) return "No supported source files found in the project."; @@ -205,36 +284,51 @@ export async function semanticNavigate(options: SemanticNavigateOptions): Promis if (files.length === 0) return "Could not read any source files."; - const embedTexts = files.map((f) => `${f.header} ${f.relativePath} ${f.content}`); - - let vectors: number[][]; + let embeddableFiles: FileInfo[] = files; + let vectors: number[][] = []; + let skippedForEmbedding = 0; try { - vectors = await fetchEmbeddings(embedTexts); + const embedded = await embedFilesWithFallback(files); + embeddableFiles = embedded.files; + vectors = embedded.vectors; + skippedForEmbedding = embedded.skipped; } catch (err) { - return `Ollama not available for embeddings: ${err instanceof Error ? err.message : String(err)}\nMake sure Ollama is running or signed in (ollama signin) with model ${EMBED_MODEL}.`; + const providerHint = EMBED_PROVIDER === "openai" + ? `Check CONTEXTPLUS_OPENAI_API_KEY and CONTEXTPLUS_OPENAI_BASE_URL.` + : `Make sure Ollama is running (check OLLAMA_HOST) and that the embedding model configured in OLLAMA_EMBED_MODEL is available.`; + return `Embedding provider (${EMBED_PROVIDER}) not available: ${err instanceof Error ? err.message : String(err)}\n${providerHint}`; } - if (files.length <= MAX_FILES_PER_LEAF) { + if (embeddableFiles.length === 0) return "No embeddable source files found in the project."; + + if (embeddableFiles.length <= MAX_FILES_PER_LEAF) { let fileLabels: string[]; try { - const prompt = `For each file below, produce a 3-7 word description. Return ONLY a JSON array of strings.\n\n${files.map((f) => `${f.relativePath}: ${f.header}`).join("\n")}`; + const prompt = `For each file below, produce a 3-7 word description. Return ONLY a JSON array of strings.\n\n${embeddableFiles.map((f) => `${f.relativePath}: ${f.header}`).join("\n")}`; const response = await chatCompletion(prompt); const match = response.match(/\[[\s\S]*\]/); - fileLabels = match ? JSON.parse(match[0]) : files.map((f) => f.header); + fileLabels = match ? JSON.parse(match[0]) : embeddableFiles.map((f) => f.header); } catch { - fileLabels = files.map((f) => f.header); + fileLabels = embeddableFiles.map((f) => f.header); } - const lines = [`Semantic Navigator: ${files.length} files\n`]; - for (let i = 0; i < files.length; i++) { - const symbols = files[i].symbolPreview.length > 0 ? ` | symbols: ${files[i].symbolPreview.join(", ")}` : ""; - lines.push(` ${files[i].relativePath} - ${fileLabels[i] || files[i].header}${symbols}`); + const summary = skippedForEmbedding > 0 + ? `Semantic Navigator: ${embeddableFiles.length} files (${skippedForEmbedding} skipped due embedding limits)\n` + : `Semantic Navigator: ${embeddableFiles.length} files\n`; + const lines = [summary]; + for (let i = 0; i < embeddableFiles.length; i++) { + const symbols = embeddableFiles[i].symbolPreview.length > 0 ? ` | symbols: ${embeddableFiles[i].symbolPreview.join(", ")}` : ""; + lines.push(` ${embeddableFiles[i].relativePath} - ${fileLabels[i] || embeddableFiles[i].header}${symbols}`); } return lines.join("\n"); } - const tree = await buildHierarchy(files, vectors, maxClusters, 0, maxDepth); + const tree = await buildHierarchy(embeddableFiles, vectors, maxClusters, 0, maxDepth); tree.label = "Project"; - return `Semantic Navigator: ${files.length} files organized by meaning\n\n${renderClusterTree(tree)}`; + const summary = skippedForEmbedding > 0 + ? `Semantic Navigator: ${embeddableFiles.length} files organized by meaning (${skippedForEmbedding} skipped due embedding limits)` + : `Semantic Navigator: ${embeddableFiles.length} files organized by meaning`; + + return `${summary}\n\n${renderClusterTree(tree)}`; } diff --git a/src/tools/semantic-search.ts b/src/tools/semantic-search.ts index 2c67176..c511e81 100644 --- a/src/tools/semantic-search.ts +++ b/src/tools/semantic-search.ts @@ -12,7 +12,7 @@ import { type SearchDocument, type SearchQueryOptions, } from "../core/embeddings.js"; -import { readFile } from "fs/promises"; +import { readFile, stat } from "fs/promises"; import { extname, resolve } from "path"; export interface SemanticSearchOptions { @@ -34,13 +34,38 @@ let lastIndexTime = 0; const INDEX_TTL_MS = 60000; const SEARCH_CACHE_FILE = "embeddings-cache.json"; -const TEXT_INDEX_EXTENSIONS = new Set([".md", ".txt", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".lock", ".env"]); +const TEXT_INDEX_EXTENSIONS = new Set([ + ".md", + ".txt", + ".json", + ".jsonc", + ".geojson", + ".csv", + ".tsv", + ".ndjson", + ".yaml", + ".yml", + ".toml", + ".lock", + ".env", +]); const MAX_TEXT_DOC_CHARS = 4000; +const DEFAULT_MAX_EMBED_FILE_SIZE = 50 * 1024; function isTextIndexCandidate(filePath: string): boolean { return TEXT_INDEX_EXTENSIONS.has(extname(filePath).toLowerCase()); } +function toIntegerOr(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function getMaxEmbedFileSize(): number { + return Math.max(1024, toIntegerOr(process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE, DEFAULT_MAX_EMBED_FILE_SIZE)); +} + function extractPlainTextHeader(content: string): string { const lines = content.split("\n"); const headerLines: string[] = []; @@ -67,38 +92,39 @@ async function buildSearchDocumentForFile(rootDir: string, relativePath: string) const normalized = normalizeRelativePath(relativePath); const fullPath = resolve(rootDir, normalized); - if (isSupportedFile(fullPath)) { + if (isTextIndexCandidate(fullPath)) { try { - const analysis = await analyzeFile(fullPath); - const flatSymbols = flattenSymbols(analysis.symbols); + if ((await stat(fullPath)).size > getMaxEmbedFileSize()) return null; + const raw = await readFile(fullPath, "utf-8"); + const content = raw.slice(0, MAX_TEXT_DOC_CHARS); return { path: normalized, - header: analysis.header, - symbols: flatSymbols.map((s) => s.name), - symbolEntries: flatSymbols.map((s) => ({ - name: s.name, - kind: s.kind, - line: s.line, - endLine: s.endLine, - signature: s.signature, - })), - content: flatSymbols.map((s) => s.signature).join(" "), + header: extractPlainTextHeader(content), + symbols: [], + content, }; } catch { return null; } } - if (!isTextIndexCandidate(fullPath)) return null; + if (!isSupportedFile(fullPath)) return null; try { - const raw = await readFile(fullPath, "utf-8"); - const content = raw.slice(0, MAX_TEXT_DOC_CHARS); + const analysis = await analyzeFile(fullPath); + const flatSymbols = flattenSymbols(analysis.symbols); return { path: normalized, - header: extractPlainTextHeader(content), - symbols: [], - content, + header: analysis.header, + symbols: flatSymbols.map((s) => s.name), + symbolEntries: flatSymbols.map((s) => ({ + name: s.name, + kind: s.kind, + line: s.line, + endLine: s.endLine, + signature: s.signature, + })), + content: flatSymbols.map((s) => s.signature).join(" "), }; } catch { return null; diff --git a/src/tools/static-analysis.ts b/src/tools/static-analysis.ts index 48bf883..208e427 100644 --- a/src/tools/static-analysis.ts +++ b/src/tools/static-analysis.ts @@ -1,12 +1,12 @@ // Static analysis runner using native linters and compilers // Delegates dead code detection to deterministic tools, not LLM guessing -import { exec } from "child_process"; +import { execFile } from "child_process"; import { stat } from "fs/promises"; import { resolve, extname } from "path"; import { promisify } from "util"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); export interface StaticAnalysisOptions { rootDir: string; @@ -29,9 +29,8 @@ const LINTER_MAP: Record = { }; async function runCommand(cmd: string, args: string[], cwd: string): Promise { - const fullCmd = `${cmd} ${args.join(" ")}`; try { - const { stdout, stderr } = await execAsync(fullCmd, { cwd, timeout: 30000, maxBuffer: 1024 * 512 }); + const { stdout, stderr } = await execFileAsync(cmd, args, { cwd, timeout: 30000, maxBuffer: 1024 * 512 }); return { tool: cmd, output: (stdout + stderr).trim(), exitCode: 0 }; } catch (err: any) { return { tool: cmd, output: (err.stdout ?? "") + (err.stderr ?? ""), exitCode: err.code ?? 1 }; diff --git a/test/main/embedding-tracker.test.mjs b/test/main/embedding-tracker.test.mjs index 352e2fa..2c47dba 100644 --- a/test/main/embedding-tracker.test.mjs +++ b/test/main/embedding-tracker.test.mjs @@ -1,7 +1,14 @@ +// Embedding tracker controller tests cover lazy startup and shutdown modes +// FEATURE: Verifies watcher creation only occurs when explicitly needed + import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { + createEmbeddingTrackerController, + parseEmbeddingTrackerMode, +} from "../../build/core/embedding-tracker.js"; -describe("embedding-tracker", () => { +describe("embedding-tracker controller", () => { it("exports startEmbeddingTracker", async () => { const mod = await import("../../build/core/embedding-tracker.js"); assert.equal(typeof mod.startEmbeddingTracker, "function"); @@ -11,4 +18,66 @@ describe("embedding-tracker", () => { const mod = await import("../../build/core/embedding-tracker.js"); assert.equal(mod.startEmbeddingTracker.length, 1); }); + + it("parses tracker modes with lazy as the safe default", () => { + assert.equal(parseEmbeddingTrackerMode(undefined), "lazy"); + assert.equal(parseEmbeddingTrackerMode("true"), "lazy"); + assert.equal(parseEmbeddingTrackerMode("lazy"), "lazy"); + assert.equal(parseEmbeddingTrackerMode("eager"), "eager"); + assert.equal(parseEmbeddingTrackerMode("off"), "off"); + }); + + it("defers tracker startup in lazy mode", () => { + let starts = 0; + let stops = 0; + const controller = createEmbeddingTrackerController({ + rootDir: ".", + mode: "true", + starter: () => { + starts += 1; + return () => { + stops += 1; + }; + }, + }); + + assert.equal(starts, 0); + assert.equal(controller.isRunning(), false); + controller.ensureStarted(); + controller.ensureStarted(); + assert.equal(starts, 1); + assert.equal(controller.isRunning(), true); + controller.stop(); + assert.equal(stops, 1); + assert.equal(controller.isRunning(), false); + }); + + it("starts immediately in eager mode and never starts when disabled", () => { + let eagerStarts = 0; + const eager = createEmbeddingTrackerController({ + rootDir: ".", + mode: "eager", + starter: () => { + eagerStarts += 1; + return () => {}; + }, + }); + + assert.equal(eagerStarts, 1); + assert.equal(eager.isRunning(), true); + + let disabledStarts = 0; + const disabled = createEmbeddingTrackerController({ + rootDir: ".", + mode: "false", + starter: () => { + disabledStarts += 1; + return () => {}; + }, + }); + + disabled.ensureStarted(); + assert.equal(disabledStarts, 0); + assert.equal(disabled.isRunning(), false); + }); }); diff --git a/test/main/embeddings.test.mjs b/test/main/embeddings.test.mjs index 82c14ad..3aedbd2 100644 --- a/test/main/embeddings.test.mjs +++ b/test/main/embeddings.test.mjs @@ -1,6 +1,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { Ollama } from "ollama"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { SearchIndex, fetchEmbedding, @@ -40,6 +43,53 @@ describe("embeddings", () => { const index = new SearchIndex(); assert.equal(typeof index.getDocumentCount, "function"); }); + + it("re-embeds when content changes beyond first 8000 characters", async () => { + const originalEmbed = Ollama.prototype.embed; + const rootDir = await mkdtemp(join(tmpdir(), "contextplus-embed-")); + let callCount = 0; + Ollama.prototype.embed = async function ({ input }) { + const batch = Array.isArray(input) ? input : [input]; + for (const value of batch) { + if (value.length > 8000) + throw new Error("input length exceeds context length"); + } + callCount += batch.length; + return { embeddings: batch.map(() => [1, 0, 0]) }; + }; + + try { + const index = new SearchIndex(); + const sharedPrefix = "x".repeat(8500); + const firstDoc = [ + { + path: "src/long.ts", + header: "header", + symbols: ["alpha"], + content: `${sharedPrefix} tail_one`, + }, + ]; + const secondDoc = [ + { + path: "src/long.ts", + header: "header", + symbols: ["alpha"], + content: `${sharedPrefix} tail_two`, + }, + ]; + + await index.index(firstDoc, rootDir); + const firstPassCalls = callCount; + assert.ok(firstPassCalls > 0); + + callCount = 0; + await index.index(secondDoc, rootDir); + assert.ok(callCount > 0); + } finally { + Ollama.prototype.embed = originalEmbed; + await rm(rootDir, { recursive: true, force: true }); + } + }); }); describe("fetchEmbedding", () => { @@ -85,5 +135,108 @@ describe("embeddings", () => { Ollama.prototype.embed = originalEmbed; } }); + + it("splits oversized text into chunks and merges vectors", async () => { + const originalEmbed = Ollama.prototype.embed; + const tailMarker = "__tail_marker__"; + const seenLengths = []; + + Ollama.prototype.embed = async function ({ input }) { + const batch = Array.isArray(input) ? input : [input]; + for (const value of batch) { + seenLengths.push(value.length); + if (value.length > 8000) + throw new Error("input length exceeds context length"); + } + return { + embeddings: batch.map((value) => [ + value.includes(tailMarker) ? 10 : 1, + ]), + }; + }; + + try { + const vectors = await fetchEmbedding( + `${"a".repeat(9000)}${tailMarker}${"b".repeat(1000)}`, + ); + assert.equal(vectors.length, 1); + assert.ok(vectors[0][0] > 1); + assert.ok(seenLengths.every((length) => length <= 8000)); + assert.ok(seenLengths.length > 1); + } finally { + Ollama.prototype.embed = originalEmbed; + } + }); + + it("keeps shrinking under strict context limits beyond eight retries", async () => { + const originalEmbed = Ollama.prototype.embed; + const seenLengths = []; + + Ollama.prototype.embed = async function ({ input }) { + const batch = Array.isArray(input) ? input : [input]; + seenLengths.push(...batch.map((value) => value.length)); + if (batch.some((value) => value.length > 20)) { + throw new Error("input length exceeds context length"); + } + return { embeddings: batch.map((value) => [value.length]) }; + }; + + try { + const vectors = await fetchEmbedding("x".repeat(8000)); + assert.equal(vectors.length, 1); + assert.ok(vectors[0][0] <= 20); + assert.ok(seenLengths.length > 9); + } finally { + Ollama.prototype.embed = originalEmbed; + } + }); + + it("forwards configured embed runtime options to Ollama", async () => { + const originalEmbed = Ollama.prototype.embed; + const previousEnv = { + CONTEXTPLUS_EMBED_NUM_GPU: process.env.CONTEXTPLUS_EMBED_NUM_GPU, + CONTEXTPLUS_EMBED_MAIN_GPU: process.env.CONTEXTPLUS_EMBED_MAIN_GPU, + CONTEXTPLUS_EMBED_NUM_THREAD: process.env.CONTEXTPLUS_EMBED_NUM_THREAD, + CONTEXTPLUS_EMBED_NUM_BATCH: process.env.CONTEXTPLUS_EMBED_NUM_BATCH, + CONTEXTPLUS_EMBED_NUM_CTX: process.env.CONTEXTPLUS_EMBED_NUM_CTX, + CONTEXTPLUS_EMBED_LOW_VRAM: process.env.CONTEXTPLUS_EMBED_LOW_VRAM, + }; + const requests = []; + + process.env.CONTEXTPLUS_EMBED_NUM_GPU = "1"; + process.env.CONTEXTPLUS_EMBED_MAIN_GPU = "0"; + process.env.CONTEXTPLUS_EMBED_NUM_THREAD = "6"; + process.env.CONTEXTPLUS_EMBED_NUM_BATCH = "64"; + process.env.CONTEXTPLUS_EMBED_NUM_CTX = "4096"; + process.env.CONTEXTPLUS_EMBED_LOW_VRAM = "true"; + + Ollama.prototype.embed = async function (request) { + requests.push(request); + const batch = Array.isArray(request.input) + ? request.input + : [request.input]; + return { embeddings: batch.map((value) => [value.length]) }; + }; + + try { + const vectors = await fetchEmbedding("gpu options probe"); + assert.equal(vectors.length, 1); + assert.ok(requests.length > 0); + assert.deepEqual(requests[0].options, { + num_gpu: 1, + main_gpu: 0, + num_thread: 6, + num_batch: 64, + num_ctx: 4096, + low_vram: true, + }); + } finally { + Ollama.prototype.embed = originalEmbed; + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); }); }); diff --git a/test/main/idle-timeout-spawn.test.mjs b/test/main/idle-timeout-spawn.test.mjs new file mode 100644 index 0000000..3085f7a --- /dev/null +++ b/test/main/idle-timeout-spawn.test.mjs @@ -0,0 +1,147 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { resolve, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { + createIdleMonitor, +} from "../../build/core/process-lifecycle.js"; + +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function createTestScript(withFix) { + const buildPath = join(PROJECT_ROOT, "build/core/process-lifecycle.js").replace(/\\/g, "/"); + return ` + import { createIdleMonitor } from "file://${buildPath}"; + + const idleMonitor = createIdleMonitor({ + timeoutMs: 200, + onIdle: () => { + process.stderr.write("IDLE_SHUTDOWN\\n"); + process.exit(0); + }, + ${withFix ? 'isTransportAlive: () => process.stdin.readable && !process.stdin.destroyed,' : ''} + }); + + process.stderr.write("STARTED\\n"); + const keepAlive = setInterval(() => {}, 1000); + setTimeout(() => { + idleMonitor.stop(); + clearInterval(keepAlive); + process.stderr.write("SURVIVED\\n"); + process.exit(0); + }, 1500); + `; +} + +function runHarness(withFix) { + return new Promise((resolve) => { + const tmpDir = mkdtempSync(join(tmpdir(), "cp-test-")); + const scriptPath = join(tmpDir, "harness.mjs"); + writeFileSync(scriptPath, createTestScript(withFix)); + + const child = spawn("node", [scriptPath], { + stdio: ["pipe", "pipe", "pipe"], + }); + + let stderr = ""; + child.stderr.on("data", (d) => { stderr += d.toString(); }); + + child.on("exit", (code) => { + resolve({ code, stderr }); + }); + }); +} + +describe("idle-timeout transport-aware fix", () => { + it("does NOT fire onIdle when isTransportAlive returns true", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => true, + }); + await wait(80); + assert.equal(idleFired, 0, "onIdle should not fire when transport is alive"); + monitor.stop(); + }); + + it("fires onIdle when isTransportAlive returns false", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => false, + }); + await wait(80); + assert.equal(idleFired, 1, "onIdle should fire when transport is dead"); + monitor.stop(); + }); + + it("fires onIdle normally when no isTransportAlive provided (backward compat)", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + }); + await wait(80); + assert.equal(idleFired, 1, "onIdle should fire with no transport check"); + monitor.stop(); + }); + + it("reschedules then fires when transport dies after initial alive check", async () => { + let transportAlive = true; + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => transportAlive, + }); + await wait(50); + assert.equal(idleFired, 0, "should not fire while transport alive"); + transportAlive = false; + await wait(50); + assert.equal(idleFired, 1, "should fire after transport dies"); + monitor.stop(); + }); + + it("touch resets the idle timer even with transport check", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 40, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => false, + }); + await wait(20); + monitor.touch(); + await wait(20); + assert.equal(idleFired, 0, "touch should reset timer"); + await wait(30); + assert.equal(idleFired, 1, "should fire after full timeout post-touch"); + monitor.stop(); + }); + + it("spawn: without isTransportAlive, server exits on idle with stdin open", async () => { + const result = await runHarness(false); + assert.equal(result.code, 0); + assert.ok(result.stderr.includes("IDLE_SHUTDOWN"), + "server idle-shutdown with stdin open (no transport check)"); + assert.ok(!result.stderr.includes("SURVIVED"), + "server died before survival window"); + }); + + it("spawn: with isTransportAlive, server survives idle when stdin is open", async () => { + const result = await runHarness(true); + assert.equal(result.code, 0); + assert.ok(!result.stderr.includes("IDLE_SHUTDOWN"), + "server should NOT idle-shutdown when transport alive"); + assert.ok(result.stderr.includes("SURVIVED"), + "server should survive past idle timeout"); + }); +}); diff --git a/test/main/process-lifecycle.test.mjs b/test/main/process-lifecycle.test.mjs index a61b867..60a4d37 100644 --- a/test/main/process-lifecycle.test.mjs +++ b/test/main/process-lifecycle.test.mjs @@ -1,10 +1,18 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { + createIdleMonitor, + getIdleShutdownMs, isBrokenPipeError, + isProcessAlive, runCleanup, + startParentMonitor, } from "../../build/core/process-lifecycle.js"; +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + describe("process-lifecycle", () => { it("detects broken pipe style stream errors", () => { assert.equal(isBrokenPipeError({ code: "EPIPE" }), true); @@ -35,4 +43,87 @@ describe("process-lifecycle", () => { assert.equal(calls.includes("server"), true); assert.equal(calls.includes("transport"), true); }); + + it("stops monitors during cleanup", async () => { + const calls = []; + await runCleanup({ + stopTracker: () => { + calls.push("tracker"); + }, + stopMonitors: () => { + calls.push("monitors"); + }, + closeServer: async () => { + calls.push("server"); + }, + closeTransport: async () => { + calls.push("transport"); + }, + }); + assert.deepEqual(calls, ["monitors", "tracker", "server", "transport"]); + }); + + it("parses idle timeout values with disable support", () => { + assert.equal(getIdleShutdownMs(undefined), 900000); + assert.equal(getIdleShutdownMs("off"), 0); + assert.equal(getIdleShutdownMs("1000"), 60000); + }); + + it("checks process liveness through signal probing", () => { + assert.equal( + isProcessAlive(42, () => {}), + true, + ); + assert.equal( + isProcessAlive(42, () => { + throw { code: "ESRCH" }; + }), + false, + ); + assert.equal( + isProcessAlive(42, () => { + throw { code: "EPERM" }; + }), + true, + ); + }); + + it("fires idle monitor after inactivity", async () => { + let calls = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { + calls += 1; + }, + }); + + await wait(15); + monitor.touch(); + await wait(20); + assert.equal(calls, 0); + await wait(20); + assert.equal(calls, 1); + monitor.stop(); + }); + + it("fires parent monitor when parent disappears", async () => { + let checks = 0; + let calls = 0; + const stop = startParentMonitor({ + parentPid: process.ppid, + pollIntervalMs: 10, + isProcessAlive: () => { + checks += 1; + return false; + }, + onParentExit: () => { + calls += 1; + }, + }); + + await wait(1100); + stop(); + assert.equal(calls, 1); + assert.equal(checks, 1); + }); }); diff --git a/test/main/semantic-navigate.test.mjs b/test/main/semantic-navigate.test.mjs index 822a7ee..3ee9fe9 100644 --- a/test/main/semantic-navigate.test.mjs +++ b/test/main/semantic-navigate.test.mjs @@ -3,6 +3,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Ollama } from "ollama"; describe("semantic-navigate", () => { it("exports semanticNavigate as a function", async () => { @@ -14,4 +18,55 @@ describe("semantic-navigate", () => { const mod = await import("../../build/tools/semantic-navigate.js"); assert.equal(mod.semanticNavigate.length, 1); }); + + it("skips data files and navigates source files", async () => { + const { semanticNavigate } = + await import("../../build/tools/semantic-navigate.js"); + const rootDir = await mkdtemp( + join(tmpdir(), "contextplus-semantic-navigate-"), + ); + const originalEmbed = Ollama.prototype.embed; + const originalChat = Ollama.prototype.chat; + + Ollama.prototype.embed = async function ({ input }) { + const batch = Array.isArray(input) ? input : [input]; + return { embeddings: batch.map(() => [1, 0, 0]) }; + }; + Ollama.prototype.chat = async function () { + return { message: { content: JSON.stringify(["Source file"]) } }; + }; + + try { + await writeFile( + join(rootDir, "app.ts"), + [ + "// Semantic navigate fixture header line one two three four", + "// FEATURE: semantic navigate fixture for source-only clustering output", + "export const meaning = 42;", + "", + ].join("\n"), + ); + await writeFile( + join(rootDir, "data.json"), + JSON.stringify({ + rows: Array.from({ length: 10000 }, (_, idx) => ({ + id: idx, + value: `cell_${idx}`, + })), + }), + ); + + const result = await semanticNavigate({ + rootDir, + maxDepth: 2, + maxClusters: 5, + }); + assert.match(result, /app\.ts/); + assert.doesNotMatch(result, /data\.json/); + } finally { + Ollama.prototype.embed = originalEmbed; + Ollama.prototype.chat = originalChat; + await rm(rootDir, { recursive: true, force: true }); + } + }); }); diff --git a/test/main/semantic-search.test.mjs b/test/main/semantic-search.test.mjs index cf96404..054ddf8 100644 --- a/test/main/semantic-search.test.mjs +++ b/test/main/semantic-search.test.mjs @@ -1,6 +1,13 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { invalidateSearchCache } from "../../build/tools/semantic-search.js"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Ollama } from "ollama"; +import { + invalidateSearchCache, + semanticCodeSearch, +} from "../../build/tools/semantic-search.js"; describe("semantic-search", () => { describe("invalidateSearchCache", () => { @@ -30,5 +37,62 @@ describe("semantic-search", () => { const mod = await import("../../build/tools/semantic-search.js"); assert.equal(mod.semanticCodeSearch.length, 1); }); + + it("skips oversized data files and still indexes source files", async () => { + const rootDir = await mkdtemp( + join(tmpdir(), "contextplus-semantic-search-"), + ); + const originalEmbed = Ollama.prototype.embed; + const previousSizeLimit = process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE; + + Ollama.prototype.embed = async function ({ input }) { + const batch = Array.isArray(input) ? input : [input]; + return { + embeddings: batch.map((value) => [ + value.includes("greet") ? 1 : 0.25, + ]), + }; + }; + + process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE = "1024"; + + try { + await writeFile( + join(rootDir, "app.ts"), + [ + "// Semantic search fixture header line one two three four", + "// FEATURE: semantic search fixture coverage for mixed data projects", + "export function greet(name: string): string {", + " return `hello ${name}`;", + "}", + "", + ].join("\n"), + ); + await writeFile( + join(rootDir, "data.json"), + JSON.stringify({ + rows: Array.from({ length: 50000 }, (_, idx) => ({ + id: idx, + value: `payload_${idx}`, + })), + }), + ); + + invalidateSearchCache(); + const result = await semanticCodeSearch({ + rootDir, + query: "greet", + topK: 3, + }); + assert.match(result, /app\.ts/); + assert.doesNotMatch(result, /data\.json/); + } finally { + if (previousSizeLimit === undefined) + delete process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE; + else process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE = previousSizeLimit; + Ollama.prototype.embed = originalEmbed; + await rm(rootDir, { recursive: true, force: true }); + } + }); }); }); diff --git a/test/main/shadow-traversal.test.mjs b/test/main/shadow-traversal.test.mjs new file mode 100644 index 0000000..49224b1 --- /dev/null +++ b/test/main/shadow-traversal.test.mjs @@ -0,0 +1,107 @@ +// PoC test for CWE-22: Path traversal in shadow restore system +// FEATURE: Security regression test for path traversal in createRestorePoint / restorePoint + +import { describe, it, after, before } from "node:test"; +import assert from "node:assert/strict"; +import { + createRestorePoint, + restorePoint, +} from "../../build/git/shadow.js"; +import { writeFile, mkdir, rm, readFile, access } from "fs/promises"; +import { join, resolve } from "path"; + +const FIXTURE_DIR = join(process.cwd(), "test", "_shadow_traversal_fixtures"); +const OUTSIDE_DIR = join(process.cwd(), "test", "_shadow_traversal_outside"); + +async function setup() { + await rm(FIXTURE_DIR, { recursive: true, force: true }); + await rm(OUTSIDE_DIR, { recursive: true, force: true }); + await mkdir(FIXTURE_DIR, { recursive: true }); + await mkdir(OUTSIDE_DIR, { recursive: true }); +} + +async function cleanup() { + await rm(FIXTURE_DIR, { recursive: true, force: true }); + await rm(OUTSIDE_DIR, { recursive: true, force: true }); +} + +describe("shadow path traversal (CWE-22)", async () => { + await setup(); + + describe("createRestorePoint rejects traversal paths", () => { + it("rejects file paths containing ../", async () => { + // Create a sensitive file outside rootDir + const secretPath = join(OUTSIDE_DIR, "secret.txt"); + await writeFile(secretPath, "TOP SECRET DATA"); + + // Attempt to read it via path traversal + // The relative traversal from FIXTURE_DIR to OUTSIDE_DIR: + const traversalPath = "../_shadow_traversal_outside/secret.txt"; + + // Verify the traversal would actually resolve outside rootDir + const resolved = resolve(FIXTURE_DIR, traversalPath); + assert.ok(!resolved.startsWith(FIXTURE_DIR + "/"), + `Traversal path should resolve outside rootDir: ${resolved}`); + + // This should throw or reject the traversal path + await assert.rejects( + () => createRestorePoint(FIXTURE_DIR, [traversalPath], "traversal attempt"), + (err) => { + // Accept any error that indicates the path was rejected + return err instanceof Error && /outside|traversal|invalid|path/i.test(err.message); + }, + "createRestorePoint should reject paths that traverse outside rootDir" + ); + }); + }); + + describe("restorePoint rejects traversal paths in manifest", () => { + it("does not write files outside rootDir during restore", async () => { + // First, create a legitimate restore point + const testFile = "legit.txt"; + await writeFile(join(FIXTURE_DIR, testFile), "legit content"); + const point = await createRestorePoint(FIXTURE_DIR, [testFile], "legit backup"); + + // Now manually tamper with the manifest to inject a traversal path + const manifestPath = join(FIXTURE_DIR, ".mcp_data", "restore-points.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf-8")); + + // Add a traversal file to the existing restore point + const traversalFile = "../_shadow_traversal_outside/pwned.txt"; + const tamperedPoint = { + id: `rp-tampered-${Date.now()}`, + timestamp: Date.now(), + files: [traversalFile], + message: "tampered" + }; + + // Create backup content for the tampered point + const backupDir = join(FIXTURE_DIR, ".mcp_data", "backups", tamperedPoint.id); + await mkdir(backupDir, { recursive: true }); + const backupFileName = traversalFile.replace(/[\\/]/g, "__"); + await writeFile(join(backupDir, backupFileName), "MALICIOUS CONTENT"); + + // Save the tampered manifest + manifest.push(tamperedPoint); + await writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + + // Attempt restore — should reject the traversal path + await assert.rejects( + () => restorePoint(FIXTURE_DIR, tamperedPoint.id), + (err) => { + return err instanceof Error && /outside|traversal|invalid|path/i.test(err.message); + }, + "restorePoint should reject paths that traverse outside rootDir" + ); + + // Verify the file was NOT written outside rootDir + const pwnedPath = join(OUTSIDE_DIR, "pwned.txt"); + await assert.rejects( + () => access(pwnedPath), + "File should not have been written outside rootDir" + ); + }); + }); + + after(cleanup); +}); diff --git a/test/main/static-analysis-injection.test.mjs b/test/main/static-analysis-injection.test.mjs new file mode 100644 index 0000000..3e41a90 --- /dev/null +++ b/test/main/static-analysis-injection.test.mjs @@ -0,0 +1,102 @@ +// Test: CWE-78 command injection via targetPath in static analysis +// Verifies that shell metacharacters in targetPath cannot be used for injection + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { mkdir, writeFile, rm, readFile } from "fs/promises"; +import { resolve, join } from "path"; + +const { runStaticAnalysis } = + await import("../../build/tools/static-analysis.js"); + +const FIXTURE = resolve("test/_injection_fixtures"); +const SENTINEL = join(FIXTURE, "pwned.txt"); + +before(async () => { + await mkdir(FIXTURE, { recursive: true }); + await writeFile( + join(FIXTURE, "safe.py"), + "# Safe python file\n# FEATURE: test\nprint('hello')\n", + ); +}); + +after(async () => { + await rm(FIXTURE, { recursive: true, force: true }); +}); + +describe("CWE-78: command injection via targetPath", () => { + it("should not execute injected commands via $() with .py extension", async () => { + // This payload ends in .py so it matches the Python linter + // The $() will be interpreted by the shell in exec() + const maliciousPath = `$(echo INJECTED > ${SENTINEL}).py`; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via $() succeeded – sentinel file was created"); + }); + + it("should not execute injected commands via backticks with .py extension", async () => { + const maliciousPath = "`echo INJECTED > " + SENTINEL + "`.py"; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via backticks succeeded – sentinel file was created"); + }); + + it("should not execute injected commands via semicolon ending with .py", async () => { + // Craft: foo; echo INJECTED > sentinel; echo.py + const maliciousPath = `foo; echo INJECTED > ${SENTINEL}; echo.py`; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via semicolon succeeded – sentinel file was created"); + }); + + it("should not execute injected commands via pipe ending with .py", async () => { + const maliciousPath = `safe.py | tee ${SENTINEL} | cat foo.py`; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via pipe succeeded – sentinel file was created"); + }); +});