From 9015557c6dfe77cb8d551574c6078962c63c341a Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Mon, 30 Mar 2026 18:05:28 +0530 Subject: [PATCH] feat(tools): add detailed code structure analysis tool - Implemented `code-structure.ts` to extract imports, exports, dependencies, and call graphs from source files using tree-sitter. - Added functions for extracting imports, exports, and function calls for TypeScript, JavaScript, Python, Go, and Rust. - Created a `getCodeStructure` function to return a structured overview of the code file. feat(tools): create project initializer for Context+ setup - Introduced `init.ts` to initialize Context+ projects with necessary directories and default files. - Added functionality to generate initial embeddings and context tree snapshots. feat(tools): unify research capabilities across code, memory, and ACP - Developed `research.ts` to aggregate search results from codebase, memory, and ACP sources. - Implemented `discoverRelated` function to find related files and memories based on a given file path. feat(tools): enhance search functionality with hybrid capabilities - Created `search.ts` to combine semantic and keyword search modes for identifiers and files. - Added support for customizable search options including weights and score thresholds. test: add memory graph fixtures for testing - Created a sample `graph.json` file to serve as a fixture for memory graph tests. --- .contextplus/embeddings/vectors.db | Bin 0 -> 16384 bytes INSTRUCTIONS.md | 77 +- README.md | 116 +-- TODO.md | 100 +-- cli/go.mod | 30 + cli/go.sum | 50 ++ cli/internal/ui/codestructure.go | 247 ++++++ cli/internal/ui/createhub.go | 173 ++++ cli/internal/ui/dashboard.go | 90 ++ cli/internal/ui/hubs.go | 133 +++ cli/internal/ui/memory.go | 177 ++++ cli/internal/ui/restore.go | 127 +++ cli/internal/ui/sessions.go | 149 ++++ cli/internal/ui/styles.go | 34 + cli/internal/ui/tree.go | 144 ++++ cli/main.go | 92 +++ landing/src/app/api/instructions/route.ts | 77 +- landing/src/app/page.tsx | 40 +- .../src/components/InstructionsSection.tsx | 77 +- package.json | 2 +- src/core/acp.ts | 271 ++++++ src/core/embedding-tracker.ts | 124 ++- src/core/embeddings.ts | 75 +- src/core/error-handler.ts | 93 +++ src/core/hub.ts | 18 +- src/core/memory-graph.ts | 780 ++++++++++++------ src/core/vector-db.ts | 223 +++++ src/git/shadow.ts | 202 +++-- src/index.ts | 539 ++++++------ src/tools/acp-tools.ts | 86 ++ src/tools/code-structure.ts | 218 +++++ src/tools/feature-hub.ts | 257 +++--- src/tools/init.ts | 99 +++ src/tools/memory-tools.ts | 205 +++-- src/tools/propose-commit.ts | 178 ++-- src/tools/research.ts | 117 +++ src/tools/search.ts | 105 +++ src/tools/semantic-identifiers.ts | 17 +- src/tools/semantic-search.ts | 18 +- src/tools/static-analysis.ts | 174 +++- .../.contextplus/memories/graph.json | 406 +++++++++ 41 files changed, 4924 insertions(+), 1216 deletions(-) create mode 100644 .contextplus/embeddings/vectors.db create mode 100644 cli/go.mod create mode 100644 cli/go.sum create mode 100644 cli/internal/ui/codestructure.go create mode 100644 cli/internal/ui/createhub.go create mode 100644 cli/internal/ui/dashboard.go create mode 100644 cli/internal/ui/hubs.go create mode 100644 cli/internal/ui/memory.go create mode 100644 cli/internal/ui/restore.go create mode 100644 cli/internal/ui/sessions.go create mode 100644 cli/internal/ui/styles.go create mode 100644 cli/internal/ui/tree.go create mode 100644 cli/main.go create mode 100644 src/core/acp.ts create mode 100644 src/core/error-handler.ts create mode 100644 src/core/vector-db.ts create mode 100644 src/tools/acp-tools.ts create mode 100644 src/tools/code-structure.ts create mode 100644 src/tools/init.ts create mode 100644 src/tools/research.ts create mode 100644 src/tools/search.ts create mode 100644 test/_memory_graph_fixtures/.contextplus/memories/graph.json diff --git a/.contextplus/embeddings/vectors.db b/.contextplus/embeddings/vectors.db new file mode 100644 index 0000000000000000000000000000000000000000..4d51db0c18b4a5a82e53330fdd966a0718f7c154 GIT binary patch literal 16384 zcmeI#&rZTH90%}j5HTkFxpCl9Pq+j%#usoxCB!*&j06&srLr1`{F4oXF&>DC@8)Cp z79Q>90NH^DSiViVrfvFb`&lo))}1l%1$~^%M%<_8q(BsfTu@2~QDsfbx+?L$vYOb@ zs^sKT%jSPbDtAwE-}x`upg;fu5P$##AOHafKmY;|fIz|omTD?fte2GK>0s=N#lT%S zFQVs9W}Y+VBjHVXPdM|b%YETS6X~kObeqwJSz{f#a|LahG+K4E4p*U3JV$1;HP)(X zOQB?nrIPY-=<|o6h*m_68f|Cmmj+u!qnmKlW*wWFOFYW~a2tWV=5P$##AOHafKmY;|2nCS;;~_u*0uX=z1Rwwb2tWV=5P$##k}vQJ Dloyf_ literal 0 HcmV?d00001 diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 8d8a2cf..563b712 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -13,33 +13,33 @@ The MCP server is built with TypeScript and communicates over stdio using the Mo - `parser.ts` - Multi-language symbol extraction via tree-sitter AST with regex fallback. Supports 14+ languages. - `tree-sitter.ts` - WASM grammar loader for 43 file extensions using web-tree-sitter 0.20.8. - `walker.ts` - Gitignore-aware recursive directory traversal with depth and target path control. -- `embeddings.ts` - Ollama vector embedding engine with disk cache, cosine similarity search, and API key support. +- `embeddings.ts` - Ollama/OpenAI-compatible vector embedding engine backed by vector DB persistence. **Tools Layer** (`src/tools/`): -- `context-tree.ts` - Token-aware structural tree with symbol line ranges and Level 0/1/2 pruning. -- `file-skeleton.ts` - Function signatures with line ranges, without reading full bodies. -- `semantic-search.ts` - Ollama-powered semantic file search with symbol definition lines and 60s cache TTL. -- `semantic-identifiers.ts` - Identifier-level semantic search returning ranked definitions + call chains with line numbers. -- `semantic-navigate.ts` - Browse-by-meaning navigator using spectral clustering and Ollama labeling. +- `context-tree.ts` - `tree` tool implementation for token-aware structural mapping. +- `file-skeleton.ts` - `skeleton` tool implementation for signatures and type surfaces. +- `search.ts` - Unified `search` tool for file and identifier retrieval. +- `semantic-navigate.ts` - `cluster` tool implementation for spectral semantic grouping. - `blast-radius.ts` - Symbol usage tracer across the entire codebase. -- `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). +- `static-analysis.ts` - `lint` runner with project/file skill scoring output. +- `propose-commit.ts` - `checkpoint` tool for validated writes with local restore points. +- `feature-hub.ts` - `find_hub` ranking and full-project hub context fallback. +- `memory-tools.ts` - Memory graph wrappers for create/search/explore/bulk/update/delete flows. +- `init.ts` - Project bootstrap tool for `.contextplus` directories and context snapshot. -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. +The memory graph is a **Retrieval-Augmented Generation (RAG)** system. Agents SHOULD use `search_memory` early in each task to retrieve prior context, and persist learnings with `create_memory` and `create_relation` after completing work. Stale links are pruned automatically before graph access. **Core Layer** (continued): - `hub.ts` - Wikilink parser for `[[path]]` links, cross-link tags, hub discovery, orphan detection. -- `memory-graph.ts` - In-memory property graph with JSON persistence, decay scoring, and auto-similarity edges. +- `memory-graph.ts` - Graph metadata + markdown node files + vector DB-backed semantic retrieval. **Git Layer** (`src/git/`): - `shadow.ts` - Shadow restore point system for undo without touching git history. -**Entry Point**: `src/index.ts` registers 17 MCP tools and starts the stdio transport. Accepts an optional CLI argument for the target project root directory (defaults to `process.cwd()`). +**Entry Point**: `src/index.ts` registers 18 MCP tools and starts the stdio transport. Accepts an optional CLI argument for the target project root directory (defaults to `process.cwd()`). ## Environment Variables @@ -53,18 +53,18 @@ The memory graph is a **Retrieval-Augmented Generation (RAG)** system. Agents MU | `CONTEXTPLUS_EMBED_TRACKER_MAX_FILES` | `8` | Max changed files per tracker tick (hard-capped to 5-10) | | `CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS` | `700` | Debounce before applying tracker refresh | -Runtime cache: `.mcp_data/` is created at MCP startup and stores reusable embedding vectors for files, identifiers, and call sites. A realtime tracker watches file updates and refreshes changed function/file embeddings incrementally. +Runtime storage: `.contextplus/` is created by `init` and stores hubs, memory graph data, and vector DB embeddings. A realtime tracker can refresh changed function/file embeddings incrementally. ## Fast Execute Mode (Mandatory) Default to execution-first behavior. Use minimal tokens, minimal narration, and maximum tool leverage. -1. Skip long planning prose. Start with lightweight scoping: `get_context_tree` and `get_file_skeleton`. +1. Skip long planning prose. Start with lightweight scoping: `tree` and `skeleton`. 2. Run independent discovery operations in parallel whenever possible (for example, multiple searches/reads). 3. Prefer structural tools over full-file reads to conserve context. -4. Before modifying or deleting symbols, run `get_blast_radius`. -5. Write changes through `propose_commit` only. -6. Run `run_static_analysis` once after edits, or once per changed module for larger refactors. +4. Before modifying or deleting symbols, run `blast_radius`. +5. Write changes through `checkpoint`. +6. Run `lint` once after edits, or once per changed module for larger refactors. ### Execution Rules @@ -77,7 +77,7 @@ Default to execution-first behavior. Use minimal tokens, minimal narration, and ### Token-Efficiency Rules 1. Treat 100 effective tokens as better than 1000 vague tokens. -2. Use high-signal tool calls first (`get_file_skeleton`, `get_context_tree`, `get_blast_radius`). +2. Use high-signal tool calls first (`skeleton`, `tree`, `blast_radius`). 3. Read full file bodies only when signatures/structure are insufficient. 4. Avoid repeated scans of unchanged areas. 5. Prefer direct edits + deterministic validation over extended speculative analysis. @@ -127,25 +127,26 @@ Strict order within every file: ## Tool Reference -| Tool | When to Use | -| ---------------------------- | ---------------------------------------------------------------------------------- | -| `get_context_tree` | Start of every task. Map files + symbols with line ranges. | -| `semantic_navigate` | Browse codebase by meaning, not directory structure. | -| `get_file_skeleton` | MUST run before full reads. Get signatures + line ranges first. | -| `semantic_code_search` | Find relevant files by concept with symbol definition lines. | -| `semantic_identifier_search` | Find closest functions/classes/variables and ranked call chains with line numbers. | -| `get_blast_radius` | Before deleting or modifying any symbol. | -| `run_static_analysis` | After writing code. Catch dead code deterministically. | -| `propose_commit` | The ONLY way to save files. Validates before writing. | -| `list_restore_points` | See undo history. | -| `undo_change` | Revert a bad AI change without touching git. | -| `get_feature_hub` | Browse feature graph hubs. Find orphaned files. | -| `upsert_memory_node` | Create/update memory nodes (concept, file, symbol, note) with auto-embedding. | -| `create_relation` | Create typed edges between memory nodes (depends_on, implements, etc). | -| `search_memory_graph` | Semantic search + graph traversal across 1st/2nd-degree neighbors. | -| `prune_stale_links` | Remove decayed edges (e^(-λt)) and orphan nodes periodically. | -| `add_interlinked_context` | Bulk-add nodes with auto-similarity linking (cosine ≥ 0.72). | -| `retrieve_with_traversal` | Start from a node, walk outward, return scored neighbors by decay and depth. | +| Tool | When to Use | +| ----------------- | ---------------------------------------------------------------------------------- | +| `init` | Bootstrap `.contextplus` structure and context snapshot for a project. | +| `tree` | Start of every task. Map files + symbols with line ranges. | +| `cluster` | Browse codebase by meaning, not directory structure. | +| `skeleton` | Run before full reads. Get signatures + line ranges first. | +| `search` | Unified semantic/keyword search across files, identifiers, or both. | +| `blast_radius` | Before deleting or modifying any symbol. | +| `lint` | After writing code. Catch dead code and get skill scoring. | +| `checkpoint` | Save file changes with validation and local restore-point creation. | +| `restore_points` | See local restore history. | +| `restore` | Revert to a specific restore point without touching git history. | +| `find_hub` | Rank hubs by query or list all hub context when query is omitted. | +| `create_memory` | Create/update memory nodes with embedding refresh. | +| `create_relation` | Create typed edges between memory nodes (depends_on, implements, etc). | +| `search_memory` | Semantic/keyword memory search with neighborhood traversal. | +| `explore_memory` | Traverse outward from a known memory node id. | +| `bulk_memory` | Bulk-add nodes and optional auto-linking. | +| `update_memory` | Update existing memory content and refresh embeddings. | +| `delete_memory` | Delete nodes or relations from the memory graph. | ## Anti-Patterns to Avoid diff --git a/README.md b/README.md index edf8525..3207ee0 100644 --- a/README.md +++ b/README.md @@ -10,45 +10,46 @@ https://github.com/user-attachments/assets/a97a451f-c9b4-468d-b036-15b65fc13e79 ### Discovery -| Tool | Description | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `get_context_tree` | Structural AST tree of a project with file headers and symbol ranges (line numbers for functions/classes/methods). Dynamic pruning shrinks output automatically. | -| `get_file_skeleton` | Function signatures, class methods, and type definitions with line ranges, without reading full bodies. Shows the API surface. | -| `semantic_code_search` | Search by meaning, not exact text. Uses embeddings over file headers/symbols and returns matched symbol definition lines. | -| `semantic_identifier_search` | Identifier-level semantic retrieval for functions/classes/variables with ranked call sites and line numbers. | -| `semantic_navigate` | Browse codebase by meaning using spectral clustering. Groups semantically related files into labeled clusters. | +| Tool | Description | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tree` | Structural AST tree of a project with file headers and symbol ranges (line numbers for functions/classes/methods). Dynamic pruning shrinks output automatically. | +| `skeleton` | Function signatures, class methods, and type definitions with line ranges, without reading full bodies. Shows the API surface. | +| `search` | Unified search for file-level and identifier-level retrieval with semantic, keyword, or hybrid modes. | +| `cluster` | Browse codebase by meaning using spectral clustering. Groups semantically related files into labeled clusters. | ### Analysis -| Tool | Description | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `get_blast_radius` | Trace every file and line where a symbol is imported or used. Prevents orphaned references. | -| `run_static_analysis` | Run native linters and compilers to find unused variables, dead code, and type errors. Supports TypeScript, Python, Rust, Go. | +| Tool | Description | +| -------------- | ----------------------------------------------------------------------------------------------------------------- | +| `blast_radius` | Trace every file and line where a symbol is imported or used. Prevents orphaned references. | +| `lint` | Run native linters/compilers and project skill checks to find errors, dead code, and instruction-rule violations. | ### Code Ops -| Tool | Description | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `propose_commit` | The only way to write code. Validates against strict rules before saving. Creates a shadow restore point before writing. | -| `get_feature_hub` | Obsidian-style feature hub navigator. Hubs are `.md` files with `[[wikilinks]]` that map features to code files. | +| Tool | Description | +| ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `checkpoint` | Write code after validation and create a local restore point before saving. | +| `find_hub` | Query-ranked feature hub search with semantic/keyword/both modes; without query it returns all hub context in project. | +| `init` | Initialize `.contextplus` workspace with hubs, embeddings, config, and memories structure plus context tree snapshot. | ### Version Control -| Tool | Description | -| --------------------- | ---------------------------------------------------------------------------------------------------------- | -| `list_restore_points` | List all shadow restore points created by `propose_commit`. Each captures file state before AI changes. | -| `undo_change` | Restore files to their state before a specific AI change. Uses shadow restore points. Does not affect git. | +| Tool | Description | +| ---------------- | -------------------------------------------------------------------------------------------- | +| `restore_points` | List all local restore points created by `checkpoint`. | +| `restore` | Restore files to their state at a specific local restore point. Does not affect git history. | ### 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 | +| ----------------- | -------------------------------------------------------------------------------------------------------- | +| `create_memory` | Create or update a memory node (concept, file, symbol, note) with auto-generated embeddings. | +| `update_memory` | Update memory node content and refresh embeddings. | +| `delete_memory` | Delete memory nodes or relation edges. | +| `create_relation` | Create typed edges between nodes (relates_to, depends_on, implements, references, similar_to, contains). | +| `search_memory` | Semantic/keyword search with graph traversal — finds direct matches then walks neighbors. | +| `explore_memory` | Start from a node and walk outward — returns reachable neighbors scored by decay and depth. | +| `bulk_memory` | Bulk-add nodes with optional auto-similarity linking. | ## Setup @@ -136,10 +137,10 @@ npm run build 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 | +| 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) @@ -212,7 +213,7 @@ Any endpoint implementing the [OpenAI Embeddings API](https://platform.openai.co } ``` -> **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`). +> **Note:** The `cluster` tool 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). @@ -220,38 +221,39 @@ Any endpoint implementing the [OpenAI Embeddings API](https://platform.openai.co Three layers built with TypeScript over stdio using the Model Context Protocol SDK: -**Core** (`src/core/`) - Multi-language AST parsing (tree-sitter, 43 extensions), gitignore-aware traversal, Ollama vector embeddings with disk cache, wikilink hub graph, in-memory property graph with decay scoring. +**Core** (`src/core/`) - Multi-language AST parsing (tree-sitter, 43 extensions), gitignore-aware traversal, vector DB-backed embeddings, wikilink hub graph, and markdown-backed memory graph with traversal scoring. -**Tools** (`src/tools/`) - 17 MCP tools exposing structural, semantic, operational, and memory graph capabilities. +**Tools** (`src/tools/`) - 18 MCP tools exposing structural, semantic, operational, and memory graph capabilities. **Git** (`src/git/`) - Shadow restore point system for undo without touching git history. -**Runtime Cache** (`.mcp_data/`) - created on server startup; stores reusable file, identifier, and call-site embeddings to avoid repeated GPU/CPU embedding work. A realtime tracker refreshes changed files/functions incrementally. +**Runtime Workspace** (`.contextplus/`) - initialized by `init`; stores hubs, embeddings database, config snapshots, and memory graph files. A realtime tracker refreshes changed files/functions incrementally. ## Config -| 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 | +| 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_BATCH_CONCURRENCY` | string (parsed as number) | `1` | Number of embedding batches processed concurrently, clamped to 1-8 | +| `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 index b1364bf..f3622cf 100644 --- a/TODO.md +++ b/TODO.md @@ -2,41 +2,41 @@ ## 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] +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] and edit those checks ## 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 +- [x] rename tools for better meaning + - [x] rename semantic_navigate to cluster + - [x] rename get_context_tree to tree + - [x] rename semantic_identifier_search and semantic_code_search (merged) to search + - [x] 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 + - [x] add parameter to search for data in hubs by semantic meaning or keyword match or both + - [x] add parameter optionality so if no parameters are provided, it returns context of all hubs in the project + - [x] rename get_file_skeleton to skeleton + - [x] rename get_blast_radius to blast_radius + - [x] rename run_static_analysis to lint + - [x] 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 + - [x] 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 + - [x] rename list_restore_points to restore_points + - [x] rename undo_change to restore and change its functionality to restore to a specific commit point + - [x] rename upsert_memory_node to create_memory + - [x] rename search_memory_graph to search_memory + - [x] rename retrieve_with_traversal to explore_memory + - [x] create delete_memory tool that deletes nodes or relationships in the memory graph + - [x] 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 + - [x] add_interlinked_context to bulk_memory +- [x] 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) + - [x] add options for filtering by semantic meaning or normal search or both + - [x] use a vector database for storing embeddings and searching instead of doing it in memory for better performance and scalability +- [x] create a new memory system that uses a graph database and md files and vector database for storing memories + - [x] 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 + - [x] 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 +- [x] create a new tool called init that initializes the project by creating a context tree and .contextplus folder + - [x] use .contextplus/hubs for feature hubs + - [x] use .contextplus/embeddings for storing file and symbol embeddings + - [x] use .contextplus/config for configuration files + - [x] use .contextplus/memories for memory graph data --- @@ -47,24 +47,24 @@ 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 +- [x] 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 +- [x] ctx+ cli in cli/ folder + - [x] visualize memory graphs, unto commits, hubs in the cli + - [x] use charm's tui library - bubble or tea + - [x] features like `contextplus init` + - [x] visualize context tree, undo commits, hubs list, and more in the cli + - [x] create hubs option from the cli for humans +- [x] acp features (maybe that we can list all sessions and memories from all agents, like opencode, copilot, claude, codex into one generalized list) + - [x] improved memory search from acp + - [x] load session memoies from acp into the memory graph + - [x] cli: see all sessions of all agents in list and add semantic search in cli + - [x] cli: see all memories of all agents in list and add semantic search in cli + - [x] use .contextplus/external_memories for storing acp imported memories and sessions +- [x] faster and cleaner agent protocol access +- [x] 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" +- [x] better treesitter support and tools for using it to understand code structure and semantics better +- [x] add these features to be visualized in the cli +- [x] add researchplus tools and features diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..04d5fcd --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,30 @@ +module github.com/contextplus/cli + +go 1.25.1 + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..aa519ac --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,50 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/cli/internal/ui/codestructure.go b/cli/internal/ui/codestructure.go new file mode 100644 index 0000000..a9ec9de --- /dev/null +++ b/cli/internal/ui/codestructure.go @@ -0,0 +1,247 @@ +// Code Structure TUI - displays AST analysis for selected file +// Shows imports, exports, and call graph using tree-sitter parsing + +package ui + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type codeStructureModel struct { + files []string + cursor int + selected string + content string + scroll int +} + +func newCodeStructureModel() codeStructureModel { + files := discoverSourceFiles(".") + return codeStructureModel{files: files} +} + +func discoverSourceFiles(root string) []string { + exts := []string{".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".java", ".c", ".cpp"} + var files []string + filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + if info != nil && info.IsDir() && (info.Name() == "node_modules" || info.Name() == ".git" || info.Name() == ".contextplus") { + return filepath.SkipDir + } + return nil + } + for _, ext := range exts { + if strings.HasSuffix(path, ext) { + files = append(files, path) + break + } + } + return nil + }) + if len(files) > 100 { + files = files[:100] + } + return files +} + +func analyzeFile(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Sprintf("Error reading file: %v", err) + } + content := string(data) + lines := strings.Split(content, "\n") + ext := filepath.Ext(path) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("# %s\n", path)) + sb.WriteString(fmt.Sprintf("Lines: %d | Extension: %s\n\n", len(lines), ext)) + + imports := extractImportsSimple(content, ext) + if len(imports) > 0 { + sb.WriteString(fmt.Sprintf("## Imports (%d)\n", len(imports))) + for _, imp := range imports { + sb.WriteString(fmt.Sprintf(" %s\n", imp)) + } + sb.WriteString("\n") + } + + exports := extractExportsSimple(content, ext) + if len(exports) > 0 { + sb.WriteString(fmt.Sprintf("## Exports (%d)\n", len(exports))) + for _, exp := range exports { + sb.WriteString(fmt.Sprintf(" %s\n", exp)) + } + sb.WriteString("\n") + } + + funcs := extractFunctionsSimple(content, ext) + if len(funcs) > 0 { + sb.WriteString(fmt.Sprintf("## Functions (%d)\n", len(funcs))) + for _, fn := range funcs { + sb.WriteString(fmt.Sprintf(" %s\n", fn)) + } + } + + return sb.String() +} + +func extractImportsSimple(content, ext string) []string { + var imports []string + var re *regexp.Regexp + switch ext { + case ".ts", ".tsx", ".js", ".jsx": + re = regexp.MustCompile(`(?m)^import\s+.*from\s+['"]([^'"]+)['"]`) + case ".py": + re = regexp.MustCompile(`(?m)^(?:from\s+(\S+)\s+import|import\s+(\S+))`) + case ".go": + re = regexp.MustCompile(`(?m)"([^"]+)"`) + case ".rs": + re = regexp.MustCompile(`(?m)^use\s+([^;]+);`) + default: + return imports + } + matches := re.FindAllStringSubmatch(content, -1) + for _, m := range matches { + for i := 1; i < len(m); i++ { + if m[i] != "" { + imports = append(imports, m[i]) + break + } + } + } + return imports +} + +func extractExportsSimple(content, ext string) []string { + var exports []string + var re *regexp.Regexp + switch ext { + case ".ts", ".tsx", ".js", ".jsx": + re = regexp.MustCompile(`(?m)^export\s+(?:default\s+)?(?:function|class|const|let|var|interface|type|enum)\s+(\w+)`) + case ".py": + re = regexp.MustCompile(`(?m)^(?:def|class)\s+(\w+)`) + case ".go": + re = regexp.MustCompile(`(?m)^(?:func|type)\s+([A-Z]\w*)`) + case ".rs": + re = regexp.MustCompile(`(?m)^pub\s+(?:fn|struct|enum|trait)\s+(\w+)`) + default: + return exports + } + matches := re.FindAllStringSubmatch(content, -1) + for _, m := range matches { + if len(m) > 1 { + exports = append(exports, m[1]) + } + } + return exports +} + +func extractFunctionsSimple(content, ext string) []string { + var funcs []string + var re *regexp.Regexp + switch ext { + case ".ts", ".tsx", ".js", ".jsx": + re = regexp.MustCompile(`(?m)(?:function|async\s+function)\s+(\w+)|(\w+)\s*[:=]\s*(?:async\s+)?(?:function|\([^)]*\)\s*=>)`) + case ".py": + re = regexp.MustCompile(`(?m)^(?:def|async\s+def)\s+(\w+)`) + case ".go": + re = regexp.MustCompile(`(?m)^func\s+(?:\([^)]+\)\s+)?(\w+)`) + case ".rs": + re = regexp.MustCompile(`(?m)fn\s+(\w+)`) + case ".java": + re = regexp.MustCompile(`(?m)(?:public|private|protected)?\s*(?:static)?\s*\w+\s+(\w+)\s*\(`) + default: + return funcs + } + matches := re.FindAllStringSubmatch(content, -1) + for _, m := range matches { + for i := 1; i < len(m); i++ { + if m[i] != "" { + funcs = append(funcs, m[i]) + break + } + } + } + return funcs +} + +func (m codeStructureModel) Init() tea.Cmd { return nil } + +func (m codeStructureModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc", "b": + if m.selected != "" { + m.selected = "" + m.content = "" + m.scroll = 0 + return m, nil + } + return newDashboard(), nil + case "up", "k": + if m.selected != "" { + m.scroll = Clamp(m.scroll-1, 0, 100) + } else { + m.cursor = Clamp(m.cursor-1, 0, len(m.files)-1) + } + case "down", "j": + if m.selected != "" { + m.scroll++ + } else { + m.cursor = Clamp(m.cursor+1, 0, len(m.files)-1) + } + case "enter", " ": + if m.selected == "" && len(m.files) > 0 { + m.selected = m.files[m.cursor] + m.content = analyzeFile(m.selected) + } + } + } + return m, nil +} + +func (m codeStructureModel) View() string { + if m.selected != "" { + lines := strings.Split(m.content, "\n") + start := Clamp(m.scroll, 0, len(lines)-1) + end := Clamp(start+20, 0, len(lines)) + visible := strings.Join(lines[start:end], "\n") + return TitleStyle.Render("Code Structure: "+m.selected) + "\n\n" + visible + "\n\n" + HelpStyle.Render("↑/k ↓/j scroll • b/esc back • q quit") + } + + s := TitleStyle.Render("Code Structure Analysis") + "\n\n" + if len(m.files) == 0 { + s += DimStyle.Render("No source files found") + } else { + start := Clamp(m.cursor-5, 0, len(m.files)-1) + end := Clamp(start+12, 0, len(m.files)) + for i := start; i < end; i++ { + cursor := " " + style := DimStyle + if m.cursor == i { + cursor = "> " + style = SelectedStyle + } + s += fmt.Sprintf("%s%s\n", cursor, style.Render(m.files[i])) + } + } + s += "\n" + HelpStyle.Render("↑/k ↓/j nav • enter select • b back • q quit") + return s +} + +func RunCodeStructure() { + p := tea.NewProgram(newCodeStructureModel()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + } +} diff --git a/cli/internal/ui/createhub.go b/cli/internal/ui/createhub.go new file mode 100644 index 0000000..9e3cb5a --- /dev/null +++ b/cli/internal/ui/createhub.go @@ -0,0 +1,173 @@ +// Create hub view TUI - interactive hub creation for humans +// Allows creating new feature hubs with wikilinks and descriptions + +package ui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type createHubModel struct { + inputs []textinput.Model + focusIdx int + err error + created bool + hubPath string +} + +func newCreateHubModel() createHubModel { + inputs := make([]textinput.Model, 3) + + inputs[0] = textinput.New() + inputs[0].Placeholder = "my-feature" + inputs[0].Focus() + inputs[0].CharLimit = 64 + inputs[0].Width = 40 + inputs[0].Prompt = "Hub name: " + + inputs[1] = textinput.New() + inputs[1].Placeholder = "Description of the feature" + inputs[1].CharLimit = 200 + inputs[1].Width = 60 + inputs[1].Prompt = "Feature: " + + inputs[2] = textinput.New() + inputs[2].Placeholder = "src/file.ts, src/other.ts" + inputs[2].CharLimit = 500 + inputs[2].Width = 60 + inputs[2].Prompt = "Links: " + + return createHubModel{inputs: inputs} +} + +func (m createHubModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m createHubModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc": + if m.created { + return newDashboard(), nil + } + return newDashboard(), nil + case "tab", "down": + m.focusIdx = (m.focusIdx + 1) % len(m.inputs) + return m.updateFocus() + case "shift+tab", "up": + m.focusIdx = (m.focusIdx - 1 + len(m.inputs)) % len(m.inputs) + return m.updateFocus() + case "enter": + if m.focusIdx == len(m.inputs)-1 { + return m.createHub() + } + m.focusIdx = (m.focusIdx + 1) % len(m.inputs) + return m.updateFocus() + } + } + cmd := m.updateInputs(msg) + return m, cmd +} + +func (m *createHubModel) updateFocus() (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + if i == m.focusIdx { + cmds[i] = m.inputs[i].Focus() + } else { + m.inputs[i].Blur() + } + } + return m, tea.Batch(cmds...) +} + +func (m *createHubModel) updateInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + return tea.Batch(cmds...) +} + +func (m createHubModel) createHub() (tea.Model, tea.Cmd) { + name := strings.TrimSpace(m.inputs[0].Value()) + feature := strings.TrimSpace(m.inputs[1].Value()) + links := strings.TrimSpace(m.inputs[2].Value()) + + if name == "" { + m.err = fmt.Errorf("hub name is required") + return m, nil + } + + hubsDir := filepath.Join(".contextplus", "hubs") + if err := os.MkdirAll(hubsDir, 0755); err != nil { + m.err = err + return m, nil + } + + hubPath := filepath.Join(hubsDir, name+".md") + var content strings.Builder + content.WriteString(fmt.Sprintf("# %s\n\n", name)) + if feature != "" { + content.WriteString(fmt.Sprintf("FEATURE: %s\n\n", feature)) + } + if links != "" { + content.WriteString("## Linked Files\n\n") + for _, link := range strings.Split(links, ",") { + link = strings.TrimSpace(link) + if link != "" { + content.WriteString(fmt.Sprintf("- [[%s]]\n", link)) + } + } + } + + if err := os.WriteFile(hubPath, []byte(content.String()), 0644); err != nil { + m.err = err + return m, nil + } + + m.created = true + m.hubPath = hubPath + return m, nil +} + +func (m createHubModel) View() string { + s := TitleStyle.Render("Create Feature Hub") + "\n\n" + + if m.created { + s += SuccessStyle.Render(fmt.Sprintf("Hub created: %s", m.hubPath)) + "\n\n" + s += HelpStyle.Render("Press esc to return to dashboard") + return s + } + + if m.err != nil { + s += ErrorStyle.Render(fmt.Sprintf("Error: %v", m.err)) + "\n\n" + } + + for i, input := range m.inputs { + s += input.View() + "\n" + if i < len(m.inputs)-1 { + s += "\n" + } + } + + s += "\n" + HelpStyle.Render("tab next • shift+tab prev • enter submit/next • esc cancel") + return s +} + +func RunCreateHub() { + p := tea.NewProgram(newCreateHubModel()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + } +} diff --git a/cli/internal/ui/dashboard.go b/cli/internal/ui/dashboard.go new file mode 100644 index 0000000..7ebee9b --- /dev/null +++ b/cli/internal/ui/dashboard.go @@ -0,0 +1,90 @@ +// Dashboard TUI - main interactive menu for Context+ CLI +// Provides navigation to tree, hubs, restore, memory views + +package ui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +type dashboardModel struct { + choices []string + cursor int + selected int +} + +func newDashboard() dashboardModel { + return dashboardModel{ + choices: []string{ + "Context Tree", + "Feature Hubs", + "Restore Points", + "Memory Graph", + "ACP Sessions", + "Code Structure", + "Create Hub", + "Quit", + }, + } +} + +func (m dashboardModel) Init() tea.Cmd { return nil } + +func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "up", "k": + m.cursor = Clamp(m.cursor-1, 0, len(m.choices)-1) + case "down", "j": + m.cursor = Clamp(m.cursor+1, 0, len(m.choices)-1) + case "enter", " ": + m.selected = m.cursor + switch m.cursor { + case 0: + return newTreeModel(), nil + case 1: + return newHubsModel(), nil + case 2: + return newRestoreModel(), nil + case 3: + return newMemoryModel(), nil + case 4: + return newSessionsModel(), nil + case 5: + return newCodeStructureModel(), nil + case 6: + return newCreateHubModel(), nil + case 7: + return m, tea.Quit + } + } + } + return m, nil +} + +func (m dashboardModel) View() string { + s := TitleStyle.Render("Context+ Dashboard") + "\n\n" + for i, choice := range m.choices { + cursor := " " + style := DimStyle + if m.cursor == i { + cursor = "> " + style = SelectedStyle + } + s += fmt.Sprintf("%s%s\n", cursor, style.Render(choice)) + } + s += "\n" + HelpStyle.Render("↑/k up • ↓/j down • enter select • q quit") + return s +} + +func RunDashboard() { + p := tea.NewProgram(newDashboard()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + } +} diff --git a/cli/internal/ui/hubs.go b/cli/internal/ui/hubs.go new file mode 100644 index 0000000..5f69a25 --- /dev/null +++ b/cli/internal/ui/hubs.go @@ -0,0 +1,133 @@ +// Hubs view TUI - displays feature hubs from .contextplus/hubs +// Lists hub markdown files with wikilinks and descriptions + +package ui + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type hub struct { + name string + path string + links []string + feature string +} + +type hubsModel struct { + hubs []hub + cursor int + scroll int + height int + err error +} + +func newHubsModel() hubsModel { + hubs, err := loadHubs() + return hubsModel{hubs: hubs, height: 20, err: err} +} + +func loadHubs() ([]hub, error) { + hubsDir := filepath.Join(".contextplus", "hubs") + entries, err := os.ReadDir(hubsDir) + if err != nil { + return nil, err + } + var hubs []hub + linkRe := regexp.MustCompile(`\[\[([^\]|]+)(?:\|[^\]]+)?\]\]`) + featureRe := regexp.MustCompile(`(?i)FEATURE:\s*(.+)`) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + path := filepath.Join(hubsDir, entry.Name()) + content, err := os.ReadFile(path) + if err != nil { + continue + } + text := string(content) + matches := linkRe.FindAllStringSubmatch(text, -1) + var links []string + for _, m := range matches { + links = append(links, m[1]) + } + feature := "" + if fm := featureRe.FindStringSubmatch(text); len(fm) > 1 { + feature = strings.TrimSpace(fm[1]) + } + hubs = append(hubs, hub{ + name: strings.TrimSuffix(entry.Name(), ".md"), + path: path, + links: links, + feature: feature, + }) + } + return hubs, nil +} + +func (m hubsModel) Init() tea.Cmd { return nil } + +func (m hubsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc", "backspace": + return newDashboard(), nil + case "up", "k": + m.cursor = Clamp(m.cursor-1, 0, MaxInt(0, len(m.hubs)-1)) + if m.cursor < m.scroll { + m.scroll = m.cursor + } + case "down", "j": + m.cursor = Clamp(m.cursor+1, 0, MaxInt(0, len(m.hubs)-1)) + if m.cursor >= m.scroll+m.height { + m.scroll = m.cursor - m.height + 1 + } + } + case tea.WindowSizeMsg: + m.height = msg.Height - 6 + } + return m, nil +} + +func (m hubsModel) View() string { + s := TitleStyle.Render("Feature Hubs") + "\n\n" + if m.err != nil { + s += ErrorStyle.Render(fmt.Sprintf("Error: %v", m.err)) + "\n" + s += DimStyle.Render("Run 'contextplus init' to create .contextplus/hubs/") + "\n" + } else if len(m.hubs) == 0 { + s += DimStyle.Render("No hubs found in .contextplus/hubs/") + "\n" + s += DimStyle.Render("Create a hub with 'contextplus create-hub'") + "\n" + } else { + end := MinInt(m.scroll+m.height, len(m.hubs)) + for i := m.scroll; i < end; i++ { + h := m.hubs[i] + line := fmt.Sprintf("%s (%d links)", h.name, len(h.links)) + if h.feature != "" { + line += " - " + h.feature + } + if i == m.cursor { + s += SelectedStyle.Render("> "+line) + "\n" + } else { + s += DimStyle.Render(" "+line) + "\n" + } + } + } + s += "\n" + HelpStyle.Render("↑/k up • ↓/j down • esc back • q quit") + return s +} + +func RunHubs() { + p := tea.NewProgram(newHubsModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + } +} diff --git a/cli/internal/ui/memory.go b/cli/internal/ui/memory.go new file mode 100644 index 0000000..bb6e0f9 --- /dev/null +++ b/cli/internal/ui/memory.go @@ -0,0 +1,177 @@ +// Memory view TUI - displays memory graph nodes and relations +// Reads from .contextplus/memories/graph.json for visualization + +package ui + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + tea "github.com/charmbracelet/bubbletea" +) + +type memoryNode struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` + Metadata map[string]string `json:"metadata"` +} + +type memoryEdge struct { + ID string `json:"id"` + Source string `json:"source"` + Target string `json:"target"` + Relation string `json:"relation"` + Weight float64 `json:"weight"` +} + +type memoryGraph struct { + Nodes []memoryNode `json:"nodes"` + Edges []memoryEdge `json:"edges"` +} + +type memoryModel struct { + graph memoryGraph + cursor int + scroll int + height int + err error + mode string +} + +func newMemoryModel() memoryModel { + graph, err := loadMemoryGraph() + return memoryModel{graph: graph, height: 20, err: err, mode: "nodes"} +} + +func loadMemoryGraph() (memoryGraph, error) { + graphPath := filepath.Join(".contextplus", "memories", "graph.json") + data, err := os.ReadFile(graphPath) + if err != nil { + return memoryGraph{}, err + } + var graph memoryGraph + if err := json.Unmarshal(data, &graph); err != nil { + return memoryGraph{}, err + } + return graph, nil +} + +func (m memoryModel) Init() tea.Cmd { return nil } + +func (m memoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + maxIdx := m.maxIndex() + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc", "backspace": + return newDashboard(), nil + case "tab": + if m.mode == "nodes" { + m.mode = "edges" + } else { + m.mode = "nodes" + } + m.cursor, m.scroll = 0, 0 + case "up", "k": + m.cursor = Clamp(m.cursor-1, 0, maxIdx) + if m.cursor < m.scroll { + m.scroll = m.cursor + } + case "down", "j": + m.cursor = Clamp(m.cursor+1, 0, maxIdx) + if m.cursor >= m.scroll+m.height { + m.scroll = m.cursor - m.height + 1 + } + } + case tea.WindowSizeMsg: + m.height = msg.Height - 8 + } + return m, nil +} + +func (m memoryModel) maxIndex() int { + if m.mode == "nodes" { + return MaxInt(0, len(m.graph.Nodes)-1) + } + return MaxInt(0, len(m.graph.Edges)-1) +} + +func (m memoryModel) View() string { + title := "Memory Graph - Nodes" + if m.mode == "edges" { + title = "Memory Graph - Edges" + } + s := TitleStyle.Render(title) + "\n" + s += DimStyle.Render(fmt.Sprintf("(%d nodes, %d edges)", len(m.graph.Nodes), len(m.graph.Edges))) + "\n\n" + + if m.err != nil { + s += ErrorStyle.Render(fmt.Sprintf("Error: %v", m.err)) + "\n" + s += DimStyle.Render("Use create_memory tool to add memory nodes.") + "\n" + } else if m.mode == "nodes" { + s += m.renderNodes() + } else { + s += m.renderEdges() + } + s += "\n" + HelpStyle.Render("tab switch • ↑/k up • ↓/j down • esc back • q quit") + return s +} + +func (m memoryModel) renderNodes() string { + if len(m.graph.Nodes) == 0 { + return DimStyle.Render("No memory nodes found.") + "\n" + } + var s string + end := MinInt(m.scroll+m.height, len(m.graph.Nodes)) + for i := m.scroll; i < end; i++ { + n := m.graph.Nodes[i] + line := fmt.Sprintf("[%s] %s (%s)", n.Type, n.Label, n.ID[:8]) + if i == m.cursor { + s += SelectedStyle.Render("> "+line) + "\n" + } else { + s += DimStyle.Render(" "+line) + "\n" + } + } + return s +} + +func (m memoryModel) renderEdges() string { + if len(m.graph.Edges) == 0 { + return DimStyle.Render("No memory edges found.") + "\n" + } + nodeMap := make(map[string]string) + for _, n := range m.graph.Nodes { + nodeMap[n.ID] = n.Label + } + var s string + end := MinInt(m.scroll+m.height, len(m.graph.Edges)) + for i := m.scroll; i < end; i++ { + e := m.graph.Edges[i] + srcLabel := nodeMap[e.Source] + tgtLabel := nodeMap[e.Target] + if srcLabel == "" { + srcLabel = e.Source[:8] + } + if tgtLabel == "" { + tgtLabel = e.Target[:8] + } + line := fmt.Sprintf("%s --%s--> %s (%.2f)", srcLabel, e.Relation, tgtLabel, e.Weight) + if i == m.cursor { + s += SelectedStyle.Render("> "+line) + "\n" + } else { + s += DimStyle.Render(" "+line) + "\n" + } + } + return s +} + +func RunMemory() { + p := tea.NewProgram(newMemoryModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + } +} diff --git a/cli/internal/ui/restore.go b/cli/internal/ui/restore.go new file mode 100644 index 0000000..18dab24 --- /dev/null +++ b/cli/internal/ui/restore.go @@ -0,0 +1,127 @@ +// Restore view TUI - displays and manages checkpoint restore points +// Reads from .contextplus/checkpoints and allows undo operations + +package ui + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +type restorePoint struct { + ID string `json:"id"` + Timestamp int64 `json:"timestamp"` + Files []string `json:"files"` + Message string `json:"message"` +} + +type restoreModel struct { + points []restorePoint + cursor int + scroll int + height int + err error + restored string +} + +func newRestoreModel() restoreModel { + points, err := loadRestorePoints() + return restoreModel{points: points, height: 20, err: err} +} + +func loadRestorePoints() ([]restorePoint, error) { + indexPath := filepath.Join(".contextplus", "checkpoints", "index.json") + data, err := os.ReadFile(indexPath) + if err != nil { + return nil, err + } + var points []restorePoint + if err := json.Unmarshal(data, &points); err != nil { + return nil, err + } + sort.Slice(points, func(i, j int) bool { + return points[i].Timestamp > points[j].Timestamp + }) + return points, nil +} + +func (m restoreModel) Init() tea.Cmd { return nil } + +func (m restoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc", "backspace": + return newDashboard(), nil + case "up", "k": + m.cursor = Clamp(m.cursor-1, 0, MaxInt(0, len(m.points)-1)) + if m.cursor < m.scroll { + m.scroll = m.cursor + } + case "down", "j": + m.cursor = Clamp(m.cursor+1, 0, MaxInt(0, len(m.points)-1)) + if m.cursor >= m.scroll+m.height { + m.scroll = m.cursor - m.height + 1 + } + case "enter", "r": + if len(m.points) > 0 && m.cursor < len(m.points) { + m.restored = m.points[m.cursor].ID + } + } + case tea.WindowSizeMsg: + m.height = msg.Height - 8 + } + return m, nil +} + +func (m restoreModel) View() string { + s := TitleStyle.Render("Restore Points") + "\n\n" + if m.restored != "" { + s += SuccessStyle.Render(fmt.Sprintf("Selected: %s (use MCP to restore)", m.restored)) + "\n\n" + } + if m.err != nil { + s += ErrorStyle.Render(fmt.Sprintf("Error: %v", m.err)) + "\n" + s += DimStyle.Render("No checkpoints found. Use checkpoint tool to create restore points.") + "\n" + } else if len(m.points) == 0 { + s += DimStyle.Render("No restore points found.") + "\n" + } else { + end := MinInt(m.scroll+m.height, len(m.points)) + for i := m.scroll; i < end; i++ { + p := m.points[i] + ts := time.Unix(p.Timestamp/1000, 0).Format("2006-01-02 15:04:05") + line := fmt.Sprintf("%s | %s | %d file(s)", p.ID[:8], ts, len(p.Files)) + if p.Message != "" { + line += " | " + truncate(p.Message, 30) + } + if i == m.cursor { + s += SelectedStyle.Render("> "+line) + "\n" + } else { + s += DimStyle.Render(" "+line) + "\n" + } + } + } + s += "\n" + HelpStyle.Render("↑/k up • ↓/j down • enter/r select • esc back • q quit") + return s +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} + +func RunRestore() { + p := tea.NewProgram(newRestoreModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + } +} diff --git a/cli/internal/ui/sessions.go b/cli/internal/ui/sessions.go new file mode 100644 index 0000000..be4ec90 --- /dev/null +++ b/cli/internal/ui/sessions.go @@ -0,0 +1,149 @@ +// Sessions view TUI - displays imported ACP sessions from external agents +// Reads from .contextplus/external_memories/sessions.json + +package ui + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +type acpSession struct { + ID string `json:"id"` + Source string `json:"source"` + Timestamp int64 `json:"timestamp"` + Title string `json:"title"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` +} + +type sessionsModel struct { + sessions []acpSession + cursor int + scroll int + height int + err error + filter string +} + +func newSessionsModel() sessionsModel { + sessions, err := loadACPSessions() + return sessionsModel{sessions: sessions, height: 20, err: err} +} + +func loadACPSessions() ([]acpSession, error) { + path := filepath.Join(".contextplus", "external_memories", "sessions.json") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var sessions []acpSession + if err := json.Unmarshal(data, &sessions); err != nil { + return nil, err + } + return sessions, nil +} + +func (m sessionsModel) Init() tea.Cmd { return nil } + +func (m sessionsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc", "backspace": + return newDashboard(), nil + case "up", "k": + m.cursor = Clamp(m.cursor-1, 0, MaxInt(0, len(m.sessions)-1)) + if m.cursor < m.scroll { + m.scroll = m.cursor + } + case "down", "j": + m.cursor = Clamp(m.cursor+1, 0, MaxInt(0, len(m.sessions)-1)) + if m.cursor >= m.scroll+m.height { + m.scroll = m.cursor - m.height + 1 + } + case "1": + m.filter = "opencode" + m.cursor, m.scroll = 0, 0 + case "2": + m.filter = "copilot" + m.cursor, m.scroll = 0, 0 + case "3": + m.filter = "claude" + m.cursor, m.scroll = 0, 0 + case "4": + m.filter = "codex" + m.cursor, m.scroll = 0, 0 + case "0": + m.filter = "" + m.cursor, m.scroll = 0, 0 + } + case tea.WindowSizeMsg: + m.height = msg.Height - 8 + } + return m, nil +} + +func (m sessionsModel) filtered() []acpSession { + if m.filter == "" { + return m.sessions + } + var out []acpSession + for _, s := range m.sessions { + if s.Source == m.filter { + out = append(out, s) + } + } + return out +} + +func (m sessionsModel) View() string { + s := TitleStyle.Render("ACP Sessions") + "\n" + if m.filter != "" { + s += DimStyle.Render(fmt.Sprintf("Filter: %s", m.filter)) + "\n" + } + s += "\n" + + if m.err != nil { + s += ErrorStyle.Render(fmt.Sprintf("Error: %v", m.err)) + "\n" + s += DimStyle.Render("Use import_acp tool to import agent sessions.") + "\n" + } else { + filtered := m.filtered() + if len(filtered) == 0 { + s += DimStyle.Render("No sessions found.") + "\n" + } else { + end := MinInt(m.scroll+m.height, len(filtered)) + for i := m.scroll; i < end; i++ { + sess := filtered[i] + ts := time.Unix(sess.Timestamp/1000, 0).Format("2006-01-02") + line := fmt.Sprintf("[%s] %s | %s (%d msgs)", sess.Source, sess.ID[:12], ts, len(sess.Messages)) + if sess.Title != "" { + line += " | " + truncate(sess.Title, 25) + } + if i == m.cursor { + s += SelectedStyle.Render("> "+line) + "\n" + } else { + s += DimStyle.Render(" "+line) + "\n" + } + } + } + } + s += "\n" + HelpStyle.Render("1-4 filter source • 0 clear • ↑/k ↓/j nav • esc back • q quit") + return s +} + +func RunSessions() { + p := tea.NewProgram(newSessionsModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + } +} diff --git a/cli/internal/ui/styles.go b/cli/internal/ui/styles.go new file mode 100644 index 0000000..f80640e --- /dev/null +++ b/cli/internal/ui/styles.go @@ -0,0 +1,34 @@ +// Core TUI types and shared styling for Context+ CLI views +// Provides common lipgloss styles and model interfaces + +package ui + +import "github.com/charmbracelet/lipgloss" + +var ( + TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205")) + HeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + SelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")) + DimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + HelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("82")) +) + +func MaxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func MinInt(a, b int) int { + if a < b { + return a + } + return b +} + +func Clamp(val, minVal, maxVal int) int { + return MinInt(MaxInt(val, minVal), maxVal) +} diff --git a/cli/internal/ui/tree.go b/cli/internal/ui/tree.go new file mode 100644 index 0000000..14fcc82 --- /dev/null +++ b/cli/internal/ui/tree.go @@ -0,0 +1,144 @@ +// Tree view TUI - displays project context tree structure +// Reads from .contextplus and displays files with headers/symbols + +package ui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type treeNode struct { + path string + name string + isDir bool + depth int + children []*treeNode +} + +type treeModel struct { + root *treeNode + flat []*treeNode + cursor int + scroll int + height int + err error +} + +func newTreeModel() treeModel { + root, err := buildTree(".") + flat := flattenTree(root, nil) + return treeModel{root: root, flat: flat, height: 20, err: err} +} + +func buildTree(root string) (*treeNode, error) { + node := &treeNode{path: root, name: filepath.Base(root), isDir: true, depth: 0} + err := walkDir(root, node, 0, 4) + return node, err +} + +func walkDir(path string, parent *treeNode, depth, maxDepth int) error { + if depth > maxDepth { + return nil + } + entries, err := os.ReadDir(path) + if err != nil { + return err + } + for _, entry := range entries { + name := entry.Name() + if shouldSkip(name) { + continue + } + fullPath := filepath.Join(path, name) + child := &treeNode{path: fullPath, name: name, isDir: entry.IsDir(), depth: depth + 1} + parent.children = append(parent.children, child) + if entry.IsDir() { + walkDir(fullPath, child, depth+1, maxDepth) + } + } + return nil +} + +func shouldSkip(name string) bool { + skip := []string{".git", "node_modules", ".mcp_data", "build", "dist", ".next", "__pycache__", ".venv", "vendor"} + for _, s := range skip { + if name == s { + return true + } + } + return strings.HasPrefix(name, ".") +} + +func flattenTree(node *treeNode, flat []*treeNode) []*treeNode { + if node == nil { + return flat + } + flat = append(flat, node) + for _, child := range node.children { + flat = flattenTree(child, flat) + } + return flat +} + +func (m treeModel) Init() tea.Cmd { return nil } + +func (m treeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc", "backspace": + return newDashboard(), nil + case "up", "k": + m.cursor = Clamp(m.cursor-1, 0, len(m.flat)-1) + if m.cursor < m.scroll { + m.scroll = m.cursor + } + case "down", "j": + m.cursor = Clamp(m.cursor+1, 0, len(m.flat)-1) + if m.cursor >= m.scroll+m.height { + m.scroll = m.cursor - m.height + 1 + } + } + case tea.WindowSizeMsg: + m.height = msg.Height - 6 + } + return m, nil +} + +func (m treeModel) View() string { + s := TitleStyle.Render("Context Tree") + "\n\n" + if m.err != nil { + s += ErrorStyle.Render(fmt.Sprintf("Error: %v", m.err)) + "\n" + } + end := MinInt(m.scroll+m.height, len(m.flat)) + for i := m.scroll; i < end; i++ { + node := m.flat[i] + prefix := strings.Repeat(" ", node.depth) + icon := "📄" + if node.isDir { + icon = "📁" + } + line := fmt.Sprintf("%s%s %s", prefix, icon, node.name) + if i == m.cursor { + s += SelectedStyle.Render("> "+line) + "\n" + } else { + s += DimStyle.Render(" "+line) + "\n" + } + } + s += "\n" + HelpStyle.Render("↑/k up • ↓/j down • esc back • q quit") + return s +} + +func RunTree() { + p := tea.NewProgram(newTreeModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + } +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..46f75ba --- /dev/null +++ b/cli/main.go @@ -0,0 +1,92 @@ +// Context+ CLI with Bubble Tea TUI for visualizing project context +// Commands: tree, hubs, restore, memory, sessions, create-hub, init + +package main + +import ( + "fmt" + "os" + + "github.com/contextplus/cli/internal/ui" +) + +func main() { + if len(os.Args) < 2 { + ui.RunDashboard() + return + } + + cmd := os.Args[1] + switch cmd { + case "init": + runInit() + case "tree": + ui.RunTree() + case "hubs": + ui.RunHubs() + case "restore", "undo": + ui.RunRestore() + case "memory", "mem": + ui.RunMemory() + case "sessions", "acp": + ui.RunSessions() + case "structure", "ast": + ui.RunCodeStructure() + case "create-hub", "hub": + ui.RunCreateHub() + case "help", "-h", "--help": + printHelp() + case "version", "-v", "--version": + fmt.Println("contextplus-cli v1.0.0") + default: + fmt.Printf("Unknown command: %s\n", cmd) + printHelp() + os.Exit(1) + } +} + +func runInit() { + cwd, _ := os.Getwd() + dirs := []string{".contextplus", ".contextplus/hubs", ".contextplus/embeddings", ".contextplus/config", ".contextplus/memories", ".contextplus/memories/nodes", ".contextplus/external_memories"} + for _, d := range dirs { + os.MkdirAll(d, 0755) + } + + files := map[string]string{ + ".contextplus/memories/graph.json": `{"nodes":{},"edges":{}}`, + ".contextplus/external_memories/sessions.json": `[]`, + ".contextplus/external_memories/memories.json": `[]`, + ".contextplus/hubs/README.md": "# Context+ Hubs\n\nUse markdown files with [[path/to/file]] links to group features.\n", + } + for path, content := range files { + if _, err := os.Stat(path); os.IsNotExist(err) { + os.WriteFile(path, []byte(content), 0644) + } + } + + fmt.Printf("Initialized contextplus in %s\n", cwd) + fmt.Println("Created directories and default files") + fmt.Println("") + fmt.Println("To generate embeddings for semantic search, use the MCP init tool:") + fmt.Println(" - Use the 'init' MCP tool via your agent (Claude, Cursor, etc.)") + fmt.Println(" - Or run: npx contextplus init") +} + +func printHelp() { + fmt.Println(`contextplus-cli - Context+ TUI + +Usage: contextplus [command] + +Commands: + (none) Open interactive dashboard + tree View context tree + hubs View feature hubs + restore View/restore checkpoints + memory View memory graph + sessions View ACP agent sessions + structure Code structure analysis + create-hub Create a new hub + init Initialize .contextplus folder + help Show this help + version Show version`) +} diff --git a/landing/src/app/api/instructions/route.ts b/landing/src/app/api/instructions/route.ts index 6c771a2..facb373 100644 --- a/landing/src/app/api/instructions/route.ts +++ b/landing/src/app/api/instructions/route.ts @@ -15,33 +15,33 @@ The MCP server is built with TypeScript and communicates over stdio using the Mo - \`parser.ts\` - Multi-language symbol extraction via tree-sitter AST with regex fallback. Supports 14+ languages. - \`tree-sitter.ts\` - WASM grammar loader for 43 file extensions using web-tree-sitter 0.20.8. - \`walker.ts\` - Gitignore-aware recursive directory traversal with depth and target path control. -- \`embeddings.ts\` - Ollama vector embedding engine with disk cache, cosine similarity search, and API key support. +- \`embeddings.ts\` - Ollama/OpenAI-compatible vector embedding engine backed by vector DB persistence. **Tools Layer** (\`src/tools/\`): -- \`context-tree.ts\` - Token-aware structural tree with symbol line ranges and Level 0/1/2 pruning. -- \`file-skeleton.ts\` - Function signatures with line ranges, without reading full bodies. -- \`semantic-search.ts\` - Ollama-powered semantic file search with symbol definition lines and 60s cache TTL. -- \`semantic-identifiers.ts\` - Identifier-level semantic search returning ranked definitions + call chains with line numbers. -- \`semantic-navigate.ts\` - Browse-by-meaning navigator using spectral clustering and Ollama labeling. +- \`context-tree.ts\` - \`tree\` tool implementation for token-aware structural mapping. +- \`file-skeleton.ts\` - \`skeleton\` tool implementation for signatures and type surfaces. +- \`search.ts\` - Unified \`search\` tool for file and identifier retrieval. +- \`semantic-navigate.ts\` - \`cluster\` tool implementation for spectral semantic grouping. - \`blast-radius.ts\` - Symbol usage tracer across the entire codebase. -- \`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). +- \`static-analysis.ts\` - \`lint\` runner with project/file skill scoring output. +- \`propose-commit.ts\` - \`checkpoint\` tool for validated writes with local restore points. +- \`feature-hub.ts\` - \`find_hub\` ranking and full-project hub context fallback. +- \`memory-tools.ts\` - Memory graph wrappers for create/search/explore/bulk/update/delete flows. +- \`init.ts\` - Project bootstrap tool for \`.contextplus\` directories and context snapshot. -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. +The memory graph is a **Retrieval-Augmented Generation (RAG)** system. Agents SHOULD use \`search_memory\` early in each task to retrieve prior context, and persist learnings with \`create_memory\` and \`create_relation\` after completing work. Stale links are pruned automatically before graph access. **Core Layer** (continued): - \`hub.ts\` - Wikilink parser for \`[[path]]\` links, cross-link tags, hub discovery, orphan detection. -- \`memory-graph.ts\` - In-memory property graph with JSON persistence, decay scoring, and auto-similarity edges. +- \`memory-graph.ts\` - Graph metadata + markdown node files + vector DB-backed semantic retrieval. **Git Layer** (\`src/git/\`): - \`shadow.ts\` - Shadow restore point system for undo without touching git history. -**Entry Point**: \`src/index.ts\` registers 17 MCP tools and starts the stdio transport. Accepts an optional CLI argument for the target project root directory (defaults to \`process.cwd()\`). +**Entry Point**: \`src/index.ts\` registers 18 MCP tools and starts the stdio transport. Accepts an optional CLI argument for the target project root directory (defaults to \`process.cwd()\`). ## Environment Variables @@ -55,18 +55,18 @@ The memory graph is a **Retrieval-Augmented Generation (RAG)** system. Agents MU | \`CONTEXTPLUS_EMBED_TRACKER_MAX_FILES\` | \`8\` | Max changed files per tracker tick (hard-capped to 5-10) | | \`CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS\` | \`700\` | Debounce before applying tracker refresh | -Runtime cache: \`.mcp_data/\` is created at MCP startup and stores reusable embedding vectors for files, identifiers, and call sites. A realtime tracker watches file updates and refreshes changed function/file embeddings incrementally. +Runtime storage: \`.contextplus/\` is created by \`init\` and stores hubs, memory graph data, and vector DB embeddings. A realtime tracker can refresh changed function/file embeddings incrementally. ## Fast Execute Mode (Mandatory) Default to execution-first behavior. Use minimal tokens, minimal narration, and maximum tool leverage. -1. Skip long planning prose. Start with lightweight scoping: \`get_context_tree\` and \`get_file_skeleton\`. +1. Skip long planning prose. Start with lightweight scoping: \`tree\` and \`skeleton\`. 2. Run independent discovery operations in parallel whenever possible (for example, multiple searches/reads). 3. Prefer structural tools over full-file reads to conserve context. -4. Before modifying or deleting symbols, run \`get_blast_radius\`. -5. Write changes through \`propose_commit\` only. -6. Run \`run_static_analysis\` once after edits, or once per changed module for larger refactors. +4. Before modifying or deleting symbols, run \`blast_radius\`. +5. Write changes through \`checkpoint\`. +6. Run \`lint\` once after edits, or once per changed module for larger refactors. ### Execution Rules @@ -79,7 +79,7 @@ Default to execution-first behavior. Use minimal tokens, minimal narration, and ### Token-Efficiency Rules 1. Treat 100 effective tokens as better than 1000 vague tokens. -2. Use high-signal tool calls first (\`get_file_skeleton\`, \`get_context_tree\`, \`get_blast_radius\`). +2. Use high-signal tool calls first (\`skeleton\`, \`tree\`, \`blast_radius\`). 3. Read full file bodies only when signatures/structure are insufficient. 4. Avoid repeated scans of unchanged areas. 5. Prefer direct edits + deterministic validation over extended speculative analysis. @@ -129,25 +129,26 @@ Strict order within every file: ## Tool Reference -| Tool | When to Use | -| ---------------------------- | ---------------------------------------------------------------------------------- | -| \`get_context_tree\` | Start of every task. Map files + symbols with line ranges. | -| \`semantic_navigate\` | Browse codebase by meaning, not directory structure. | -| \`get_file_skeleton\` | MUST run before full reads. Get signatures + line ranges first. | -| \`semantic_code_search\` | Find relevant files by concept with symbol definition lines. | -| \`semantic_identifier_search\` | Find closest functions/classes/variables and ranked call chains with line numbers. | -| \`get_blast_radius\` | Before deleting or modifying any symbol. | -| \`run_static_analysis\` | After writing code. Catch dead code deterministically. | -| \`propose_commit\` | The ONLY way to save files. Validates before writing. | -| \`list_restore_points\` | See undo history. | -| \`undo_change\` | Revert a bad AI change without touching git. | -| \`get_feature_hub\` | Browse feature graph hubs. Find orphaned files. | -| \`upsert_memory_node\` | Create/update memory nodes (concept, file, symbol, note) with auto-embedding. | -| \`create_relation\` | Create typed edges between memory nodes (depends_on, implements, etc). | -| \`search_memory_graph\` | Semantic search + graph traversal across 1st/2nd-degree neighbors. | -| \`prune_stale_links\` | Remove decayed edges (e^(-λt)) and orphan nodes periodically. | -| \`add_interlinked_context\` | Bulk-add nodes with auto-similarity linking (cosine ≥ 0.72). | -| \`retrieve_with_traversal\` | Start from a node, walk outward, return scored neighbors by decay and depth. | +| Tool | When to Use | +| ----------------- | ---------------------------------------------------------------------------------- | +| \`init\` | Bootstrap \`.contextplus\` structure and context snapshot for a project. | +| \`tree\` | Start of every task. Map files + symbols with line ranges. | +| \`cluster\` | Browse codebase by meaning, not directory structure. | +| \`skeleton\` | Run before full reads. Get signatures + line ranges first. | +| \`search\` | Unified semantic/keyword search across files, identifiers, or both. | +| \`blast_radius\` | Before deleting or modifying any symbol. | +| \`lint\` | After writing code. Catch dead code and get skill scoring. | +| \`checkpoint\` | Save file changes with validation and local restore-point creation. | +| \`restore_points\` | See local restore history. | +| \`restore\` | Revert to a specific restore point without touching git history. | +| \`find_hub\` | Rank hubs by query or list all hub context when query is omitted. | +| \`create_memory\` | Create/update memory nodes with embedding refresh. | +| \`create_relation\` | Create typed edges between memory nodes (depends_on, implements, etc). | +| \`search_memory\` | Semantic/keyword memory search with neighborhood traversal. | +| \`explore_memory\` | Traverse outward from a known memory node id. | +| \`bulk_memory\` | Bulk-add nodes and optional auto-linking. | +| \`update_memory\` | Update existing memory content and refresh embeddings. | +| \`delete_memory\` | Delete nodes or relations from the memory graph. | ## Anti-Patterns to Avoid diff --git a/landing/src/app/page.tsx b/landing/src/app/page.tsx index 8e9f959..a7a211b 100644 --- a/landing/src/app/page.tsx +++ b/landing/src/app/page.tsx @@ -9,7 +9,7 @@ export const dynamic = "force-dynamic"; const toolRefRows = [ { - name: "get_context_tree", + name: "tree", desc: "Get the structural AST tree of a project with file headers plus line-numbered function/class/method symbols. Dynamic token-aware pruning shrinks output automatically.", input: "{\n target_path?: string,\n depth_limit?: number,\n include_symbols?: boolean,\n max_tokens?: number\n}", @@ -17,71 +17,71 @@ const toolRefRows = [ '"src/\n index.ts - Entry point\n function: getStars() (L170-L181)\n function: Home() (L183-L760)\n utils/\n parser.ts - AST parsing\n function: parseFile() (L22-L84)\n function: walkTree() (L86-L132)"', }, { - name: "get_file_skeleton", + name: "skeleton", desc: "Get function signatures, class methods, and type definitions of a file with line ranges, without reading the full body.", input: "{ file_path: string }", output: '"[function] L12-L58 export function parseFile(\n filePath: string,\n options?: ParseOptions\n): Promise;\n\n[class] L60-L130 export class Walker;\n [method] L72-L94 walk(node: Node): void;\n [method] L96-L118 getSymbols(): Symbol[];"', }, { - name: "semantic_code_search", - desc: "Search the codebase by meaning, not exact text. Uses embeddings over file headers/symbols and returns matched definition lines.", - input: "{ query: string, top_k?: number }", + name: "search", + desc: "Unified semantic/keyword search for files or identifiers. Supports file, identifier, and hybrid modes.", + input: '{ query: string, search_type?: "file" | "identifier" | "hybrid", mode?: "semantic" | "keyword" | "both", top_k?: number }', output: '"1. src/auth/jwt.ts (94.0% total)\n Semantic: 91.5% | Keyword: 96.2%\n Definition lines: verifyToken@L20-L58, signToken@L60-L102\n2. src/auth/session.ts (87.4% total)\n Definition lines: createSession@L12-L42"', }, { - name: "semantic_identifier_search", - desc: "Find closest functions/classes/variables by meaning, then return ranked definition and call-chain locations with line numbers. Uses realtime-refreshed identifier embeddings.", + name: "search", + desc: "Identifier-focused search using the unified search tool with ranked definitions and call-chain locations.", input: - "{\n query: string,\n top_k?: number,\n top_calls_per_identifier?: number,\n include_kinds?: string[]\n}", + '{\n query: string,\n search_type: "identifier",\n top_k?: number,\n top_calls_per_identifier?: number,\n include_kinds?: string[]\n}', output: '"1. function verifyToken - src/auth/jwt.ts (L20-L58)\n Score: 92.4%\n Calls (3/3):\n 1. src/middleware/guard.ts:L33 (88.1%) verifyToken(token)\n 2. src/routes/api.ts:L12 (84.7%) const user = verifyToken(raw)\n2. variable tokenExpiry - src/auth/config.ts (L8)"', }, { - name: "get_blast_radius", + name: "blast_radius", desc: "Before modifying code, trace every file and line where a symbol is imported or used. Prevents orphaned references.", input: "{\n symbol_name: string,\n file_context?: string\n}", output: '"parseFile - 7 usages\n src/index.ts:14 import { parseFile }\n src/tools/tree.ts:8 const ast = parseFile(p)\n src/tools/skeleton.ts:22 parseFile(path)\n test/parser.test.ts:5 import { parseFile }"', }, { - name: "run_static_analysis", - desc: "Run the native linter or compiler to find unused variables, dead code, and type errors. Supports TypeScript, Python, Rust, Go.", + name: "lint", + desc: "Run native lint/compiler checks plus skill scoring to find errors, dead code, and rule violations.", input: "{ target_path?: string }", output: '"src/utils.ts:14:5\n error TS2345: Argument of type string\n is not assignable to parameter\n\nsrc/old.ts:1:1\n warning: file has no exports"', }, { - name: "propose_commit", - desc: "The only way to write code. Validates against strict rules before saving. Creates a shadow restore point before writing.", + name: "checkpoint", + desc: "Write code after validation and create a local restore point before saving.", input: "{\n file_path: string,\n new_content: string\n}", output: '"✓ Header comment present\n✓ No inline comments\n✓ Max nesting depth: 3\n✓ File length: 142 lines\n\nSaved src/tools/search.ts\nRestore point: rp-1719384000-a3f2"', }, { - name: "list_restore_points", - desc: "List all shadow restore points created by propose_commit. Each captures file state before AI changes.", + name: "restore_points", + desc: "List all local restore points created by checkpoint. Each captures file state before AI changes.", input: "{ }", output: '"rp-1719384000-a3f2 | 2025-06-26\n src/tools/search.ts | refactor search\n\nrp-1719383000-b7c1 | 2025-06-26\n src/index.ts | add new tool"', }, { - name: "undo_change", - desc: "Restore files to their state before a specific AI change. Uses shadow restore points. Does not affect git.", + name: "restore", + desc: "Restore files to their state before a specific AI change. Uses local restore points. Does not affect git.", input: "{ point_id: string }", output: '"Restored 1 file(s):\n src/tools/search.ts"', }, { - name: "semantic_navigate", + name: "cluster", desc: "Browse codebase by meaning using spectral clustering. Groups semantically related files into labeled clusters.", input: "{\n max_depth?: number,\n max_clusters?: number\n}", output: '"Authentication (4 files)\n src/auth/jwt.ts\n src/auth/session.ts\n src/middleware/guard.ts\n src/models/user.ts\n\nParsing (3 files)\n src/core/parser.ts\n src/core/tree-sitter.ts\n src/core/walker.ts"', }, { - name: "get_feature_hub", - desc: "Obsidian-style feature hub navigator. Hubs are .md files with [[wikilinks]] that map features to code files.", + name: "find_hub", + desc: "Rank feature hubs by semantic/keyword query, or return all hubs context when query is omitted.", input: "{\n hub_path?: string,\n feature_name?: string,\n show_orphans?: boolean\n}", output: diff --git a/landing/src/components/InstructionsSection.tsx b/landing/src/components/InstructionsSection.tsx index 3093567..979f062 100644 --- a/landing/src/components/InstructionsSection.tsx +++ b/landing/src/components/InstructionsSection.tsx @@ -17,33 +17,33 @@ The MCP server is built with TypeScript and communicates over stdio using the Mo - \`parser.ts\` - Multi-language symbol extraction via tree-sitter AST with regex fallback. Supports 14+ languages. - \`tree-sitter.ts\` - WASM grammar loader for 43 file extensions using web-tree-sitter 0.20.8. - \`walker.ts\` - Gitignore-aware recursive directory traversal with depth and target path control. -- \`embeddings.ts\` - Ollama vector embedding engine with disk cache, cosine similarity search, and API key support. +- \`embeddings.ts\` - Ollama/OpenAI-compatible vector embedding engine backed by vector DB persistence. **Tools Layer** (\`src/tools/\`): -- \`context-tree.ts\` - Token-aware structural tree with symbol line ranges and Level 0/1/2 pruning. -- \`file-skeleton.ts\` - Function signatures with line ranges, without reading full bodies. -- \`semantic-search.ts\` - Ollama-powered semantic file search with symbol definition lines and 60s cache TTL. -- \`semantic-identifiers.ts\` - Identifier-level semantic search returning ranked definitions + call chains with line numbers. -- \`semantic-navigate.ts\` - Browse-by-meaning navigator using spectral clustering and Ollama labeling. +- \`context-tree.ts\` - \`tree\` tool implementation for token-aware structural mapping. +- \`file-skeleton.ts\` - \`skeleton\` tool implementation for signatures and type surfaces. +- \`search.ts\` - Unified \`search\` tool for file and identifier retrieval. +- \`semantic-navigate.ts\` - \`cluster\` tool implementation for spectral semantic grouping. - \`blast-radius.ts\` - Symbol usage tracer across the entire codebase. -- \`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). +- \`static-analysis.ts\` - \`lint\` runner with project/file skill scoring output. +- \`propose-commit.ts\` - \`checkpoint\` tool for validated writes with local restore points. +- \`feature-hub.ts\` - \`find_hub\` ranking and full-project hub context fallback. +- \`memory-tools.ts\` - Memory graph wrappers for create/search/explore/bulk/update/delete flows. +- \`init.ts\` - Project bootstrap tool for \`.contextplus\` directories and context snapshot. -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. +The memory graph is a **Retrieval-Augmented Generation (RAG)** system. Agents SHOULD use \`search_memory\` early in each task to retrieve prior context, and persist learnings with \`create_memory\` and \`create_relation\` after completing work. Stale links are pruned automatically before graph access. **Core Layer** (continued): - \`hub.ts\` - Wikilink parser for \`[[path]]\` links, cross-link tags, hub discovery, orphan detection. -- \`memory-graph.ts\` - In-memory property graph with JSON persistence, decay scoring, and auto-similarity edges. +- \`memory-graph.ts\` - Graph metadata + markdown node files + vector DB-backed semantic retrieval. **Git Layer** (\`src/git/\`): - \`shadow.ts\` - Shadow restore point system for undo without touching git history. -**Entry Point**: \`src/index.ts\` registers 17 MCP tools and starts the stdio transport. Accepts an optional CLI argument for the target project root directory (defaults to \`process.cwd()\`). +**Entry Point**: \`src/index.ts\` registers 18 MCP tools and starts the stdio transport. Accepts an optional CLI argument for the target project root directory (defaults to \`process.cwd()\`). ## Environment Variables @@ -57,18 +57,18 @@ The memory graph is a **Retrieval-Augmented Generation (RAG)** system. Agents MU | \`CONTEXTPLUS_EMBED_TRACKER_MAX_FILES\` | \`8\` | Max changed files per tracker tick (hard-capped to 5-10) | | \`CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS\` | \`700\` | Debounce before applying tracker refresh | -Runtime cache: \`.mcp_data/\` is created at MCP startup and stores reusable embedding vectors for files, identifiers, and call sites. A realtime tracker watches file updates and refreshes changed function/file embeddings incrementally. +Runtime storage: \`.contextplus/\` is created by \`init\` and stores hubs, memory graph data, and vector DB embeddings. A realtime tracker can refresh changed function/file embeddings incrementally. ## Fast Execute Mode (Mandatory) Default to execution-first behavior. Use minimal tokens, minimal narration, and maximum tool leverage. -1. Skip long planning prose. Start with lightweight scoping: \`get_context_tree\` and \`get_file_skeleton\`. +1. Skip long planning prose. Start with lightweight scoping: \`tree\` and \`skeleton\`. 2. Run independent discovery operations in parallel whenever possible (for example, multiple searches/reads). 3. Prefer structural tools over full-file reads to conserve context. -4. Before modifying or deleting symbols, run \`get_blast_radius\`. -5. Write changes through \`propose_commit\` only. -6. Run \`run_static_analysis\` once after edits, or once per changed module for larger refactors. +4. Before modifying or deleting symbols, run \`blast_radius\`. +5. Write changes through \`checkpoint\`. +6. Run \`lint\` once after edits, or once per changed module for larger refactors. ### Execution Rules @@ -81,7 +81,7 @@ Default to execution-first behavior. Use minimal tokens, minimal narration, and ### Token-Efficiency Rules 1. Treat 100 effective tokens as better than 1000 vague tokens. -2. Use high-signal tool calls first (\`get_file_skeleton\`, \`get_context_tree\`, \`get_blast_radius\`). +2. Use high-signal tool calls first (\`skeleton\`, \`tree\`, \`blast_radius\`). 3. Read full file bodies only when signatures/structure are insufficient. 4. Avoid repeated scans of unchanged areas. 5. Prefer direct edits + deterministic validation over extended speculative analysis. @@ -131,25 +131,26 @@ Strict order within every file: ## Tool Reference -| Tool | When to Use | -| ---------------------- | ------------------------------------------------------- | -| \`get_context_tree\` | Start of every task. Map files + symbols with line ranges. | -| \`semantic_navigate\` | Browse codebase by meaning, not directory structure. | -| \`get_file_skeleton\` | MUST run before full reads. Get signatures + line ranges first. | -| \`semantic_code_search\` | Find relevant files by concept with symbol definition lines. | -| \`semantic_identifier_search\` | Find closest functions/classes/variables and ranked call chains with line numbers. | -| \`get_blast_radius\` | Before deleting or modifying any symbol. | -| \`run_static_analysis\` | After writing code. Catch dead code deterministically. | -| \`propose_commit\` | The ONLY way to save files. Validates before writing. | -| \`list_restore_points\` | See undo history. | -| \`undo_change\` | Revert a bad AI change without touching git. | -| \`get_feature_hub\` | Browse feature graph hubs. Find orphaned files. | -| \`upsert_memory_node\` | Create/update memory nodes (concept, file, symbol, note) with auto-embedding. | -| \`create_relation\` | Create typed edges between memory nodes (depends_on, implements, etc). | -| \`search_memory_graph\` | Semantic search + graph traversal across 1st/2nd-degree neighbors. | -| \`prune_stale_links\` | Remove decayed edges (e^(-λt)) and orphan nodes periodically. | -| \`add_interlinked_context\` | Bulk-add nodes with auto-similarity linking (cosine ≥ 0.72). | -| \`retrieve_with_traversal\` | Start from a node, walk outward, return scored neighbors by decay and depth. | +| Tool | When to Use | +| ----------------- | ---------------------------------------------------------------------------------- | +| \`init\` | Bootstrap \`.contextplus\` structure and context snapshot for a project. | +| \`tree\` | Start of every task. Map files + symbols with line ranges. | +| \`cluster\` | Browse codebase by meaning, not directory structure. | +| \`skeleton\` | Run before full reads. Get signatures + line ranges first. | +| \`search\` | Unified semantic/keyword search across files, identifiers, or both. | +| \`blast_radius\` | Before deleting or modifying any symbol. | +| \`lint\` | After writing code. Catch dead code and get skill scoring. | +| \`checkpoint\` | Save file changes with validation and local restore-point creation. | +| \`restore_points\` | See local restore history. | +| \`restore\` | Revert to a specific restore point without touching git history. | +| \`find_hub\` | Rank hubs by query or list all hub context when query is omitted. | +| \`create_memory\` | Create/update memory nodes with embedding refresh. | +| \`create_relation\` | Create typed edges between memory nodes (depends_on, implements, etc). | +| \`search_memory\` | Semantic/keyword memory search with neighborhood traversal. | +| \`explore_memory\` | Traverse outward from a known memory node id. | +| \`bulk_memory\` | Bulk-add nodes and optional auto-linking. | +| \`update_memory\` | Update existing memory content and refresh embeddings. | +| \`delete_memory\` | Delete nodes or relations from the memory graph. | ## Anti-Patterns to Avoid diff --git a/package.json b/package.json index b21386b..aeb045c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "contextplus", - "version": "1.0.9", + "version": "2.0.0", "type": "module", "license": "MIT", "bin": { diff --git a/src/core/acp.ts b/src/core/acp.ts new file mode 100644 index 0000000..56e2d5b --- /dev/null +++ b/src/core/acp.ts @@ -0,0 +1,271 @@ +// ACP parser for importing agent sessions from opencode, copilot, claude, codex +// Converts external session formats to unified contextplus memory format + +import { readdir, readFile, mkdir, writeFile } from "fs/promises"; +import { join, basename } from "path"; +import { existsSync } from "fs"; + +export type AgentSource = "opencode" | "copilot" | "claude" | "codex" | "unknown"; + +export interface ACPSession { + id: string; + source: AgentSource; + timestamp: number; + title: string; + messages: ACPMessage[]; + metadata: Record; +} + +export interface ACPMessage { + role: "user" | "assistant" | "system"; + content: string; + timestamp?: number; +} + +export interface ACPMemory { + id: string; + source: AgentSource; + sessionId: string; + content: string; + type: "insight" | "decision" | "context" | "code"; + timestamp: number; +} + +const EXTERNAL_DIR = "external_memories"; +const SESSIONS_FILE = "sessions.json"; +const MEMORIES_FILE = "memories.json"; + +function detectSource(filePath: string, content: string): AgentSource { + const name = basename(filePath).toLowerCase(); + if (name.includes("opencode") || content.includes('"opencode"')) return "opencode"; + if (name.includes("copilot") || content.includes('"copilot"') || content.includes("github.copilot")) return "copilot"; + if (name.includes("claude") || content.includes('"claude"') || content.includes("anthropic")) return "claude"; + if (name.includes("codex") || content.includes('"codex"') || content.includes("openai")) return "codex"; + return "unknown"; +} + +function generateId(): string { + return `acp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +function parseOpenCodeSession(data: Record): ACPSession | null { + const messages: ACPMessage[] = []; + const rawMessages = (data.messages || data.conversation || []) as Record[]; + for (const msg of rawMessages) { + messages.push({ + role: (msg.role as string) === "user" ? "user" : (msg.role as string) === "system" ? "system" : "assistant", + content: String(msg.content || msg.text || ""), + timestamp: typeof msg.timestamp === "number" ? msg.timestamp : undefined, + }); + } + if (messages.length === 0) return null; + return { + id: String(data.id || generateId()), + source: "opencode", + timestamp: typeof data.timestamp === "number" ? data.timestamp : Date.now(), + title: String(data.title || data.name || "OpenCode Session"), + messages, + metadata: { model: String(data.model || "unknown") }, + }; +} + +function parseCopilotSession(data: Record): ACPSession | null { + const messages: ACPMessage[] = []; + const rawMessages = (data.turns || data.messages || []) as Record[]; + for (const msg of rawMessages) { + const content = String(msg.request || msg.response || msg.content || ""); + if (!content) continue; + messages.push({ + role: msg.request ? "user" : "assistant", + content, + timestamp: typeof msg.timestamp === "number" ? msg.timestamp : undefined, + }); + } + if (messages.length === 0) return null; + return { + id: String(data.id || generateId()), + source: "copilot", + timestamp: typeof data.createdAt === "number" ? data.createdAt : Date.now(), + title: String(data.title || "Copilot Session"), + messages, + metadata: {}, + }; +} + +function parseClaudeSession(data: Record): ACPSession | null { + const messages: ACPMessage[] = []; + const rawMessages = (data.chat_messages || data.messages || []) as Record[]; + for (const msg of rawMessages) { + messages.push({ + role: (msg.sender as string) === "human" ? "user" : "assistant", + content: String(msg.text || msg.content || ""), + timestamp: typeof msg.created_at === "string" ? Date.parse(msg.created_at as string) : undefined, + }); + } + if (messages.length === 0) return null; + return { + id: String(data.uuid || data.id || generateId()), + source: "claude", + timestamp: typeof data.created_at === "string" ? Date.parse(data.created_at as string) : Date.now(), + title: String(data.name || "Claude Session"), + messages, + metadata: { model: String(data.model || "unknown") }, + }; +} + +function parseCodexSession(data: Record): ACPSession | null { + const messages: ACPMessage[] = []; + const rawMessages = (data.messages || data.history || []) as Record[]; + for (const msg of rawMessages) { + messages.push({ + role: (msg.role as string) === "user" ? "user" : "assistant", + content: String(msg.content || ""), + }); + } + if (messages.length === 0) return null; + return { + id: String(data.id || generateId()), + source: "codex", + timestamp: Date.now(), + title: String(data.title || "Codex Session"), + messages, + metadata: {}, + }; +} + +function parseSession(source: AgentSource, data: Record): ACPSession | null { + switch (source) { + case "opencode": return parseOpenCodeSession(data); + case "copilot": return parseCopilotSession(data); + case "claude": return parseClaudeSession(data); + case "codex": return parseCodexSession(data); + default: return parseOpenCodeSession(data); + } +} + +function extractMemories(session: ACPSession): ACPMemory[] { + const memories: ACPMemory[] = []; + for (const msg of session.messages) { + if (msg.role !== "assistant" || msg.content.length < 50) continue; + const content = msg.content; + let type: ACPMemory["type"] = "context"; + if (content.includes("```") || content.includes("function ") || content.includes("class ")) type = "code"; + else if (content.includes("decided") || content.includes("should") || content.includes("recommend")) type = "decision"; + else if (content.includes("insight") || content.includes("learned") || content.includes("discovered")) type = "insight"; + memories.push({ + id: generateId(), + source: session.source, + sessionId: session.id, + content: content.slice(0, 2000), + type, + timestamp: msg.timestamp || session.timestamp, + }); + } + return memories; +} + +export async function importSessionFile(rootDir: string, filePath: string): Promise<{ sessions: number; memories: number }> { + const content = await readFile(filePath, "utf-8"); + let data: Record; + try { + data = JSON.parse(content); + } catch { + return { sessions: 0, memories: 0 }; + } + + const source = detectSource(filePath, content); + const items = Array.isArray(data) ? data : [data]; + const sessions: ACPSession[] = []; + const memories: ACPMemory[] = []; + + for (const item of items) { + const session = parseSession(source, item as Record); + if (session) { + sessions.push(session); + memories.push(...extractMemories(session)); + } + } + + if (sessions.length === 0) return { sessions: 0, memories: 0 }; + + const extDir = join(rootDir, ".contextplus", EXTERNAL_DIR); + await mkdir(extDir, { recursive: true }); + + const sessionsPath = join(extDir, SESSIONS_FILE); + const memoriesPath = join(extDir, MEMORIES_FILE); + + let existing: ACPSession[] = []; + let existingMem: ACPMemory[] = []; + if (existsSync(sessionsPath)) { + existing = JSON.parse(await readFile(sessionsPath, "utf-8")); + } + if (existsSync(memoriesPath)) { + existingMem = JSON.parse(await readFile(memoriesPath, "utf-8")); + } + + const existingIds = new Set(existing.map((s) => s.id)); + const newSessions = sessions.filter((s) => !existingIds.has(s.id)); + const newMemIds = new Set(existingMem.map((m) => m.id)); + const newMemories = memories.filter((m) => !newMemIds.has(m.id)); + + await writeFile(sessionsPath, JSON.stringify([...existing, ...newSessions], null, 2)); + await writeFile(memoriesPath, JSON.stringify([...existingMem, ...newMemories], null, 2)); + + return { sessions: newSessions.length, memories: newMemories.length }; +} + +export async function listSessions(rootDir: string): Promise { + const sessionsPath = join(rootDir, ".contextplus", EXTERNAL_DIR, SESSIONS_FILE); + if (!existsSync(sessionsPath)) return []; + return JSON.parse(await readFile(sessionsPath, "utf-8")); +} + +export async function listMemories(rootDir: string): Promise { + const memoriesPath = join(rootDir, ".contextplus", EXTERNAL_DIR, MEMORIES_FILE); + if (!existsSync(memoriesPath)) return []; + return JSON.parse(await readFile(memoriesPath, "utf-8")); +} + +export async function searchACPMemories(rootDir: string, query: string): Promise { + const memories = await listMemories(rootDir); + const queryLower = query.toLowerCase(); + const terms = queryLower.split(/\s+/).filter((t) => t.length > 2); + return memories + .filter((m) => { + const contentLower = m.content.toLowerCase(); + return terms.some((t) => contentLower.includes(t)); + }) + .sort((a, b) => { + const aScore = terms.filter((t) => a.content.toLowerCase().includes(t)).length; + const bScore = terms.filter((t) => b.content.toLowerCase().includes(t)).length; + return bScore - aScore; + }) + .slice(0, 20); +} + +export async function discoverSessionFiles(searchDir: string): Promise { + const patterns = ["conversations.json", "chat*.json", "*session*.json", "history.json"]; + const found: string[] = []; + + async function scan(dir: string, depth: number): Promise { + if (depth > 3) return; + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + await scan(fullPath, depth + 1); + } else if (entry.name.endsWith(".json")) { + const name = entry.name.toLowerCase(); + if (patterns.some((p) => name.includes(p.replace("*", "")) || name.match(new RegExp(p.replace("*", ".*"))))) { + found.push(fullPath); + } + } + } + } catch { } + } + + await scan(searchDir, 0); + return found; +} diff --git a/src/core/embedding-tracker.ts b/src/core/embedding-tracker.ts index 6da91d5..1faf34f 100644 --- a/src/core/embedding-tracker.ts +++ b/src/core/embedding-tracker.ts @@ -1,5 +1,5 @@ -// File-system embedding tracker for realtime cache refresh on source changes -// FEATURE: Incremental embedding updates for changed files and identifiers +// Background file watcher for incremental embedding updates on source changes +// Init-once pattern: starts on first tool use (lazy) or server boot (eager) import { watch, type FSWatcher } from "fs"; import { refreshFileSearchEmbeddings } from "../tools/semantic-search.js"; @@ -22,110 +22,78 @@ export interface EmbeddingTrackerControllerOptions extends EmbeddingTrackerOptio 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 = 1500; -const MAX_PENDING_FILES = 50; - -const IGNORE_PREFIXES = [ - ".mcp_data/", - ".git/", - "node_modules/", - "build/", - "dist/", - "landing/.next/", -]; - -function normalizeRelativePath(path: string): string { - return path.replace(/\\/g, "/").replace(/^\/+/, ""); -} - -function shouldTrack(path: string): boolean { - if (!path) return false; - return !IGNORE_PREFIXES.some((prefix) => path.startsWith(prefix)); -} +const FILES_PER_TICK = { min: 5, max: 10, default: 8 }; +const DEBOUNCE_MS = { min: 500, default: 1500 }; +const MAX_PENDING = 50; +const QUIET_REFRESH_DELAY = 100; -function clampFilesPerTick(value: number | undefined): number { - if (!Number.isFinite(value)) return DEFAULT_FILES_PER_TICK; - return Math.max(MIN_FILES_PER_TICK, Math.min(MAX_FILES_PER_TICK, Math.floor(value ?? DEFAULT_FILES_PER_TICK))); -} +const IGNORE_PREFIXES = [".mcp_data/", ".contextplus/", ".git/", "node_modules/", "build/", "dist/", "landing/.next/"]; -function clampDebounceMs(value: number | undefined): number { - if (!Number.isFinite(value)) return DEFAULT_DEBOUNCE_MS; - return Math.max(500, Math.floor(value ?? DEFAULT_DEBOUNCE_MS)); -} +const normalize = (path: string): string => path.replace(/\\/g, "/").replace(/^\/+/, ""); +const shouldTrack = (path: string): boolean => path ? !IGNORE_PREFIXES.some((p) => path.startsWith(p)) : false; +const clampInt = (v: number | undefined, min: number, max: number, def: number): number => + Number.isFinite(v) ? Math.max(min, Math.min(max, Math.floor(v!))) : def; 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"; + const v = value.trim().toLowerCase(); + if (["false", "0", "no", "off", "disabled", "none"].includes(v)) return "off"; + if (["eager", "startup", "boot"].includes(v)) return "eager"; return "lazy"; } export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => void { - const pendingFiles = new Set(); - const debounceMs = clampDebounceMs(options.debounceMs); - const maxFilesPerTick = clampFilesPerTick(options.maxFilesPerTick); + const pending = new Set(); + const debounceMs = clampInt(options.debounceMs, DEBOUNCE_MS.min, 10000, DEBOUNCE_MS.default); + const filesPerTick = clampInt(options.maxFilesPerTick, FILES_PER_TICK.min, FILES_PER_TICK.max, FILES_PER_TICK.default); let watcher: FSWatcher | null = null; let timer: NodeJS.Timeout | null = null; - let isProcessing = false; + let processing = false; let closed = false; + let errorCount = 0; - const schedule = (delay: number = debounceMs): void => { + const schedule = (delay = debounceMs): void => { if (timer) clearTimeout(timer); - timer = setTimeout(() => { - void flushPending(); - }, delay); + timer = setTimeout(() => void flush(), delay); timer.unref(); }; - const flushPending = async (): Promise => { - if (closed || isProcessing) return; - if (pendingFiles.size === 0) return; + const flush = async (): Promise => { + if (closed || processing || pending.size === 0) return; + processing = true; - isProcessing = true; - const batch = Array.from(pendingFiles).slice(0, maxFilesPerTick); - for (const file of batch) pendingFiles.delete(file); + const batch = Array.from(pending).slice(0, filesPerTick); + for (const f of batch) pending.delete(f); try { - const [fileEmbeds, identifierEmbeds] = await Promise.all([ + await Promise.all([ refreshFileSearchEmbeddings({ rootDir: options.rootDir, relativePaths: batch }), refreshIdentifierEmbeddings({ rootDir: options.rootDir, relativePaths: batch }), ]); - if (fileEmbeds > 0 || identifierEmbeds > 0) { - console.error( - `Embedding tracker refreshed ${batch.length} file(s) | file-vectors=${fileEmbeds}, identifier-vectors=${identifierEmbeds}`, - ); - } - } catch (error) { - console.error("Embedding tracker refresh failed:", error); + errorCount = 0; + } catch { + if (++errorCount <= 3) console.error(`Embedding refresh failed (attempt ${errorCount}/3)`); } finally { - isProcessing = false; - if (pendingFiles.size > 0) schedule(100); + processing = false; + if (pending.size > 0) schedule(QUIET_REFRESH_DELAY); } }; try { - watcher = watch(options.rootDir, { recursive: true }, (_eventType, fileName) => { + watcher = watch(options.rootDir, { recursive: true }, (_, fileName) => { if (closed || !fileName) return; - const relativePath = normalizeRelativePath(String(fileName)); - if (!shouldTrack(relativePath)) return; - if (pendingFiles.size >= MAX_PENDING_FILES) return; - pendingFiles.add(relativePath); - schedule(); + const rel = normalize(String(fileName)); + if (shouldTrack(rel) && pending.size < MAX_PENDING) { + pending.add(rel); + schedule(); + } }); - } catch (error) { - console.error("Embedding tracker disabled: file watching is unavailable.", error); + watcher.on("error", () => { }); + } catch { return () => { }; } - watcher.on("error", (error) => { - console.error("Embedding tracker watcher error:", error); - }); - return () => { closed = true; if (timer) clearTimeout(timer); @@ -135,15 +103,15 @@ export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => v } export function createEmbeddingTrackerController(options: EmbeddingTrackerControllerOptions): EmbeddingTrackerController { - const { mode: rawMode, starter = startEmbeddingTracker, ...trackerOptions } = options; + const { mode: rawMode, starter = startEmbeddingTracker, ...trackerOpts } = options; const mode = parseEmbeddingTrackerMode(rawMode); let running = false; - let stopTracker = () => { }; + let stopFn = () => { }; const ensureStarted = (): void => { if (running || mode === "off") return; - stopTracker = starter(trackerOptions); + stopFn = starter(trackerOpts); running = true; }; @@ -154,9 +122,9 @@ export function createEmbeddingTrackerController(options: EmbeddingTrackerContro stop: () => { if (!running) return; running = false; - const stop = stopTracker; - stopTracker = () => { }; - stop(); + const s = stopFn; + stopFn = () => { }; + s(); }, isRunning: () => running, }; diff --git a/src/core/embeddings.ts b/src/core/embeddings.ts index 5942bd0..2fb3694 100644 --- a/src/core/embeddings.ts +++ b/src/core/embeddings.ts @@ -2,8 +2,9 @@ // Supports Ollama (local) and OpenAI-compatible APIs (Gemini, OpenAI, etc.) // Indexes file headers and symbols, caches embeddings to disk for speed -import { readFile, writeFile, mkdir } from "fs/promises"; +import { mkdir } from "fs/promises"; import { join } from "path"; +import { deleteNamespace, deleteVectors, listVectorKeys, listVectors, upsertVectors } from "./vector-db.js"; const EMBED_TIMEOUT_MS = 60_000; let embedAbortController = new AbortController(); @@ -74,6 +75,12 @@ export interface EmbeddingCache { [path: string]: { hash: string; vector: number[] }; } +interface EmbeddingRecord { + key: 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"; @@ -85,6 +92,9 @@ const CACHE_FILE = `embeddings-cache-${EMBED_PROVIDER}-${ACTIVE_EMBED_MODEL.repl const MIN_EMBED_BATCH_SIZE = 5; const MAX_EMBED_BATCH_SIZE = 10; const DEFAULT_EMBED_BATCH_SIZE = 8; +const MIN_EMBED_BATCH_CONCURRENCY = 1; +const MAX_EMBED_BATCH_CONCURRENCY = 8; +const DEFAULT_EMBED_BATCH_CONCURRENCY = 1; const MIN_EMBED_INPUT_CHARS = 1; const SINGLE_INPUT_SHRINK_FACTOR = 0.75; const MAX_SINGLE_INPUT_RETRIES = 40; @@ -180,6 +190,11 @@ export function getEmbeddingBatchSize(): number { return Math.min(MAX_EMBED_BATCH_SIZE, Math.max(MIN_EMBED_BATCH_SIZE, requested)); } +export function getEmbeddingBatchConcurrency(): number { + const requested = toIntegerOr(process.env.CONTEXTPLUS_EMBED_BATCH_CONCURRENCY, DEFAULT_EMBED_BATCH_CONCURRENCY); + return Math.min(MAX_EMBED_BATCH_CONCURRENCY, Math.max(MIN_EMBED_BATCH_CONCURRENCY, 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)); @@ -286,13 +301,25 @@ export async function fetchEmbedding(input: string | string[]): Promise(batches.length); + const workerCount = Math.min(getEmbeddingBatchConcurrency(), batches.length); + let nextBatchIndex = 0; + + await Promise.all(Array.from({ length: workerCount }, async () => { + while (true) { + const batchIndex = nextBatchIndex++; + if (batchIndex >= batches.length) return; + batchEmbeddings[batchIndex] = await embedBatchAdaptive(batches[batchIndex]); + } + })); + + const flattenedEmbeddings = batchEmbeddings.flat(); + const embeddings: number[][] = []; let offset = 0; for (const chunks of chunkedInputs) { @@ -414,17 +441,41 @@ export async function ensureMcpDataDir(rootDir: string): Promise { await mkdir(join(rootDir, CACHE_DIR), { recursive: true }); } +function cacheNamespace(fileName: string): string { + return `cache:${fileName}`; +} + +function toEmbeddingRecordMap(records: EmbeddingRecord[]): EmbeddingCache { + const cache: EmbeddingCache = {}; + for (const record of records) cache[record.key] = { hash: record.hash, vector: record.vector }; + return cache; +} + export async function loadEmbeddingCache(rootDir: string, fileName: string): Promise { - try { - return JSON.parse(await readFile(join(rootDir, CACHE_DIR, fileName), "utf-8")); - } catch { - return {}; - } + return toEmbeddingRecordMap(await listVectors(rootDir, cacheNamespace(fileName))); } export async function saveEmbeddingCache(rootDir: string, cache: EmbeddingCache, fileName: string): Promise { - await ensureMcpDataDir(rootDir); - await writeFile(join(rootDir, CACHE_DIR, fileName), JSON.stringify(cache)); + const namespace = cacheNamespace(fileName); + const entries = Object.entries(cache); + const nextKeys = new Set(entries.map(([key]) => key)); + const staleKeys = (await listVectorKeys(rootDir, namespace)).filter((key) => !nextKeys.has(key)); + + if (staleKeys.length > 0) { + await deleteVectors(rootDir, namespace, staleKeys); + } + + if (entries.length > 0) { + await upsertVectors( + rootDir, + namespace, + entries.map(([key, value]) => ({ key, hash: value.hash, vector: value.vector })), + ); + } +} + +export async function clearEmbeddingCache(rootDir: string, fileName: string): Promise { + await deleteNamespace(rootDir, cacheNamespace(fileName)); } function formatLineRange(line: number, endLine?: number): string { diff --git a/src/core/error-handler.ts b/src/core/error-handler.ts new file mode 100644 index 0000000..f1813e8 --- /dev/null +++ b/src/core/error-handler.ts @@ -0,0 +1,93 @@ +// Tool error handling with actionable suggestions for recovery +// Maps common error patterns to user-friendly suggestions + +export interface ToolError { + tool: string; + error: string; + suggestion: string; + alternatives?: string[]; +} + +const ERROR_PATTERNS: Array<{ pattern: RegExp; suggestion: (tool: string, match: RegExpMatchArray) => string; alternatives?: string[] }> = [ + { + pattern: /ENOENT|no such file|file not found/i, + suggestion: (tool) => `File or directory not found. Try 'tree' to see available files, or 'init' to create .contextplus structure.`, + alternatives: ["tree", "init"], + }, + { + pattern: /EACCES|permission denied/i, + suggestion: () => `Permission denied. Check file permissions or run with elevated privileges.`, + }, + { + pattern: /embedding|ollama|connection refused|ECONNREFUSED/i, + suggestion: () => `Embedding service unavailable. Ensure Ollama is running ('ollama serve') or use CONTEXTPLUS_EMBED_PROVIDER=openai with API key.`, + alternatives: ["search with mode='keyword'"], + }, + { + pattern: /timeout|ETIMEDOUT/i, + suggestion: (tool) => `Operation timed out. Try again or use a smaller scope. For search, try mode='keyword' to skip embeddings.`, + alternatives: ["search with mode='keyword'"], + }, + { + pattern: /no memory|graph.*empty|no nodes/i, + suggestion: () => `Memory graph is empty. Use 'create_memory' to add nodes or 'bulk_memory' to add multiple at once.`, + alternatives: ["create_memory", "bulk_memory"], + }, + { + pattern: /no.*hub|hub.*not found/i, + suggestion: () => `No hubs found. Create hubs in .contextplus/hubs/ or use 'init' to set up the project structure.`, + alternatives: ["init", "create hub via CLI"], + }, + { + pattern: /no restore point|checkpoint.*not found/i, + suggestion: () => `No restore points found. Use 'checkpoint' to create restore points before making changes.`, + alternatives: ["checkpoint"], + }, + { + pattern: /tree-sitter|unsupported.*language|grammar/i, + suggestion: () => `Language not supported by tree-sitter. File will be treated as plain text. Supported: ts, js, py, rs, go, java, c, cpp, etc.`, + }, + { + pattern: /json.*parse|invalid json|syntax error/i, + suggestion: () => `Invalid JSON format. Check the file contents for syntax errors.`, + }, + { + pattern: /sqlite|database|db error/i, + suggestion: () => `Database error. Try deleting .contextplus/embeddings/vectors.db and running 'init' again.`, + alternatives: ["init"], + }, +]; + +export function formatToolError(tool: string, error: unknown): ToolError { + const message = error instanceof Error ? error.message : String(error); + + for (const { pattern, suggestion, alternatives } of ERROR_PATTERNS) { + const match = message.match(pattern); + if (match) { + return { tool, error: message, suggestion: suggestion(tool, match), alternatives }; + } + } + + return { + tool, + error: message, + suggestion: `Unexpected error in '${tool}'. Check inputs and try again. Use 'tree' to verify project structure.`, + alternatives: ["tree"], + }; +} + +export function formatErrorResponse(toolError: ToolError): string { + let response = `Error in ${toolError.tool}: ${toolError.error}\n\nSuggestion: ${toolError.suggestion}`; + if (toolError.alternatives?.length) { + response += `\n\nAlternatives: ${toolError.alternatives.join(", ")}`; + } + return response; +} + +export async function withErrorHandling(tool: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + return formatErrorResponse(formatToolError(tool, error)); + } +} diff --git a/src/core/hub.ts b/src/core/hub.ts index 7eab849..402852a 100644 --- a/src/core/hub.ts +++ b/src/core/hub.ts @@ -76,7 +76,12 @@ export async function discoverHubs(rootDir: string): Promise { const skip = new Set(["node_modules", ".git", "build", "dist", ".mcp_data"]); async function walk(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }); + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } for (const entry of entries) { if (skip.has(entry.name)) continue; const full = join(dir, entry.name); @@ -84,10 +89,13 @@ export async function discoverHubs(rootDir: string): Promise { if (entry.isDirectory()) { await walk(full); } else if (entry.name.endsWith(".md")) { - const content = await readFile(full, "utf-8"); - if (WIKILINK_RE.test(content)) { - hubs.push(relative(rootDir, full).replace(/\\/g, "/")); - WIKILINK_RE.lastIndex = 0; + try { + const content = await readFile(full, "utf-8"); + if (WIKILINK_RE.test(content)) { + hubs.push(relative(rootDir, full).replace(/\\/g, "/")); + WIKILINK_RE.lastIndex = 0; + } + } catch { } } } diff --git a/src/core/memory-graph.ts b/src/core/memory-graph.ts index 386b29b..cd973cc 100644 --- a/src/core/memory-graph.ts +++ b/src/core/memory-graph.ts @@ -1,12 +1,14 @@ -// In-memory property graph with JSON persistence for linking memory nodes -// FEATURE: Memory Graph — traversal, decay scoring, auto-similarity edges +// Graph memory persistence with markdown nodes SQLite vectors and relations +// FEATURE: Memory graph storage traversal ranking and automatic stale cleanup -import { readFile, writeFile } from "fs/promises"; -import { join } from "path"; -import { fetchEmbedding, ensureMcpDataDir } from "./embeddings.js"; +import { mkdir, readFile, readdir, writeFile, rm } from "fs/promises"; +import { basename, join } from "path"; +import { fetchEmbedding } from "./embeddings.js"; +import { deleteVector, getVector, listVectors, upsertVector } from "./vector-db.js"; export type NodeType = "concept" | "file" | "symbol" | "note"; export type RelationType = "relates_to" | "depends_on" | "implements" | "references" | "similar_to" | "contains"; +export type MemorySearchMode = "semantic" | "keyword" | "both"; export interface MemoryNode { id: string; @@ -30,11 +32,6 @@ export interface MemoryEdge { metadata: Record; } -interface GraphStore { - nodes: Record; - edges: Record; -} - export interface TraversalResult { node: MemoryNode; depth: number; @@ -49,24 +46,96 @@ export interface GraphSearchResult { totalEdges: number; } -const GRAPH_FILE = "memory-graph.json"; -const CACHE_DIR = ".mcp_data"; +interface MemoryNodeRecord { + id: string; + type: NodeType; + label: string; + contentPath: string; + createdAt: number; + lastAccessed: number; + accessCount: number; + metadata: Record; +} + +interface MemoryEdgeRecord { + id: string; + source: string; + target: string; + relation: RelationType; + weight: number; + createdAt: number; + metadata: Record; +} + +interface MemoryGraphStore { + nodes: Record; + edges: Record; +} + +export interface MemorySearchOptions { + mode?: MemorySearchMode; + edgeFilter?: RelationType[]; +} + +interface DeleteMemoryInput { + nodeId?: string; + edgeId?: string; + sourceId?: string; + targetId?: string; + relation?: RelationType; +} + +const CONTEXTPLUS_DIR = ".contextplus"; +const MEMORIES_DIR = "memories"; +const NODES_DIR = "nodes"; +const GRAPH_FILE = "graph.json"; +const MEMORY_VECTOR_NAMESPACE = "memory"; const DECAY_LAMBDA = 0.05; const SIMILARITY_THRESHOLD = 0.72; const STALE_THRESHOLD = 0.15; +const MAX_EXISTING_AUTO_LINK = 200; -let graphCache = new Map(); -let savePending = new Map(); -let saveTimeout = new Map>(); +const storeCache = new Map(); +const nodeCache = new Map>(); +const savePending = new Set(); +const saveTimers = new Map>(); function generateId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +function toSafeSlug(input: string): string { + const base = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60); + return base || "node"; +} + +function normalizeRelativePath(path: string): string { + return path.replace(/\\/g, "/"); +} + +function splitTerms(text: string): string[] { + return text + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9_]+/) + .filter((token) => token.length > 1); +} + +function keywordCoverage(queryTerms: Set, content: string): number { + if (queryTerms.size === 0) return 0; + const terms = new Set(splitTerms(content)); + let matched = 0; + for (const term of queryTerms) if (terms.has(term)) matched += 1; + return matched / queryTerms.size; +} + function cosine(a: number[], b: number[]): number { const len = Math.min(a.length, b.length); if (len === 0) return 0; - let dot = 0, normA = 0, normB = 0; + let dot = 0; + let normA = 0; + let normB = 0; for (let i = 0; i < len; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; @@ -76,300 +145,525 @@ function cosine(a: number[], b: number[]): number { return denom === 0 ? 0 : dot / denom; } -function decayWeight(edge: MemoryEdge): number { - const daysSinceCreation = (Date.now() - edge.createdAt) / 86_400_000; - return edge.weight * Math.exp(-DECAY_LAMBDA * daysSinceCreation); +function hashContent(text: string): string { + let hash = 0; + for (let i = 0; i < text.length; i++) hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0; + return hash.toString(36); +} + +function decayWeight(edge: MemoryEdgeRecord): number { + return edge.weight * Math.exp(-DECAY_LAMBDA * ((Date.now() - edge.createdAt) / 86_400_000)); +} + +function graphPath(rootDir: string): string { + return join(rootDir, CONTEXTPLUS_DIR, MEMORIES_DIR, GRAPH_FILE); } -async function loadGraph(rootDir: string): Promise { - if (graphCache.has(rootDir)) return graphCache.get(rootDir)!; +function memoryNodesDir(rootDir: string): string { + return join(rootDir, CONTEXTPLUS_DIR, MEMORIES_DIR, NODES_DIR); +} + +function nodePath(rootDir: string, node: Pick): string { + return join(memoryNodesDir(rootDir), `${node.id}-${toSafeSlug(node.label)}.md`); +} + +function nodeRelativePath(rootDir: string, node: Pick): string { + return normalizeRelativePath(join(CONTEXTPLUS_DIR, MEMORIES_DIR, NODES_DIR, basename(nodePath(rootDir, node)))); +} + +function nodeVectorKey(nodeId: string): string { + return `node:${nodeId}`; +} + +function getEdgesForNode(store: MemoryGraphStore, nodeId: string): MemoryEdgeRecord[] { + return Object.values(store.edges).filter((edge) => edge.source === nodeId || edge.target === nodeId); +} + +function getNeighborId(edge: MemoryEdgeRecord, nodeId: string): string { + return edge.source === nodeId ? edge.target : edge.source; +} + +function relationAllowed(relation: RelationType, edgeFilter?: RelationType[]): boolean { + return !edgeFilter || edgeFilter.includes(relation); +} + +function extractNodeBody(content: string): string { + const marker = "\n\n## Content\n"; + const index = content.indexOf(marker); + return index < 0 ? content : content.slice(index + marker.length).trim(); +} + +function parseNodeHeader(content: string): { label: string; type: NodeType; metadata: Record } { + const lines = content.split("\n"); + const label = lines[0]?.startsWith("# ") ? lines[0].slice(2).trim() : "Untitled"; + let type: NodeType = "note"; + const metadata: Record = {}; + let inFrontmatter = false; + for (const line of lines) { + if (line.trim() === "---") { + inFrontmatter = !inFrontmatter; + continue; + } + if (!inFrontmatter) continue; + const separator = line.indexOf(":"); + if (separator <= 0) continue; + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + if (key === "type" && ["concept", "file", "symbol", "note"].includes(value)) type = value as NodeType; + else if (key && value) metadata[key] = value; + } + return { label, type, metadata }; +} + +function formatNodeMarkdown(node: MemoryNodeRecord, content: string): string { + const metadataLines = Object.entries(node.metadata).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}: ${value}`); + return [ + `# ${node.label}`, + "---", + `id: ${node.id}`, + `type: ${node.type}`, + `created_at: ${node.createdAt}`, + `updated_at: ${Date.now()}`, + `last_accessed: ${node.lastAccessed}`, + `access_count: ${node.accessCount}`, + ...metadataLines, + "---", + "", + "## Content", + content.trimEnd(), + "", + ].join("\n"); +} + +async function ensureMemoryDirs(rootDir: string): Promise { + await mkdir(memoryNodesDir(rootDir), { recursive: true }); +} + +async function loadStore(rootDir: string): Promise { + const cached = storeCache.get(rootDir); + if (cached) return cached; + await ensureMemoryDirs(rootDir); try { - const raw = JSON.parse(await readFile(join(rootDir, CACHE_DIR, GRAPH_FILE), "utf-8")); - const store: GraphStore = { - nodes: raw?.nodes && typeof raw.nodes === "object" ? raw.nodes : {}, - edges: raw?.edges && typeof raw.edges === "object" ? raw.edges : {}, + const raw = JSON.parse(await readFile(graphPath(rootDir), "utf-8")) as Partial; + const store = { + nodes: raw.nodes && typeof raw.nodes === "object" ? raw.nodes as Record : {}, + edges: raw.edges && typeof raw.edges === "object" ? raw.edges as Record : {}, }; - graphCache.set(rootDir, store); + storeCache.set(rootDir, store); + return store; } catch { - graphCache.set(rootDir, { nodes: {}, edges: {} }); + const store = { nodes: {}, edges: {} }; + storeCache.set(rootDir, store); + return store; } - return graphCache.get(rootDir)!; } -async function persistGraph(rootDir: string): Promise { - const store = graphCache.get(rootDir); +async function persistStore(rootDir: string): Promise { + const store = storeCache.get(rootDir); if (!store) return; - await ensureMcpDataDir(rootDir); - await writeFile(join(rootDir, CACHE_DIR, GRAPH_FILE), JSON.stringify(store, null, 2)); + await ensureMemoryDirs(rootDir); + await writeFile(graphPath(rootDir), JSON.stringify(store, null, 2), "utf-8"); } -function scheduleSave(rootDir: string): void { - const existing = saveTimeout.get(rootDir); +function scheduleStoreSave(rootDir: string): void { + const existing = saveTimers.get(rootDir); if (existing) clearTimeout(existing); - savePending.set(rootDir, true); - saveTimeout.set(rootDir, setTimeout(() => { - if (savePending.get(rootDir)) { - persistGraph(rootDir).catch(() => {}).finally(() => savePending.set(rootDir, false)); - } + savePending.add(rootDir); + saveTimers.set(rootDir, setTimeout(() => { + if (!savePending.has(rootDir)) return; + void persistStore(rootDir).finally(() => savePending.delete(rootDir)); }, 500)); } -function getEdgesForNode(graph: GraphStore, nodeId: string): MemoryEdge[] { - return Object.values(graph.edges).filter(e => e.source === nodeId || e.target === nodeId); +async function loadNodeContent(rootDir: string, node: MemoryNodeRecord): Promise { + const cache = nodeCache.get(rootDir) ?? new Map(); + nodeCache.set(rootDir, cache); + if (cache.has(node.id)) return cache.get(node.id)!; + try { + const content = extractNodeBody(await readFile(join(rootDir, node.contentPath), "utf-8")); + cache.set(node.id, content); + return content; + } catch { + return ""; + } +} + +async function writeNodeContent(rootDir: string, node: MemoryNodeRecord, content: string): Promise { + await ensureMemoryDirs(rootDir); + const path = nodePath(rootDir, node); + const previousPath = node.contentPath ? join(rootDir, node.contentPath) : ""; + node.contentPath = nodeRelativePath(rootDir, node); + await writeFile(path, formatNodeMarkdown(node, content), "utf-8"); + if (previousPath && normalizeRelativePath(previousPath) !== normalizeRelativePath(path)) await rm(previousPath, { force: true }); + const cache = nodeCache.get(rootDir) ?? new Map(); + cache.set(node.id, content); + nodeCache.set(rootDir, cache); +} + +async function getNodeVector(rootDir: string, nodeId: string): Promise { + return (await getVector(rootDir, MEMORY_VECTOR_NAMESPACE, nodeVectorKey(nodeId)))?.vector ?? []; +} + +async function hydrateNode(rootDir: string, node: MemoryNodeRecord): Promise { + return { + id: node.id, + type: node.type, + label: node.label, + content: await loadNodeContent(rootDir, node), + embedding: await getNodeVector(rootDir, node.id), + createdAt: node.createdAt, + lastAccessed: node.lastAccessed, + accessCount: node.accessCount, + metadata: { ...node.metadata }, + }; +} + +async function upsertNodeEmbedding(rootDir: string, node: Pick, content: string): Promise { + const text = `${node.label} ${content}`; + const [embedding] = await fetchEmbedding(text); + await upsertVector(rootDir, MEMORY_VECTOR_NAMESPACE, nodeVectorKey(node.id), hashContent(text), embedding, { type: node.type, label: node.label }); + return embedding; +} + +async function removeStaleContentFiles(rootDir: string, validPaths: Set): Promise { + await ensureMemoryDirs(rootDir); + const entries = await readdir(memoryNodesDir(rootDir), { withFileTypes: true }).catch(() => []); + await Promise.all(entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .map(async (entry) => { + const relativePath = normalizeRelativePath(join(CONTEXTPLUS_DIR, MEMORIES_DIR, NODES_DIR, entry.name)); + if (!validPaths.has(relativePath)) await rm(join(memoryNodesDir(rootDir), entry.name), { force: true }); + })); +} + +async function autoPrune(rootDir: string): Promise { + await pruneStaleLinks(rootDir, STALE_THRESHOLD); } -function getNeighborId(edge: MemoryEdge, fromId: string): string { - return edge.source === fromId ? edge.target : edge.source; +async function removeNodeById(rootDir: string, store: MemoryGraphStore, nodeId: string): Promise<{ removedNodes: number; removedEdges: number }> { + const node = store.nodes[nodeId]; + if (!node) return { removedNodes: 0, removedEdges: 0 }; + const removedEdgeIds = Object.values(store.edges) + .filter((edge) => edge.source === nodeId || edge.target === nodeId) + .map((edge) => edge.id); + for (const edgeId of removedEdgeIds) delete store.edges[edgeId]; + delete store.nodes[nodeId]; + await deleteVector(rootDir, MEMORY_VECTOR_NAMESPACE, nodeVectorKey(nodeId)); + if (node.contentPath) await rm(join(rootDir, node.contentPath), { force: true }); + return { removedNodes: 1, removedEdges: removedEdgeIds.length }; } -export async function upsertNode(rootDir: string, type: NodeType, label: string, content: string, metadata?: Record): Promise { - const graph = await loadGraph(rootDir); - const existing = Object.values(graph.nodes).find(n => n.label === label && n.type === type); +async function collectNeighbors( + rootDir: string, + store: MemoryGraphStore, + startNode: MemoryNode, + queryEmbedding: number[], + maxDepth: number, + visited: Set, + edgeFilter?: RelationType[], +): Promise { + const results: TraversalResult[] = []; + const walk = async (nodeId: string, depth: number, path: string[]): Promise => { + if (depth > maxDepth) return; + for (const edge of getEdgesForNode(store, nodeId)) { + if (!relationAllowed(edge.relation, edgeFilter)) continue; + const nextId = getNeighborId(edge, nodeId); + if (visited.has(nextId)) continue; + const record = store.nodes[nextId]; + if (!record) continue; + visited.add(nextId); + record.lastAccessed = Date.now(); + const node = await hydrateNode(rootDir, record); + const relevance = Math.max(cosine(queryEmbedding, node.embedding), 0) * 0.6 + (decayWeight(edge) / Math.max(edge.weight, 0.01)) * 0.4; + const pathRelations = [...path, `--[${edge.relation}]-->`, node.label]; + results.push({ node, depth, pathRelations, relevanceScore: Math.round(relevance * 1000) / 10 }); + await walk(nextId, depth + 1, pathRelations); + } + }; + await walk(startNode.id, 1, [startNode.label]); + return results; +} +async function scoreNode( + rootDir: string, + node: MemoryNodeRecord, + queryEmbedding: number[], + queryTerms: Set, + mode: MemorySearchMode, +): Promise<{ node: MemoryNode; score: number }> { + const hydrated = await hydrateNode(rootDir, node); + const semanticScore = mode === "keyword" ? 0 : Math.max(cosine(queryEmbedding, hydrated.embedding), 0); + const keywordScore = mode === "semantic" ? 0 : keywordCoverage(queryTerms, `${hydrated.label} ${hydrated.content} ${JSON.stringify(hydrated.metadata)}`); + return { + node: hydrated, + score: mode === "semantic" ? semanticScore : mode === "keyword" ? keywordScore : semanticScore * 0.7 + keywordScore * 0.3, + }; +} + +export async function pruneStaleLinks(rootDir: string, threshold: number = STALE_THRESHOLD): Promise<{ removed: number; remaining: number }> { + const store = await loadStore(rootDir); + const staleEdgeIds = Object.entries(store.edges).filter(([, edge]) => decayWeight(edge) < threshold).map(([edgeId]) => edgeId); + for (const edgeId of staleEdgeIds) delete store.edges[edgeId]; + const orphanNodeIds = Object.keys(store.nodes) + .filter((nodeId) => getEdgesForNode(store, nodeId).length === 0) + .filter((nodeId) => store.nodes[nodeId].accessCount <= 1) + .filter((nodeId) => Date.now() - store.nodes[nodeId].lastAccessed > 7 * 86_400_000); + for (const nodeId of orphanNodeIds) await removeNodeById(rootDir, store, nodeId); + if (staleEdgeIds.length > 0 || orphanNodeIds.length > 0) scheduleStoreSave(rootDir); + return { removed: staleEdgeIds.length + orphanNodeIds.length, remaining: Object.keys(store.edges).length }; +} + +export async function upsertNode(rootDir: string, type: NodeType, label: string, content: string, metadata: Record = {}): Promise { + await autoPrune(rootDir); + const store = await loadStore(rootDir); + const existing = Object.values(store.nodes).find((node) => node.type === type && node.label === label); + const now = Date.now(); if (existing) { - existing.content = content; - existing.lastAccessed = Date.now(); - existing.accessCount++; - if (metadata) Object.assign(existing.metadata, metadata); - existing.embedding = (await fetchEmbedding(`${label} ${content}`))[0]; - scheduleSave(rootDir); - return existing; + existing.lastAccessed = now; + existing.accessCount += 1; + existing.metadata = { ...existing.metadata, ...metadata }; + const embedding = await upsertNodeEmbedding(rootDir, existing, content); + await writeNodeContent(rootDir, existing, content); + scheduleStoreSave(rootDir); + return { ...await hydrateNode(rootDir, existing), embedding }; } - - const node: MemoryNode = { + const node: MemoryNodeRecord = { id: generateId("mn"), type, label, - content, - embedding: (await fetchEmbedding(`${label} ${content}`))[0], - createdAt: Date.now(), - lastAccessed: Date.now(), + contentPath: "", + createdAt: now, + lastAccessed: now, accessCount: 1, - metadata: metadata ?? {}, + metadata: { ...metadata }, }; - graph.nodes[node.id] = node; - scheduleSave(rootDir); - return node; + store.nodes[node.id] = node; + const embedding = await upsertNodeEmbedding(rootDir, node, content); + await writeNodeContent(rootDir, node, content); + scheduleStoreSave(rootDir); + return { ...await hydrateNode(rootDir, node), embedding }; } -export async function createRelation(rootDir: string, sourceId: string, targetId: string, relation: RelationType, weight?: number, metadata?: Record): Promise { - const graph = await loadGraph(rootDir); - if (!graph.nodes[sourceId] || !graph.nodes[targetId]) return null; +export async function updateMemoryContent(rootDir: string, nodeId: string, content: string, metadata: Record = {}): Promise { + await autoPrune(rootDir); + const store = await loadStore(rootDir); + const node = store.nodes[nodeId]; + if (!node) return null; + node.lastAccessed = Date.now(); + node.accessCount += 1; + node.metadata = { ...node.metadata, ...metadata }; + const embedding = await upsertNodeEmbedding(rootDir, node, content); + await writeNodeContent(rootDir, node, content); + scheduleStoreSave(rootDir); + return { ...await hydrateNode(rootDir, node), embedding }; +} - const duplicate = Object.values(graph.edges).find(e => - e.source === sourceId && e.target === targetId && e.relation === relation - ); +export async function createRelation( + rootDir: string, + sourceId: string, + targetId: string, + relation: RelationType, + weight: number = 1, + metadata: Record = {}, +): Promise { + await autoPrune(rootDir); + const store = await loadStore(rootDir); + if (!store.nodes[sourceId] || !store.nodes[targetId]) return null; + const duplicate = Object.values(store.edges).find((edge) => edge.source === sourceId && edge.target === targetId && edge.relation === relation); if (duplicate) { - duplicate.weight = weight ?? duplicate.weight; - if (metadata) Object.assign(duplicate.metadata, metadata); - scheduleSave(rootDir); - return duplicate; + duplicate.weight = weight; + duplicate.metadata = { ...duplicate.metadata, ...metadata }; + scheduleStoreSave(rootDir); + return { ...duplicate }; } - - const edge: MemoryEdge = { + const edge: MemoryEdgeRecord = { id: generateId("me"), source: sourceId, target: targetId, relation, - weight: weight ?? 1.0, + weight, createdAt: Date.now(), - metadata: metadata ?? {}, + metadata: { ...metadata }, }; - graph.edges[edge.id] = edge; - scheduleSave(rootDir); - return edge; + store.edges[edge.id] = edge; + scheduleStoreSave(rootDir); + return { ...edge }; } -export async function searchGraph(rootDir: string, query: string, maxDepth: number = 1, topK: number = 5, edgeFilter?: RelationType[]): Promise { - const graph = await loadGraph(rootDir); - const nodes = Object.values(graph.nodes); - if (nodes.length === 0) return { direct: [], neighbors: [], totalNodes: 0, totalEdges: 0 }; - - const [queryVec] = await fetchEmbedding(query); - const scored = nodes.map(n => ({ node: n, score: cosine(queryVec, n.embedding) })) - .sort((a, b) => b.score - a.score); - - const directHits = scored.slice(0, topK).map(({ node, score }) => { - node.lastAccessed = Date.now(); - return { - node, - depth: 0, - pathRelations: [] as string[], - relevanceScore: Math.round(score * 1000) / 10, - }; - }); - - const neighborResults: TraversalResult[] = []; - const visited = new Set(directHits.map(h => h.node.id)); - - for (const hit of directHits) { - traverseNeighbors(graph, hit.node.id, queryVec, 1, maxDepth, [hit.node.label], visited, neighborResults, edgeFilter); +export async function deleteMemory(rootDir: string, input: DeleteMemoryInput): Promise<{ removedNodes: number; removedEdges: number }> { + await autoPrune(rootDir); + const store = await loadStore(rootDir); + let removedNodes = 0; + let removedEdges = 0; + if (input.nodeId) { + const result = await removeNodeById(rootDir, store, input.nodeId); + removedNodes += result.removedNodes; + removedEdges += result.removedEdges; } + if (input.edgeId && store.edges[input.edgeId]) { + delete store.edges[input.edgeId]; + removedEdges += 1; + } + if (input.sourceId && input.targetId) { + for (const edge of Object.values(store.edges)) { + if (edge.source === input.sourceId && edge.target === input.targetId && (!input.relation || edge.relation === input.relation)) { + delete store.edges[edge.id]; + removedEdges += 1; + } + } + } + if (removedNodes > 0 || removedEdges > 0) scheduleStoreSave(rootDir); + return { removedNodes, removedEdges }; +} - neighborResults.sort((a, b) => b.relevanceScore - a.relevanceScore); - - scheduleSave(rootDir); +export async function searchGraph(rootDir: string, query: string, maxDepth: number = 1, topK: number = 5, options: MemorySearchOptions = {}): Promise { + await autoPrune(rootDir); + const store = await loadStore(rootDir); + const nodes = Object.values(store.nodes); + if (nodes.length === 0) return { direct: [], neighbors: [], totalNodes: 0, totalEdges: 0 }; + const [queryEmbedding] = await fetchEmbedding(query); + const queryTerms = new Set(splitTerms(query)); + const mode = options.mode ?? "both"; + const scored = await Promise.all(nodes.map((node) => scoreNode(rootDir, node, queryEmbedding, queryTerms, mode))); + const direct = scored + .sort((a, b) => b.score - a.score) + .slice(0, Math.max(1, topK)) + .map((item) => { + const record = store.nodes[item.node.id]; + record.lastAccessed = Date.now(); + record.accessCount += 1; + return { + node: item.node, + depth: 0, + pathRelations: [], + relevanceScore: Math.round(item.score * 1000) / 10, + }; + }); + const visited = new Set(direct.map((item) => item.node.id)); + const neighbors: TraversalResult[] = []; + for (const hit of direct) neighbors.push(...await collectNeighbors(rootDir, store, hit.node, queryEmbedding, maxDepth, visited, options.edgeFilter)); + scheduleStoreSave(rootDir); return { - direct: directHits, - neighbors: neighborResults.slice(0, topK * 2), + direct, + neighbors: neighbors.sort((a, b) => b.relevanceScore - a.relevanceScore).slice(0, Math.max(1, topK) * 2), totalNodes: nodes.length, - totalEdges: Object.keys(graph.edges).length, + totalEdges: Object.keys(store.edges).length, }; } -function traverseNeighbors( - graph: GraphStore, nodeId: string, queryVec: number[], depth: number, maxDepth: number, - pathLabels: string[], visited: Set, results: TraversalResult[], edgeFilter?: RelationType[], -): void { - if (depth > maxDepth) return; - - for (const edge of getEdgesForNode(graph, nodeId)) { - if (edgeFilter && !edgeFilter.includes(edge.relation)) continue; - const neighborId = getNeighborId(edge, nodeId); - if (visited.has(neighborId)) continue; - - const neighbor = graph.nodes[neighborId]; - if (!neighbor) continue; - - visited.add(neighborId); - const similarity = cosine(queryVec, neighbor.embedding); - const edgeDecay = decayWeight(edge); - const relevance = similarity * 0.6 + (edgeDecay / Math.max(edge.weight, 0.01)) * 0.4; - - results.push({ - node: neighbor, - depth, - pathRelations: [...pathLabels, `--[${edge.relation}]-->`, neighbor.label], - relevanceScore: Math.round(relevance * 1000) / 10, - }); - - neighbor.lastAccessed = Date.now(); - traverseNeighbors(graph, neighborId, queryVec, depth + 1, maxDepth, [...pathLabels, `--[${edge.relation}]-->`, neighbor.label], visited, results, edgeFilter); - } -} - -export async function pruneStaleLinks(rootDir: string, threshold?: number): Promise<{ removed: number; remaining: number }> { - const graph = await loadGraph(rootDir); - const cutoff = threshold ?? STALE_THRESHOLD; - const toRemove: string[] = []; - - for (const [edgeId, edge] of Object.entries(graph.edges)) { - if (decayWeight(edge) < cutoff) toRemove.push(edgeId); - } - - for (const id of toRemove) delete graph.edges[id]; - - const orphanNodeIds = Object.keys(graph.nodes).filter(nodeId => - getEdgesForNode(graph, nodeId).length === 0 - && graph.nodes[nodeId].accessCount <= 1 - && (Date.now() - graph.nodes[nodeId].lastAccessed) > 7 * 86_400_000 - ); - for (const id of orphanNodeIds) delete graph.nodes[id]; - - scheduleSave(rootDir); - return { removed: toRemove.length + orphanNodeIds.length, remaining: Object.keys(graph.edges).length }; -} - -export async function addInterlinkedContext(rootDir: string, items: Array<{ type: NodeType; label: string; content: string; metadata?: Record }>, autoLink: boolean = true): Promise<{ nodes: MemoryNode[]; edges: MemoryEdge[] }> { +export async function addInterlinkedContext( + rootDir: string, + items: Array<{ type: NodeType; label: string; content: string; metadata?: Record }>, + autoLink: boolean = true, +): Promise<{ nodes: MemoryNode[]; edges: MemoryEdge[] }> { + await autoPrune(rootDir); const createdNodes: MemoryNode[] = []; - for (const item of items) { - createdNodes.push(await upsertNode(rootDir, item.type, item.label, item.content, item.metadata)); - } - + for (const item of items) createdNodes.push(await upsertNode(rootDir, item.type, item.label, item.content, item.metadata ?? {})); const createdEdges: MemoryEdge[] = []; - - if (autoLink && createdNodes.length > 1) { - for (let i = 0; i < createdNodes.length; i++) { - for (let j = i + 1; j < createdNodes.length; j++) { - const similarity = cosine(createdNodes[i].embedding, createdNodes[j].embedding); - if (similarity >= SIMILARITY_THRESHOLD) { - const edge = await createRelation(rootDir, createdNodes[i].id, createdNodes[j].id, "similar_to", similarity); - if (edge) createdEdges.push(edge); - } + if (!autoLink || createdNodes.length === 0) return { nodes: createdNodes, edges: createdEdges }; + for (let i = 0; i < createdNodes.length; i++) { + for (let j = i + 1; j < createdNodes.length; j++) { + const similarity = cosine(createdNodes[i].embedding, createdNodes[j].embedding); + if (similarity >= SIMILARITY_THRESHOLD) { + const edge = await createRelation(rootDir, createdNodes[i].id, createdNodes[j].id, "similar_to", similarity); + if (edge) createdEdges.push(edge); } } } - - const graph = await loadGraph(rootDir); - const existingNodes = Object.values(graph.nodes) - .filter(n => !createdNodes.find(cn => cn.id === n.id)) - .slice(0, 200); - if (autoLink) { - for (const newNode of createdNodes) { - for (const existing of existingNodes) { - const similarity = cosine(newNode.embedding, existing.embedding); - if (similarity >= SIMILARITY_THRESHOLD) { - const edge = await createRelation(rootDir, newNode.id, existing.id, "similar_to", similarity); - if (edge) createdEdges.push(edge); - } + const store = await loadStore(rootDir); + const existingNodes = Object.values(store.nodes) + .filter((node) => !createdNodes.some((created) => created.id === node.id)) + .slice(0, MAX_EXISTING_AUTO_LINK); + for (const node of createdNodes) { + for (const existing of existingNodes) { + const candidate = await hydrateNode(rootDir, existing); + const similarity = cosine(node.embedding, candidate.embedding); + if (similarity >= SIMILARITY_THRESHOLD) { + const edge = await createRelation(rootDir, node.id, candidate.id, "similar_to", similarity); + if (edge) createdEdges.push(edge); } } } - return { nodes: createdNodes, edges: createdEdges }; } export async function retrieveWithTraversal(rootDir: string, startNodeId: string, maxDepth: number = 2, edgeFilter?: RelationType[]): Promise { - const graph = await loadGraph(rootDir); - const startNode = graph.nodes[startNodeId]; - if (!startNode) return []; - - startNode.lastAccessed = Date.now(); - startNode.accessCount++; - - const results: TraversalResult[] = [{ - node: startNode, - depth: 0, - pathRelations: [startNode.label], - relevanceScore: 100, - }]; - - const visited = new Set([startNodeId]); - collectTraversal(graph, startNodeId, 1, maxDepth, [startNode.label], visited, results, edgeFilter); - - scheduleSave(rootDir); + await autoPrune(rootDir); + const store = await loadStore(rootDir); + const start = store.nodes[startNodeId]; + if (!start) return []; + start.lastAccessed = Date.now(); + start.accessCount += 1; + const startNode = await hydrateNode(rootDir, start); + const results: TraversalResult[] = [{ node: startNode, depth: 0, pathRelations: [startNode.label], relevanceScore: 100 }]; + const visited = new Set([startNode.id]); + results.push(...await collectNeighbors(rootDir, store, startNode, startNode.embedding, maxDepth, visited, edgeFilter)); + scheduleStoreSave(rootDir); return results; } -function collectTraversal( - graph: GraphStore, nodeId: string, depth: number, maxDepth: number, - pathLabels: string[], visited: Set, results: TraversalResult[], edgeFilter?: RelationType[], -): void { - if (depth > maxDepth) return; - - for (const edge of getEdgesForNode(graph, nodeId)) { - if (edgeFilter && !edgeFilter.includes(edge.relation)) continue; - const neighborId = getNeighborId(edge, nodeId); - if (visited.has(neighborId)) continue; - - const neighbor = graph.nodes[neighborId]; - if (!neighbor) continue; - - visited.add(neighborId); - neighbor.lastAccessed = Date.now(); - - const decayed = decayWeight(edge); - const depthPenalty = 1 / (1 + depth * 0.3); - const score = decayed * depthPenalty * 100; +export async function getGraphStats(rootDir: string): Promise<{ nodes: number; edges: number; types: Record; relations: Record }> { + await autoPrune(rootDir); + const store = await loadStore(rootDir); + const types: Record = {}; + const relations: Record = {}; + for (const node of Object.values(store.nodes)) types[node.type] = (types[node.type] ?? 0) + 1; + for (const edge of Object.values(store.edges)) relations[edge.relation] = (relations[edge.relation] ?? 0) + 1; + return { nodes: Object.keys(store.nodes).length, edges: Object.keys(store.edges).length, types, relations }; +} - results.push({ - node: neighbor, - depth, - pathRelations: [...pathLabels, `--[${edge.relation}]-->`, neighbor.label], - relevanceScore: Math.round(score * 10) / 10, - }); +export async function reloadMemoryNodesFromFiles(rootDir: string): Promise { + const store = await loadStore(rootDir); + let updated = 0; + for (const node of Object.values(store.nodes)) { + try { + const raw = await readFile(join(rootDir, node.contentPath), "utf-8"); + const header = parseNodeHeader(raw); + const content = extractNodeBody(raw); + node.label = header.label; + node.type = header.type; + if (Object.keys(header.metadata).length > 0) node.metadata = { ...node.metadata, ...header.metadata }; + node.lastAccessed = Date.now(); + await upsertNodeEmbedding(rootDir, node, content); + const cache = nodeCache.get(rootDir) ?? new Map(); + cache.set(node.id, content); + nodeCache.set(rootDir, cache); + updated += 1; + } catch { + } + } + if (updated > 0) scheduleStoreSave(rootDir); + return updated; +} - collectTraversal(graph, neighborId, depth + 1, maxDepth, [...pathLabels, `--[${edge.relation}]-->`, neighbor.label], visited, results, edgeFilter); +export async function rebuildMemoryVectors(rootDir: string): Promise { + const store = await loadStore(rootDir); + let updated = 0; + for (const node of Object.values(store.nodes)) { + await upsertNodeEmbedding(rootDir, node, await loadNodeContent(rootDir, node)); + updated += 1; } + return updated; } -export async function getGraphStats(rootDir: string): Promise<{ nodes: number; edges: number; types: Record; relations: Record }> { - const graph = await loadGraph(rootDir); - const types: Record = {}; - const relations: Record = {}; +export async function clearMemoryGraph(rootDir: string): Promise { + const store = await loadStore(rootDir); + for (const node of Object.values(store.nodes)) { + await deleteVector(rootDir, MEMORY_VECTOR_NAMESPACE, nodeVectorKey(node.id)); + if (node.contentPath) await rm(join(rootDir, node.contentPath), { force: true }); + } + store.nodes = {}; + store.edges = {}; + await removeStaleContentFiles(rootDir, new Set()); + scheduleStoreSave(rootDir); +} - for (const node of Object.values(graph.nodes)) types[node.type] = (types[node.type] ?? 0) + 1; - for (const edge of Object.values(graph.edges)) relations[edge.relation] = (relations[edge.relation] ?? 0) + 1; +export async function finalizeMemoryStore(rootDir: string): Promise { + const store = await loadStore(rootDir); + await removeStaleContentFiles(rootDir, new Set(Object.values(store.nodes).map((node) => node.contentPath))); + scheduleStoreSave(rootDir); +} - return { nodes: Object.keys(graph.nodes).length, edges: Object.keys(graph.edges).length, types, relations }; +export async function getMemoryGraphEmbeddings(rootDir: string): Promise { + return (await listVectors(rootDir, MEMORY_VECTOR_NAMESPACE)).length; } diff --git a/src/core/vector-db.ts b/src/core/vector-db.ts new file mode 100644 index 0000000..24906cc --- /dev/null +++ b/src/core/vector-db.ts @@ -0,0 +1,223 @@ +// SQLite vector storage with namespaces hashes and metadata for retrieval +// FEATURE: Shared vector database layer for search and memory embeddings + +import { mkdir, rm } from "fs/promises"; +import { join } from "path"; +import { DatabaseSync } from "node:sqlite"; + +export interface VectorRecord { + key: string; + hash: string; + vector: number[]; + metadata: Record; + updatedAt: number; +} + +export interface VectorUpsertInput { + key: string; + hash: string; + vector: number[]; + metadata?: Record; +} + +const CONTEXTPLUS_DIR = ".contextplus"; +const EMBEDDINGS_DIR = "embeddings"; +const VECTOR_DB_FILE = "vectors.db"; + +function parseMetadata(value: string | null): Record { + if (!value) return {}; + try { + const parsed = JSON.parse(value) as Record; + if (parsed && typeof parsed === "object") return parsed; + } catch { + } + return {}; +} + +function parseVector(value: string): number[] { + try { + const parsed = JSON.parse(value) as number[]; + if (Array.isArray(parsed)) return parsed; + } catch { + } + return []; +} + +function ensureSchema(db: DatabaseSync): void { + db.exec( + "CREATE TABLE IF NOT EXISTS vectors (" + + "namespace TEXT NOT NULL, " + + "key TEXT NOT NULL, " + + "hash TEXT NOT NULL, " + + "vector TEXT NOT NULL, " + + "metadata TEXT NOT NULL DEFAULT '{}', " + + "updated_at INTEGER NOT NULL, " + + "PRIMARY KEY(namespace, key))", + ); + db.exec("CREATE INDEX IF NOT EXISTS idx_vectors_namespace_updated ON vectors(namespace, updated_at DESC)"); +} + +async function getDbPath(rootDir: string): Promise { + const embeddingsPath = join(rootDir, CONTEXTPLUS_DIR, EMBEDDINGS_DIR); + await mkdir(embeddingsPath, { recursive: true }); + return join(embeddingsPath, VECTOR_DB_FILE); +} + +function runTransaction(db: DatabaseSync, runner: () => T): T { + db.exec("BEGIN"); + try { + const result = runner(); + db.exec("COMMIT"); + return result; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +} + +export async function getVectorDb(rootDir: string): Promise { + const db = new DatabaseSync(await getDbPath(rootDir)); + ensureSchema(db); + return db; +} + +async function withDb(rootDir: string, runner: (db: DatabaseSync) => T): Promise { + const db = await getVectorDb(rootDir); + try { + return runner(db); + } finally { + try { + db.close(); + } catch { + } + } +} + +export function closeVectorDb(_rootDir: string): void { +} + +export function closeAllVectorDbs(): void { +} + +export async function resetVectorDb(rootDir: string): Promise { + await rm(await getDbPath(rootDir), { force: true }); +} + +export async function listVectorKeys(rootDir: string, namespace: string): Promise { + return withDb(rootDir, (db) => { + const rows = db.prepare("SELECT key FROM vectors WHERE namespace = ?").all(namespace) as Array<{ key: string }>; + return rows.map((row) => row.key); + }); +} + +export async function upsertVectors( + rootDir: string, + namespace: string, + vectors: VectorUpsertInput[], +): Promise { + if (vectors.length === 0) return; + + await withDb(rootDir, (db) => { + const now = Date.now(); + const statement = db.prepare( + "INSERT INTO vectors(namespace, key, hash, vector, metadata, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(namespace, key) DO UPDATE SET " + + "hash=excluded.hash, vector=excluded.vector, metadata=excluded.metadata, updated_at=excluded.updated_at", + ); + + runTransaction(db, () => { + for (const vector of vectors) { + statement.run( + namespace, + vector.key, + vector.hash, + JSON.stringify(vector.vector), + JSON.stringify(vector.metadata ?? {}), + now, + ); + } + }); + }); +} + +export async function deleteVectors(rootDir: string, namespace: string, keys: string[]): Promise { + if (keys.length === 0) return 0; + + return withDb(rootDir, (db) => { + const statement = db.prepare("DELETE FROM vectors WHERE namespace = ? AND key = ?"); + return runTransaction(db, () => { + let deleted = 0; + for (const key of keys) { + const result = statement.run(namespace, key) as { changes?: number }; + deleted += result.changes ?? 0; + } + return deleted; + }); + }); +} + +export async function upsertVector( + rootDir: string, + namespace: string, + key: string, + hash: string, + vector: number[], + metadata: Record = {}, +): Promise { + await upsertVectors(rootDir, namespace, [{ key, hash, vector, metadata }]); +} + +export async function getVector(rootDir: string, namespace: string, key: string): Promise { + return withDb(rootDir, (db) => { + const row = db.prepare( + "SELECT key, hash, vector, metadata, updated_at FROM vectors WHERE namespace = ? AND key = ?", + ).get(namespace, key) as { + key: string; + hash: string; + vector: string; + metadata: string | null; + updated_at: number; + } | undefined; + if (!row) return null; + return { + key: row.key, + hash: row.hash, + vector: parseVector(row.vector), + metadata: parseMetadata(row.metadata), + updatedAt: row.updated_at, + }; + }); +} + +export async function listVectors(rootDir: string, namespace: string): Promise { + return withDb(rootDir, (db) => { + const rows = db.prepare( + "SELECT key, hash, vector, metadata, updated_at FROM vectors WHERE namespace = ? ORDER BY updated_at DESC", + ).all(namespace) as Array<{ + key: string; + hash: string; + vector: string; + metadata: string | null; + updated_at: number; + }>; + return rows.map((row) => ({ + key: row.key, + hash: row.hash, + vector: parseVector(row.vector), + metadata: parseMetadata(row.metadata), + updatedAt: row.updated_at, + })); + }); +} + +export async function deleteVector(rootDir: string, namespace: string, key: string): Promise { + await deleteVectors(rootDir, namespace, [key]); +} + +export async function deleteNamespace(rootDir: string, namespace: string): Promise { + return withDb(rootDir, (db) => { + const result = db.prepare("DELETE FROM vectors WHERE namespace = ?").run(namespace) as { changes?: number }; + return result.changes ?? 0; + }); +} diff --git a/src/git/shadow.ts b/src/git/shadow.ts index 0b624eb..0ac1422 100644 --- a/src/git/shadow.ts +++ b/src/git/shadow.ts @@ -1,12 +1,8 @@ -// Shadow git branch manager for safe AI change tracking -// Creates restore points on hidden branch without polluting main history +// Local checkpoint snapshots for file restore and iterative agent safety +// FEATURE: Undoable checkpoint lifecycle without rewriting existing git history -import { simpleGit, type SimpleGit } from "simple-git"; -import { readFile, writeFile, mkdir } from "fs/promises"; -import { join, dirname } from "path"; - -const SHADOW_BRANCH = "mcp-shadow-history"; -const DATA_DIR = ".mcp_data"; +import { mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises"; +import { dirname, join, relative, resolve } from "path"; export interface RestorePoint { id: string; @@ -15,106 +11,140 @@ export interface RestorePoint { message: string; } -async function ensureDataDir(rootDir: string): Promise { - const dataPath = join(rootDir, DATA_DIR); - await mkdir(dataPath, { recursive: true }); - return dataPath; +interface CheckpointManifest { + checkpoints: RestorePoint[]; +} + +const CONTEXTPLUS_DIR = ".contextplus"; +const CHECKPOINTS_DIR = "checkpoints"; +const MANIFEST_FILE = "manifest.json"; +const MAX_CHECKPOINTS = 120; + +function normalizeFilePath(filePath: string): string { + return filePath.replace(/\\/g, "/").replace(/^\.\//, ""); +} + +function checkpointDir(rootDir: string): string { + return join(rootDir, CONTEXTPLUS_DIR, CHECKPOINTS_DIR); +} + +function manifestPath(rootDir: string): string { + return join(checkpointDir(rootDir), MANIFEST_FILE); +} + +function checkpointDataDir(rootDir: string, checkpointId: string): string { + return join(checkpointDir(rootDir), checkpointId); } -async function loadManifest(rootDir: string): Promise { - const manifestPath = join(rootDir, DATA_DIR, "restore-points.json"); +function backupFilePath(rootDir: string, checkpointId: string, filePath: string): string { + return join(checkpointDataDir(rootDir, checkpointId), "files", normalizeFilePath(filePath)); +} + +async function ensureCheckpointRoot(rootDir: string): Promise { + await mkdir(checkpointDir(rootDir), { recursive: true }); +} + +async function loadManifest(rootDir: string): Promise { + await ensureCheckpointRoot(rootDir); try { - return JSON.parse(await readFile(manifestPath, "utf-8")); + const data = JSON.parse(await readFile(manifestPath(rootDir), "utf-8")) as Partial; + const checkpoints = Array.isArray(data.checkpoints) ? data.checkpoints : []; + return { checkpoints }; } catch { - return []; + return { checkpoints: [] }; } } -async function saveManifest(rootDir: string, points: RestorePoint[]): Promise { - const dataPath = await ensureDataDir(rootDir); - await writeFile(join(dataPath, "restore-points.json"), JSON.stringify(points, null, 2)); +async function saveManifest(rootDir: string, manifest: CheckpointManifest): Promise { + await ensureCheckpointRoot(rootDir); + await writeFile(manifestPath(rootDir), JSON.stringify(manifest, null, 2), "utf-8"); } -export async function createRestorePoint(rootDir: string, files: string[], message: string): Promise { - const dataPath = await ensureDataDir(rootDir); - 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); - try { - const content = await readFile(fullPath, "utf-8"); - const backupPath = join(backupDir, file.replace(/[\\/]/g, "__")); - await writeFile(backupPath, content); - } catch { - } +async function checkpointFileExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; } +} - const point: RestorePoint = { id, timestamp: Date.now(), files, message }; +async function pruneOldCheckpoints(rootDir: string, manifest: CheckpointManifest): Promise { + if (manifest.checkpoints.length <= MAX_CHECKPOINTS) return; + const removed = manifest.checkpoints.splice(0, manifest.checkpoints.length - MAX_CHECKPOINTS); + await Promise.all(removed.map((checkpoint) => rm(checkpointDataDir(rootDir, checkpoint.id), { recursive: true, force: true }))); +} + +export async function createRestorePoint(rootDir: string, files: string[], message: string): Promise { + const normalizedFiles = Array.from(new Set(files.map(normalizeFilePath).filter(Boolean))); + const id = `rp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const timestamp = Date.now(); + const checkpoint: RestorePoint = { id, timestamp, files: normalizedFiles, message }; + await Promise.all(normalizedFiles.map(async (filePath) => { + const sourcePath = resolve(rootDir, filePath); + if (!await checkpointFileExists(sourcePath)) return; + const backupPath = backupFilePath(rootDir, id, filePath); + await mkdir(dirname(backupPath), { recursive: true }); + await writeFile(backupPath, await readFile(sourcePath)); + })); const manifest = await loadManifest(rootDir); - manifest.push(point); - if (manifest.length > 100) manifest.splice(0, manifest.length - 100); + manifest.checkpoints.push(checkpoint); + manifest.checkpoints.sort((a, b) => a.timestamp - b.timestamp); + await pruneOldCheckpoints(rootDir, manifest); await saveManifest(rootDir, manifest); + return checkpoint; +} - return point; +export async function listRestorePoints(rootDir: string): Promise { + const manifest = await loadManifest(rootDir); + return manifest.checkpoints.slice().sort((a, b) => b.timestamp - a.timestamp); } export async function restorePoint(rootDir: string, pointId: string): Promise { const manifest = await loadManifest(rootDir); - 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 restoredFiles: string[] = []; - - for (const file of point.files) { - 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); - } catch { - } - } + const checkpoint = manifest.checkpoints.find((entry) => entry.id === pointId); + if (!checkpoint) throw new Error(`Restore point ${pointId} not found`); + const restored: string[] = []; + await Promise.all(checkpoint.files.map(async (filePath) => { + const backupPath = backupFilePath(rootDir, checkpoint.id, filePath); + if (!await checkpointFileExists(backupPath)) return; + const targetPath = resolve(rootDir, filePath); + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, await readFile(backupPath)); + restored.push(filePath); + })); + return restored.sort((a, b) => a.localeCompare(b)); +} - return restoredFiles; +export async function getCheckpointSummary(rootDir: string): Promise { + const checkpoints = await listRestorePoints(rootDir); + return checkpoints.map((entry) => `${entry.id} | ${new Date(entry.timestamp).toISOString()} | ${entry.files.length} files | ${entry.message}`); } -export async function listRestorePoints(rootDir: string): Promise { - return loadManifest(rootDir); +export async function cleanMissingCheckpointData(rootDir: string): Promise { + const manifest = await loadManifest(rootDir); + const before = manifest.checkpoints.length; + manifest.checkpoints = await manifest.checkpoints.reduce(async (promise, checkpoint) => { + const acc = await promise; + if (await checkpointFileExists(checkpointDataDir(rootDir, checkpoint.id))) acc.push(checkpoint); + return acc; + }, Promise.resolve([] as RestorePoint[])); + if (manifest.checkpoints.length !== before) await saveManifest(rootDir, manifest); + return before - manifest.checkpoints.length; } -export async function shadowCommit(rootDir: string, message: string): Promise { - try { - const git: SimpleGit = simpleGit(rootDir); - const isRepo = await git.checkIsRepo(); - if (!isRepo) return false; - - const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"]); - const stashResult = await git.stash(["push", "-m", `mcp-shadow: ${message}`]); - - if (!stashResult.includes("No local changes")) { - try { - const branchExists = await git.branch(["-l", SHADOW_BRANCH]); - if (!branchExists.all.includes(SHADOW_BRANCH)) { - await git.branch([SHADOW_BRANCH]); - } - await git.checkout(SHADOW_BRANCH); - await git.stash(["pop"]); - await git.add("."); - await git.commit(`[MCP Shadow] ${message}`); - await git.checkout(currentBranch); - } catch (e) { - await git.checkout(currentBranch); - try { await git.stash(["pop"]); } catch { } - return false; - } +export async function listCheckpointFiles(rootDir: string, pointId: string): Promise { + const base = join(checkpointDataDir(rootDir, pointId), "files"); + if (!await checkpointFileExists(base)) return []; + const result: string[] = []; + const walk = async (dir: string): Promise => { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) await walk(entryPath); + else result.push(normalizeFilePath(relative(base, entryPath))); } - return true; - } catch { - return false; - } + }; + await walk(base); + return result.sort((a, b) => a.localeCompare(b)); } diff --git a/src/index.ts b/src/index.ts index e688e3a..80d2025 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -// Context+ MCP - Semantic codebase navigator for AI agents -// Structural AST tree, blast radius, semantic search, commit gatekeeper +// Context+ MCP server with v1 tool names and unified semantics +// FEATURE: MCP entrypoint for discovery analysis checkpoint and memory workflows import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; @@ -8,19 +8,33 @@ import { mkdir, writeFile } from "fs/promises"; import { dirname, resolve } from "path"; import { z } from "zod"; 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, cancelAllEmbeddings } from "./core/embeddings.js"; -import { semanticCodeSearch, invalidateSearchCache } from "./tools/semantic-search.js"; -import { semanticIdentifierSearch, invalidateIdentifierSearchCache } from "./tools/semantic-identifiers.js"; +import { formatToolError, formatErrorResponse } from "./core/error-handler.js"; +import { getIdleShutdownMs, getParentPollMs, isBrokenPipeError, createIdleMonitor, runCleanup, startParentMonitor } from "./core/process-lifecycle.js"; +import { restorePoint, listRestorePoints } from "./git/shadow.js"; import { getBlastRadius } from "./tools/blast-radius.js"; -import { runStaticAnalysis } from "./tools/static-analysis.js"; -import { proposeCommit } from "./tools/propose-commit.js"; -import { listRestorePoints, restorePoint } from "./git/shadow.js"; +import { getContextTree } from "./tools/context-tree.js"; +import { findHub } from "./tools/feature-hub.js"; +import { getFileSkeleton } from "./tools/file-skeleton.js"; +import { formatInitResult, initProject } from "./tools/init.js"; +import { + toolBulkMemory, + toolCreateMemory, + toolCreateRelation, + toolDeleteMemory, + toolExploreMemory, + toolSearchMemory, + toolUpdateMemory, +} from "./tools/memory-tools.js"; +import { checkpoint } from "./tools/propose-commit.js"; +import { searchCodebase } from "./tools/search.js"; +import { invalidateIdentifierSearchCache } from "./tools/semantic-identifiers.js"; import { semanticNavigate } from "./tools/semantic-navigate.js"; -import { getFeatureHub } from "./tools/feature-hub.js"; -import { toolUpsertMemoryNode, toolCreateRelation, toolSearchMemoryGraph, toolPruneStaleLinks, toolAddInterlinkedContext, toolRetrieveWithTraversal } from "./tools/memory-tools.js"; +import { invalidateSearchCache } from "./tools/semantic-search.js"; +import { runLint } from "./tools/static-analysis.js"; +import { getCodeStructure } from "./tools/code-structure.js"; +import { research, discoverRelated } from "./tools/research.js"; +import { toolImportACP, toolListACPSessions, toolListACPMemories, toolSearchACP } from "./tools/acp-tools.js"; type AgentTarget = "claude" | "cursor" | "vscode" | "windsurf" | "opencode"; @@ -45,12 +59,17 @@ let ensureTrackerRunning = () => { }; function withRequestActivity( handler: (args: TArgs) => Promise, - options?: { useEmbeddingTracker?: boolean }, + options?: { useEmbeddingTracker?: boolean; toolName?: string }, ): (args: TArgs) => Promise { return async (args: TArgs): Promise => { noteServerActivity(); if (options?.useEmbeddingTracker) ensureTrackerRunning(); - return handler(args); + try { + return await handler(args); + } catch (error) { + const toolError = formatToolError(options?.toolName ?? "unknown", error); + return { content: [{ type: "text" as const, text: formatErrorResponse(toolError) }] } as TResult; + } }; } @@ -61,7 +80,7 @@ function parseAgentTarget(input?: string): AgentTarget { if (normalized === "vscode" || normalized === "vs-code" || normalized === "vs") return "vscode"; if (normalized === "windsurf") return "windsurf"; if (normalized === "opencode" || normalized === "open-code") return "opencode"; - throw new Error(`Unsupported coding agent \"${input}\". Use one of: claude, cursor, vscode, windsurf, opencode.`); + throw new Error(`Unsupported coding agent "${input}". Use one of: claude, cursor, vscode, windsurf, opencode.`); } function parseRunner(args: string[]): "npx" | "bunx" { @@ -69,13 +88,13 @@ function parseRunner(args: string[]): "npx" | "bunx" { if (explicit) { const value = explicit.split("=")[1]; if (value === "npx" || value === "bunx") return value; - throw new Error(`Unsupported runner \"${value}\". Use --runner=npx or --runner=bunx.`); + throw new Error(`Unsupported runner "${value}". Use --runner=npx or --runner=bunx.`); } const runnerFlagIndex = args.findIndex((arg) => arg === "--runner"); if (runnerFlagIndex >= 0) { const value = args[runnerFlagIndex + 1]; if (value === "npx" || value === "bunx") return value; - throw new Error(`Unsupported runner \"${value}\". Use --runner=npx or --runner=bunx.`); + throw new Error(`Unsupported runner "${value}". Use --runner=npx or --runner=bunx.`); } const userAgent = (process.env.npm_config_user_agent ?? "").toLowerCase(); const execPath = (process.env.npm_execpath ?? "").toLowerCase(); @@ -84,18 +103,19 @@ function parseRunner(args: string[]): "npx" | "bunx" { } function buildMcpConfig(runner: "npx" | "bunx") { - const commandArgs = runner === "npx" ? ["-y", "contextplus"] : ["contextplus"]; return JSON.stringify( { mcpServers: { contextplus: { command: runner, - args: commandArgs, + args: runner === "npx" ? ["-y", "contextplus"] : ["contextplus"], env: { OLLAMA_EMBED_MODEL: "nomic-embed-text", OLLAMA_CHAT_MODEL: "gemma2:27b", OLLAMA_API_KEY: "YOUR_OLLAMA_API_KEY", - CONTEXTPLUS_EMBED_BATCH_SIZE: "8", + CONTEXTPLUS_EMBED_BATCH_SIZE: "10", + CONTEXTPLUS_EMBED_BATCH_CONCURRENCY: "4", + CONTEXTPLUS_EMBED_CHUNK_CHARS: "8000", CONTEXTPLUS_EMBED_TRACKER: "lazy", }, }, @@ -107,20 +127,21 @@ function buildMcpConfig(runner: "npx" | "bunx") { } function buildOpenCodeConfig(runner: "npx" | "bunx") { - const command = runner === "npx" ? ["npx", "-y", "contextplus"] : ["bunx", "contextplus"]; return JSON.stringify( { $schema: "https://opencode.ai/config.json", mcp: { contextplus: { type: "local", - command, + command: runner === "npx" ? ["npx", "-y", "contextplus"] : ["bunx", "contextplus"], enabled: true, environment: { OLLAMA_EMBED_MODEL: "nomic-embed-text", OLLAMA_CHAT_MODEL: "gemma2:27b", OLLAMA_API_KEY: "YOUR_OLLAMA_API_KEY", - CONTEXTPLUS_EMBED_BATCH_SIZE: "8", + CONTEXTPLUS_EMBED_BATCH_SIZE: "10", + CONTEXTPLUS_EMBED_BATCH_CONCURRENCY: "4", + CONTEXTPLUS_EMBED_CHUNK_CHARS: "8000", CONTEXTPLUS_EMBED_TRACKER: "lazy", }, }, @@ -143,12 +164,10 @@ async function runInitCommand(args: string[]) { console.error(`Wrote MCP config: ${outputPath}`); } -const server = new McpServer({ - name: "contextplus", - version: "1.0.0", -}, { - capabilities: { logging: {} }, -}); +const server = new McpServer( + { name: "contextplus", version: "1.0.0" }, + { capabilities: { logging: {} } }, +); server.resource( "contextplus_instructions", @@ -166,15 +185,13 @@ server.resource( ); server.tool( - "get_context_tree", - "Get the structural tree of the project with file headers, function names, classes, enums, and line ranges. " + - "Automatically reads 2-line headers for file purpose. Dynamic token-aware pruning: " + - "Level 2 (deep symbols) -> Level 1 (headers only) -> Level 0 (file names only) based on project size.", + "tree", + "Project structural tree with headers and optional symbols.", { - target_path: z.string().optional().describe("Specific directory or file to analyze (relative to project root). Defaults to root."), - depth_limit: z.number().optional().describe("How many folder levels deep to scan. Use 1-2 for large projects."), - 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."), + target_path: z.string().optional().describe("Specific directory or file (relative to project root)."), + depth_limit: z.number().optional().describe("Folder depth to scan."), + include_symbols: z.boolean().optional().describe("Include symbol names and ranges. Default true."), + max_tokens: z.number().optional().describe("Output token budget. Default 20000."), }, withRequestActivity(async ({ target_path, depth_limit, include_symbols, max_tokens }) => ({ content: [{ @@ -187,70 +204,43 @@ server.tool( maxTokens: max_tokens, }), }], - })), + }), { toolName: "tree" }), ); server.tool( - "semantic_identifier_search", - "Search semantic intent at identifier level (functions, methods, classes, variables) with definition lines and ranked call sites. " + - "Uses embeddings over symbol signatures and source context, then returns line-numbered definition/call chains.", - { - query: z.string().describe("Natural language intent to match identifiers and usages."), - top_k: z.number().optional().describe("How many identifiers to return. Default: 5."), - top_calls_per_identifier: z.number().optional().describe("How many ranked call sites per identifier. Default: 10."), - include_kinds: z.array(z.string()).optional().describe("Optional kinds filter, e.g. [\"function\", \"method\", \"variable\"]."), - 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."), - }, - withRequestActivity(async ({ query, top_k, top_calls_per_identifier, include_kinds, semantic_weight, keyword_weight }) => ({ - content: [{ - type: "text" as const, - text: await semanticIdentifierSearch({ - rootDir: ROOT_DIR, - query, - topK: top_k, - topCallsPerIdentifier: top_calls_per_identifier, - includeKinds: include_kinds, - semanticWeight: semantic_weight, - keywordWeight: keyword_weight, - }), - }], - }), { useEmbeddingTracker: true }), -); - -server.tool( - "get_file_skeleton", - "Get detailed function signatures, class methods, and type definitions of a specific file WITHOUT reading the full body. " + - "Shows the API surface: function names, parameters, return types, and line ranges. Perfect for understanding how to use code without loading it all.", - { - file_path: z.string().describe("Path to the file to inspect (relative to project root)."), - }, + "skeleton", + "File structure and signatures without full body loading.", + { file_path: z.string().describe("File path relative to project root.") }, withRequestActivity(async ({ file_path }) => ({ - content: [{ - type: "text" as const, - text: await getFileSkeleton({ rootDir: ROOT_DIR, filePath: file_path }), - }], - })), + content: [{ type: "text" as const, text: await getFileSkeleton({ rootDir: ROOT_DIR, filePath: file_path }) }], + }), { toolName: "skeleton" }), ); server.tool( - "semantic_code_search", - "Search the codebase by MEANING, not just exact variable names. Uses Ollama embeddings over file headers and symbol names. " + - "Example: searching 'user authentication' finds files about login, sessions, JWT even if those exact words aren't used, with matched definition lines.", + "search", + "Unified search across files and identifiers with semantic, keyword, or hybrid modes.", { - query: z.string().describe("Natural language description of what you're looking for. Example: 'how are transactions signed'"), - top_k: z.number().optional().describe("Number of matches to return. Default: 5."), - semantic_weight: z.number().optional().describe("Weight for embedding similarity in hybrid ranking. Default: 0.72."), - keyword_weight: z.number().optional().describe("Weight for keyword overlap in hybrid ranking. Default: 0.28."), - min_semantic_score: z.number().optional().describe("Minimum semantic score filter. Accepts 0-1 or 0-100."), - min_keyword_score: z.number().optional().describe("Minimum keyword score filter. Accepts 0-1 or 0-100."), - min_combined_score: z.number().optional().describe("Minimum final score filter. Accepts 0-1 or 0-100."), - 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."), + query: z.string().describe("Natural language query."), + search_type: z.enum(["identifier", "file", "hybrid"]).optional().describe("Search target scope. Default hybrid."), + mode: z.enum(["semantic", "keyword", "both"]).optional().describe("Ranking mode. Default both."), + top_k: z.number().optional().describe("Top results count."), + top_calls_per_identifier: z.number().optional().describe("Top call-sites per identifier for identifier/hybrid search."), + include_kinds: z.array(z.string()).optional().describe("Optional identifier kinds filter."), + semantic_weight: z.number().optional().describe("Semantic score weight for both mode."), + keyword_weight: z.number().optional().describe("Keyword score weight for both mode."), + min_semantic_score: z.number().optional().describe("Minimum semantic score threshold."), + min_keyword_score: z.number().optional().describe("Minimum keyword score threshold."), + min_combined_score: z.number().optional().describe("Minimum total score threshold."), + require_keyword_match: z.boolean().optional().describe("Require keyword overlap."), + require_semantic_match: z.boolean().optional().describe("Require semantic score > 0."), }, withRequestActivity(async ({ query, + search_type, + mode, top_k, + top_calls_per_identifier, + include_kinds, semantic_weight, keyword_weight, min_semantic_score, @@ -261,10 +251,14 @@ server.tool( }) => ({ content: [{ type: "text" as const, - text: await semanticCodeSearch({ + text: await searchCodebase({ rootDir: ROOT_DIR, query, + searchType: search_type, + mode, topK: top_k, + topCallsPerIdentifier: top_calls_per_identifier, + includeKinds: include_kinds, semanticWeight: semantic_weight, keywordWeight: keyword_weight, minSemanticScore: min_semantic_score, @@ -274,84 +268,89 @@ server.tool( requireSemanticMatch: require_semantic_match, }), }], - }), { useEmbeddingTracker: true }), + }), { useEmbeddingTracker: true, toolName: "search" }), ); server.tool( - "get_blast_radius", - "Before deleting or modifying code, check the BLAST RADIUS. Traces every file and line where a specific symbol " + - "(function, class, variable) is imported or used. Prevents orphaned code. Also warns if usage count is low (candidate for inlining).", + "cluster", + "Semantic navigation clusters for project files.", { - 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."), + max_depth: z.number().optional().describe("Maximum cluster depth."), + max_clusters: z.number().optional().describe("Maximum clusters per level."), }, - withRequestActivity(async ({ symbol_name, file_context }) => ({ + withRequestActivity(async ({ max_depth, max_clusters }) => ({ content: [{ type: "text" as const, - text: await getBlastRadius({ rootDir: ROOT_DIR, symbolName: symbol_name, fileContext: file_context }), + text: await semanticNavigate({ rootDir: ROOT_DIR, maxDepth: max_depth, maxClusters: max_clusters }), }], - })), + }), { useEmbeddingTracker: true, toolName: "cluster" }), ); server.tool( - "run_static_analysis", - "Run the project's native linter/compiler to find unused variables, dead code, type errors, and syntax issues. " + - "Delegates detection to deterministic tools instead of LLM guessing. Supports TypeScript, Python, Rust, Go.", + "blast_radius", + "Trace symbol usages across the codebase.", { - target_path: z.string().optional().describe("Specific file or folder to lint (relative to root). Omit for full project."), + symbol_name: z.string().describe("Function, class, or variable name to trace."), + file_context: z.string().optional().describe("Definition file to exclude the definition line."), }, - withRequestActivity(async ({ target_path }) => ({ + withRequestActivity(async ({ symbol_name, file_context }) => ({ content: [{ type: "text" as const, - text: await runStaticAnalysis({ rootDir: ROOT_DIR, targetPath: target_path }), + text: await getBlastRadius({ rootDir: ROOT_DIR, symbolName: symbol_name, fileContext: file_context }), }], - })), + }), { toolName: "blast_radius" }), ); server.tool( - "propose_commit", - "The ONLY way to write code. Validates the code against strict rules before saving: " + - "2-line header comments, no inline comments, max nesting depth, max file length. " + - "Creates a shadow restore point before writing. REJECTS code that violates formatting rules.", + "lint", + "Run linting and project skill scoring checks.", + { target_path: z.string().optional().describe("Optional file/folder path to lint.") }, + withRequestActivity(async ({ target_path }) => ({ + content: [{ type: "text" as const, text: await runLint({ rootDir: ROOT_DIR, targetPath: target_path }) }], + }), { toolName: "lint" }), +); + +server.tool( + "code_structure", + "Deep AST analysis showing imports, exports, and call graph for a source file.", + { file_path: z.string().describe("File path relative to project root.") }, + withRequestActivity(async ({ file_path }) => ({ + content: [{ type: "text" as const, text: await getCodeStructure({ rootDir: ROOT_DIR, filePath: file_path }) }], + }), { toolName: "code_structure" }), +); + +server.tool( + "checkpoint", + "Write file after validation and create local restore checkpoint.", { - 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."), + file_path: z.string().describe("Target file path relative to project root."), + new_content: z.string().describe("Full replacement file content."), }, withRequestActivity(async ({ file_path, new_content }) => { invalidateSearchCache(); invalidateIdentifierSearchCache(); return { - content: [{ - type: "text" as const, - text: await proposeCommit({ rootDir: ROOT_DIR, filePath: file_path, newContent: new_content }), - }], + content: [{ type: "text" as const, text: await checkpoint({ rootDir: ROOT_DIR, filePath: file_path, newContent: new_content }) }], }; - }), + }, { toolName: "checkpoint" }), ); server.tool( - "list_restore_points", - "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.", + "restore_points", + "List available local restore checkpoints.", {}, withRequestActivity(async () => { const points = await listRestorePoints(ROOT_DIR); if (points.length === 0) return { content: [{ type: "text" as const, text: "No restore points found." }] }; - - const lines = points.map((p) => - `${p.id} | ${new Date(p.timestamp).toISOString()} | ${p.files.join(", ")} | ${p.message}`, - ); + const lines = points.map((entry) => `${entry.id} | ${new Date(entry.timestamp).toISOString()} | ${entry.files.join(", ")} | ${entry.message}`); return { content: [{ type: "text" as const, text: `Restore Points (${points.length}):\n\n${lines.join("\n")}` }] }; - }), + }, { toolName: "restore_points" }), ); server.tool( - "undo_change", - "Restore files to their state before a specific AI change. Uses the shadow restore point system. " + - "Does NOT affect git history. Call list_restore_points first to find the point ID.", - { - point_id: z.string().describe("The restore point ID (format: rp-timestamp-hash). Get from list_restore_points."), - }, + "restore", + "Restore files from a specific checkpoint.", + { point_id: z.string().describe("Checkpoint ID from restore_points.") }, withRequestActivity(async ({ point_id }) => { const restored = await restorePoint(ROOT_DIR, point_id); invalidateSearchCache(); @@ -359,163 +358,236 @@ server.tool( return { content: [{ type: "text" as const, - text: restored.length > 0 - ? `Restored ${restored.length} file(s):\n${restored.join("\n")}` - : "No files were restored. The backup may be empty.", + text: restored.length > 0 ? `Restored ${restored.length} file(s):\n${restored.join("\n")}` : "No files restored.", }], }; - }), + }, { toolName: "restore" }), ); server.tool( - "semantic_navigate", - "Browse the codebase by MEANING, not directory structure. Uses spectral clustering on Ollama embeddings to group " + - "semantically related files into labeled clusters. Inspired by Gabriella Gonzalez's semantic navigator. " + - "Requires Ollama running with an embedding model and a chat model for labeling.", + "find_hub", + "Rank or list feature hubs by semantic/keyword/hybrid matching.", { - 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."), + query: z.string().optional().describe("Search query. If omitted, returns all hubs context."), + mode: z.enum(["semantic", "keyword", "both"]).optional().describe("Search mode. Default both."), + top_k: z.number().optional().describe("Top hubs to return. Default 5."), }, - withRequestActivity(async ({ max_depth, max_clusters }) => ({ - content: [{ - type: "text" as const, - text: await semanticNavigate({ rootDir: ROOT_DIR, maxDepth: max_depth, maxClusters: max_clusters }), - }], - })), + withRequestActivity(async ({ query, mode, top_k }) => ({ + content: [{ type: "text" as const, text: await findHub({ rootDir: ROOT_DIR, query, mode, topK: top_k }) }], + }), { useEmbeddingTracker: true, toolName: "find_hub" }), ); server.tool( - "get_feature_hub", - "Obsidian-style feature hub navigator. Hub files are .md files containing [[path/to/file]] wikilinks that act as a Map of Content. " + - "Modes: (1) No args = list all hubs, (2) hub_path or feature_name = show hub with bundled skeletons of all linked files, " + - "(3) show_orphans = find files not linked to any hub. Prevents orphaned code and enables graph-based codebase navigation.", + "init", + "Initialize project context tree and .contextplus workspace directories.", { - hub_path: z.string().optional().describe("Path to a specific hub .md file (relative to root)."), - 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."), + target_path: z.string().optional().describe("Optional project path relative to current root."), + skip_embeddings: z.boolean().optional().describe("Skip initial embeddings for faster init. Default false."), }, - withRequestActivity(async ({ hub_path, feature_name, show_orphans }) => ({ + withRequestActivity(async ({ target_path, skip_embeddings }) => ({ content: [{ type: "text" as const, - text: await getFeatureHub({ - rootDir: ROOT_DIR, - hubPath: hub_path, - featureName: feature_name, - showOrphans: show_orphans, - }), + text: formatInitResult(await initProject({ rootDir: ROOT_DIR, targetPath: target_path, skipEmbeddings: skip_embeddings })), }], - })), + }), { toolName: "init" }), ); server.tool( - "upsert_memory_node", - "Create or update a memory node in the linking graph. Nodes represent concepts, files, symbols, or notes with auto-generated embeddings. " + - "If a node with the same label and type exists, it updates content and increments access count. Returns the node ID for use in create_relation.", + "create_memory", + "Create or update a memory node with automatic embedding updates.", { - type: z.enum(["concept", "file", "symbol", "note"]).describe("Node type: concept (abstract ideas), file (source files), symbol (functions/classes), note (free-form)."), - label: z.string().describe("Short identifier for the node. Used for deduplication with type."), - 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."), + type: z.enum(["concept", "file", "symbol", "note"]).describe("Memory node type."), + label: z.string().describe("Memory node label."), + content: z.string().describe("Memory content."), + metadata: z.record(z.string()).optional().describe("Optional metadata map."), }, withRequestActivity(async ({ type, label, content, metadata }) => ({ - content: [{ - type: "text" as const, - text: await toolUpsertMemoryNode({ rootDir: ROOT_DIR, type, label, content, metadata }), - }], - })), + content: [{ type: "text" as const, text: await toolCreateMemory({ rootDir: ROOT_DIR, type, label, content, metadata }) }], + }), { useEmbeddingTracker: true, toolName: "create_memory" }), ); server.tool( "create_relation", - "Create a typed edge between two memory nodes. Supports relation types: relates_to, depends_on, implements, references, similar_to, contains. " + - "Edges have weights (0-1) that decay over time via e^(-λt). Duplicate edges update weight instead of creating new ones.", + "Create or update relation edge between memory nodes.", { - source_id: z.string().describe("ID of the source memory node."), - target_id: z.string().describe("ID of the target memory node."), - relation: z.enum(["relates_to", "depends_on", "implements", "references", "similar_to", "contains"]).describe("Relationship type between nodes."), - 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."), + source_id: z.string().describe("Source memory node id."), + target_id: z.string().describe("Target memory node id."), + relation: z.enum(["relates_to", "depends_on", "implements", "references", "similar_to", "contains"]).describe("Relation type."), + weight: z.number().optional().describe("Relation weight."), + metadata: z.record(z.string()).optional().describe("Optional relation 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 }), }], - })), + }), { toolName: "create_relation" }), ); server.tool( - "search_memory_graph", - "Search the memory graph by meaning with graph traversal. First finds direct matches via embedding similarity, " + - "then traverses 1st/2nd-degree neighbors to discover linked context. Returns both direct hits and graph-connected neighbors with relevance scores.", + "search_memory", + "Search memory graph with semantic/keyword modes and graph traversal.", { - query: z.string().describe("Natural language query to search the memory graph."), - max_depth: z.number().optional().describe("How many hops to traverse from direct matches. Default: 1."), - top_k: z.number().optional().describe("Number of direct matches to return. Default: 5."), + query: z.string().describe("Memory search query."), + mode: z.enum(["semantic", "keyword", "both"]).optional().describe("Search mode. Default both."), + max_depth: z.number().optional().describe("Traversal depth."), + top_k: z.number().optional().describe("Top matches count."), 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."), + .describe("Optional relation filter for traversal."), }, - withRequestActivity(async ({ query, max_depth, top_k, edge_filter }) => ({ + withRequestActivity(async ({ query, mode, 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 }), + text: await toolSearchMemory({ + rootDir: ROOT_DIR, + query, + mode, + maxDepth: max_depth, + topK: top_k, + edgeFilter: edge_filter, + }), }], - })), + }), { useEmbeddingTracker: true, toolName: "search_memory" }), ); server.tool( - "prune_stale_links", - "Remove stale memory graph edges whose weight has decayed below threshold via e^(-λt) formula. " + - "Also removes orphan nodes with no edges, low access count, and >7 days since last access. Keeps the graph lean.", + "explore_memory", + "Traverse memory graph from a node id.", { - threshold: z.number().optional().describe("Minimum decayed weight to keep an edge. Default: 0.15. Lower = keep more edges."), + start_node_id: z.string().describe("Starting memory node id."), + max_depth: z.number().optional().describe("Traversal depth."), + edge_filter: z.array(z.enum(["relates_to", "depends_on", "implements", "references", "similar_to", "contains"])).optional() + .describe("Optional relation filter for traversal."), }, - withRequestActivity(async ({ threshold }) => ({ + withRequestActivity(async ({ start_node_id, max_depth, edge_filter }) => ({ content: [{ type: "text" as const, - text: await toolPruneStaleLinks({ rootDir: ROOT_DIR, threshold }), + text: await toolExploreMemory({ rootDir: ROOT_DIR, startNodeId: start_node_id, maxDepth: max_depth, edgeFilter: edge_filter }), }], - })), + }), { useEmbeddingTracker: true, toolName: "explore_memory" }), +); + +server.tool( + "update_memory", + "Update existing memory node content and refresh embeddings.", + { + node_id: z.string().describe("Memory node id."), + content: z.string().describe("Updated memory content."), + metadata: z.record(z.string()).optional().describe("Optional metadata map."), + }, + withRequestActivity(async ({ node_id, content, metadata }) => ({ + content: [{ type: "text" as const, text: await toolUpdateMemory({ rootDir: ROOT_DIR, nodeId: node_id, content, metadata }) }], + }), { useEmbeddingTracker: true, toolName: "update_memory" }), ); server.tool( - "add_interlinked_context", - "Bulk-add multiple memory nodes with automatic similarity linking. Computes embeddings for all items, " + - "then creates similarity edges between any pair (new-to-new and new-to-existing) with cosine similarity ≥ 0.72. " + - "Ideal for importing related concepts, files, or notes at once.", + "delete_memory", + "Delete memory nodes or relations from the graph.", + { + node_id: z.string().optional().describe("Node id to delete."), + edge_id: z.string().optional().describe("Edge id to delete."), + source_id: z.string().optional().describe("Source id for relation deletion filter."), + target_id: z.string().optional().describe("Target id for relation deletion filter."), + relation: z.enum(["relates_to", "depends_on", "implements", "references", "similar_to", "contains"]).optional() + .describe("Optional relation filter when source/target are provided."), + }, + withRequestActivity(async ({ node_id, edge_id, source_id, target_id, relation }) => ({ + content: [{ + type: "text" as const, + text: await toolDeleteMemory({ + rootDir: ROOT_DIR, + nodeId: node_id, + edgeId: edge_id, + sourceId: source_id, + targetId: target_id, + relation, + }), + }], + }), { toolName: "delete_memory" }), +); + +server.tool( + "bulk_memory", + "Bulk create memory nodes and optional similarity links.", { items: z.array(z.object({ type: z.enum(["concept", "file", "symbol", "note"]), label: z.string(), content: z.string(), metadata: z.record(z.string()).optional(), - })).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."), + })).describe("Memory nodes to create or update."), + auto_link: z.boolean().optional().describe("Whether to auto-create similarity edges. Default true."), }, withRequestActivity(async ({ items, auto_link }) => ({ - content: [{ - type: "text" as const, - text: await toolAddInterlinkedContext({ rootDir: ROOT_DIR, items, autoLink: auto_link }), - }], - })), + content: [{ type: "text" as const, text: await toolBulkMemory({ rootDir: ROOT_DIR, items, autoLink: auto_link }) }], + }), { useEmbeddingTracker: true, toolName: "bulk_memory" }), ); server.tool( - "retrieve_with_traversal", - "Start from a specific memory node and traverse the graph outward. Returns the starting node plus all reachable neighbors " + - "within the depth limit, scored by edge weight decay and depth penalty. Use after search_memory_graph to explore a specific node's neighborhood.", + "import_acp", + "Import agent sessions from external tools (opencode, copilot, claude, codex).", { - start_node_id: z.string().describe("ID of the memory node to start traversal from."), - max_depth: z.number().optional().describe("Maximum traversal depth from start node. Default: 2."), - 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."), + file_path: z.string().optional().describe("Path to session JSON file to import."), + auto_discover: z.boolean().optional().describe("Auto-discover session files in project. Default false."), }, - 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 }), - }], - })), + withRequestActivity(async ({ file_path, auto_discover }) => ({ + content: [{ type: "text" as const, text: await toolImportACP({ rootDir: ROOT_DIR, filePath: file_path, autoDiscover: auto_discover }) }], + }), { toolName: "import_acp" }), +); + +server.tool( + "list_acp_sessions", + "List imported agent sessions from external tools.", + { source: z.enum(["opencode", "copilot", "claude", "codex"]).optional().describe("Filter by agent source.") }, + withRequestActivity(async ({ source }) => ({ + content: [{ type: "text" as const, text: await toolListACPSessions({ rootDir: ROOT_DIR, source }) }], + }), { toolName: "list_acp_sessions" }), +); + +server.tool( + "list_acp_memories", + "List memory fragments from imported agent sessions.", + { + source: z.enum(["opencode", "copilot", "claude", "codex"]).optional().describe("Filter by agent source."), + type: z.enum(["insight", "decision", "context", "code"]).optional().describe("Filter by memory type."), + }, + withRequestActivity(async ({ source, type }) => ({ + content: [{ type: "text" as const, text: await toolListACPMemories({ rootDir: ROOT_DIR, source, type }) }], + }), { toolName: "list_acp_memories" }), +); + +server.tool( + "search_acp", + "Search across imported agent memories with keyword matching.", + { query: z.string().describe("Search query for ACP memories.") }, + withRequestActivity(async ({ query }) => ({ + content: [{ type: "text" as const, text: await toolSearchACP({ rootDir: ROOT_DIR, query }) }], + }), { toolName: "search_acp" }), +); + +server.tool( + "research", + "Unified search across code, memory, and ACP sources for comprehensive context.", + { + query: z.string().describe("Research query."), + sources: z.array(z.enum(["code", "memory", "acp"])).optional().describe("Sources to search. Default all."), + top_k: z.number().optional().describe("Top results per source. Default 5."), + }, + withRequestActivity(async ({ query, sources, top_k }) => ({ + content: [{ type: "text" as const, text: await research({ rootDir: ROOT_DIR, query, sources, topK: top_k }) }], + }), { useEmbeddingTracker: true, toolName: "research" }), +); + +server.tool( + "discover", + "Find related files, memories, and context for a specific file.", + { + file_path: z.string().describe("File path to find related context for."), + top_k: z.number().optional().describe("Number of related items. Default 10."), + }, + withRequestActivity(async ({ file_path, top_k }) => ({ + content: [{ type: "text" as const, text: await discoverRelated({ rootDir: ROOT_DIR, filePath: file_path, topK: top_k }) }], + }), { useEmbeddingTracker: true, toolName: "discover" }), ); async function main() { @@ -526,14 +598,11 @@ async function main() { } if (args[0] === "skeleton" || args[0] === "tree") { const targetRoot = args[1] ? resolve(args[1]) : process.cwd(); - const tree = await getContextTree({ - rootDir: targetRoot, - includeSymbols: true, - maxTokens: 50000, - }); - process.stdout.write(tree + "\n"); + const tree = await getContextTree({ rootDir: targetRoot, includeSymbols: true, maxTokens: 50_000 }); + process.stdout.write(`${tree}\n`); return; } + await ensureMcpDataDir(ROOT_DIR); const trackerController = createEmbeddingTrackerController({ rootDir: ROOT_DIR, @@ -541,6 +610,7 @@ async function main() { 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); @@ -557,16 +627,14 @@ async function main() { const closeServer = async () => { const closable = server as unknown as { close?: () => Promise | void }; - if (typeof closable.close === "function") { - await closable.close(); - } + if (typeof closable.close === "function") await closable.close(); }; + const closeTransport = async () => { const closable = transport as unknown as { close?: () => Promise | void }; - if (typeof closable.close === "function") { - await closable.close(); - } + if (typeof closable.close === "function") await closable.close(); }; + const shutdown = async (reason: string, exitCode: number = 0) => { if (shuttingDown) return; shuttingDown = true; @@ -583,6 +651,7 @@ async function main() { }); process.exit(exitCode); }; + const requestShutdown = (reason: string, exitCode: number = 0) => { void shutdown(reason, exitCode); }; diff --git a/src/tools/acp-tools.ts b/src/tools/acp-tools.ts new file mode 100644 index 0000000..4337c8b --- /dev/null +++ b/src/tools/acp-tools.ts @@ -0,0 +1,86 @@ +// ACP tools wrapper for MCP server integration +// Provides import, list, and search functions for external agent memories + +import { importSessionFile, listSessions, listMemories, searchACPMemories, discoverSessionFiles, type ACPSession, type ACPMemory } from "../core/acp.js"; + +export interface ImportACPOptions { + rootDir: string; + filePath?: string; + autoDiscover?: boolean; +} + +export async function toolImportACP(options: ImportACPOptions): Promise { + const { rootDir, filePath, autoDiscover } = options; + let imported = { sessions: 0, memories: 0 }; + + if (filePath) { + imported = await importSessionFile(rootDir, filePath); + } else if (autoDiscover) { + const files = await discoverSessionFiles(rootDir); + for (const f of files) { + const result = await importSessionFile(rootDir, f); + imported.sessions += result.sessions; + imported.memories += result.memories; + } + } + + if (imported.sessions === 0 && imported.memories === 0) { + return "No sessions found to import. Provide a file_path or enable auto_discover."; + } + return `Imported ${imported.sessions} session(s) with ${imported.memories} memory fragment(s) to .contextplus/external_memories/`; +} + +export interface ListACPSessionsOptions { + rootDir: string; + source?: string; +} + +export async function toolListACPSessions(options: ListACPSessionsOptions): Promise { + const sessions = await listSessions(options.rootDir); + const filtered = options.source ? sessions.filter((s) => s.source === options.source) : sessions; + + if (filtered.length === 0) return "No external sessions found. Use import_acp to import agent sessions."; + + const lines = filtered.map((s) => { + const date = new Date(s.timestamp).toISOString().split("T")[0]; + return `[${s.source}] ${s.id.slice(0, 12)} | ${date} | ${s.title} (${s.messages.length} msgs)`; + }); + return `External Sessions (${filtered.length}):\n\n${lines.join("\n")}`; +} + +export interface ListACPMemoriesOptions { + rootDir: string; + source?: string; + type?: string; +} + +export async function toolListACPMemories(options: ListACPMemoriesOptions): Promise { + let memories = await listMemories(options.rootDir); + if (options.source) memories = memories.filter((m) => m.source === options.source); + if (options.type) memories = memories.filter((m) => m.type === options.type); + + if (memories.length === 0) return "No external memories found. Use import_acp to import agent sessions."; + + const lines = memories.slice(0, 50).map((m) => { + const preview = m.content.slice(0, 80).replace(/\n/g, " "); + return `[${m.source}/${m.type}] ${m.id.slice(0, 10)} | ${preview}...`; + }); + return `External Memories (${memories.length} total, showing ${lines.length}):\n\n${lines.join("\n")}`; +} + +export interface SearchACPOptions { + rootDir: string; + query: string; +} + +export async function toolSearchACP(options: SearchACPOptions): Promise { + const results = await searchACPMemories(options.rootDir, options.query); + + if (results.length === 0) return `No external memories found matching "${options.query}".`; + + const lines = results.map((m, i) => { + const preview = m.content.slice(0, 120).replace(/\n/g, " "); + return `${i + 1}. [${m.source}/${m.type}] ${preview}...`; + }); + return `ACP Search Results for "${options.query}" (${results.length}):\n\n${lines.join("\n\n")}`; +} diff --git a/src/tools/code-structure.ts b/src/tools/code-structure.ts new file mode 100644 index 0000000..2aedfe5 --- /dev/null +++ b/src/tools/code-structure.ts @@ -0,0 +1,218 @@ +// Detailed AST analysis tool using tree-sitter for code understanding +// Extracts imports, exports, dependencies, and call graph from source files + +import { readFile } from "fs/promises"; +import { resolve, extname } from "path"; +import { parseWithTreeSitter, getGrammarName } from "../core/tree-sitter.js"; + +interface ImportInfo { source: string; names: string[]; line: number; } +interface ExportInfo { name: string; kind: string; line: number; } +interface CallInfo { caller: string; callee: string; line: number; } +interface StructureResult { + file: string; + language: string; + imports: ImportInfo[]; + exports: ExportInfo[]; + calls: CallInfo[]; + symbolCount: number; + lineCount: number; +} + +function extractImports(content: string, lang: string): ImportInfo[] { + const imports: ImportInfo[] = []; + const lines = content.split("\n"); + + if (lang === "typescript" || lang === "tsx" || lang === "javascript") { + const importRe = /import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['"]([^'"]+)['"]/g; + const requireRe = /(?:const|let|var)\s+(?:\{([^}]+)\}|(\w+))\s*=\s*require\(['"]([^'"]+)['"]\)/g; + for (let i = 0; i < lines.length; i++) { + for (const re of [importRe, requireRe]) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(lines[i])) !== null) { + const names = (m[1] ?? m[2] ?? "").split(",").map(n => n.trim()).filter(Boolean); + imports.push({ source: m[3], names, line: i + 1 }); + } + } + } + } else if (lang === "python") { + const fromRe = /from\s+([\w.]+)\s+import\s+(.+)/; + const importRe = /^import\s+([\w.,\s]+)/; + for (let i = 0; i < lines.length; i++) { + const fromMatch = lines[i].match(fromRe); + if (fromMatch) { + const names = fromMatch[2].split(",").map(n => n.trim().split(/\s+as\s+/)[0]).filter(Boolean); + imports.push({ source: fromMatch[1], names, line: i + 1 }); + continue; + } + const impMatch = lines[i].match(importRe); + if (impMatch) { + const modules = impMatch[1].split(",").map(n => n.trim().split(/\s+as\s+/)[0]).filter(Boolean); + imports.push({ source: modules.join(", "), names: modules, line: i + 1 }); + } + } + } else if (lang === "go") { + const singleRe = /import\s+"([^"]+)"/; + const multiStart = /import\s*\(/; + let inMulti = false; + for (let i = 0; i < lines.length; i++) { + if (multiStart.test(lines[i])) { inMulti = true; continue; } + if (inMulti) { + if (lines[i].includes(")")) { inMulti = false; continue; } + const m = lines[i].match(/"([^"]+)"/); + if (m) imports.push({ source: m[1], names: [m[1].split("/").pop() ?? m[1]], line: i + 1 }); + continue; + } + const single = lines[i].match(singleRe); + if (single) imports.push({ source: single[1], names: [single[1].split("/").pop() ?? single[1]], line: i + 1 }); + } + } else if (lang === "rust") { + const useRe = /use\s+([\w:]+)(?:::\{([^}]+)\})?;/g; + for (let i = 0; i < lines.length; i++) { + useRe.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = useRe.exec(lines[i])) !== null) { + const base = m[1]; + const names = m[2] ? m[2].split(",").map(n => n.trim()).filter(Boolean) : [base.split("::").pop() ?? base]; + imports.push({ source: base, names, line: i + 1 }); + } + } + } + return imports; +} + +function extractExports(content: string, lang: string): ExportInfo[] { + const exports: ExportInfo[] = []; + const lines = content.split("\n"); + + if (lang === "typescript" || lang === "tsx" || lang === "javascript") { + const exportRe = /export\s+(?:(default)\s+)?(?:(function|class|const|let|var|interface|type|enum)\s+)?(\w+)?/; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(exportRe); + if (m) exports.push({ name: m[3] ?? (m[1] ? "default" : "unknown"), kind: m[2] ?? "default", line: i + 1 }); + } + } else if (lang === "python") { + const defRe = /^(def|class|async\s+def)\s+(\w+)/; + const allRe = /__all__\s*=\s*\[([^\]]+)\]/; + for (let i = 0; i < lines.length; i++) { + const allMatch = lines[i].match(allRe); + if (allMatch) { + const names = allMatch[1].split(",").map(n => n.trim().replace(/['"]/g, "")).filter(Boolean); + names.forEach(name => exports.push({ name, kind: "export", line: i + 1 })); + continue; + } + const defMatch = lines[i].match(defRe); + if (defMatch && !lines[i].startsWith(" ") && !lines[i].startsWith("\t")) { + exports.push({ name: defMatch[2], kind: defMatch[1].includes("class") ? "class" : "function", line: i + 1 }); + } + } + } else if (lang === "go") { + const pubRe = /^(?:func|type|var|const)\s+([A-Z]\w*)/; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(pubRe); + if (m) exports.push({ name: m[1], kind: lines[i].startsWith("func") ? "function" : "type", line: i + 1 }); + } + } else if (lang === "rust") { + const pubRe = /pub(?:\s*\([^)]*\))?\s+(fn|struct|enum|trait|type|mod|const)\s+(\w+)/; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(pubRe); + if (m) exports.push({ name: m[2], kind: m[1], line: i + 1 }); + } + } + return exports; +} + +function extractCalls(content: string, lang: string): CallInfo[] { + const calls: CallInfo[] = []; + const lines = content.split("\n"); + let currentFn = "module"; + + const fnStartRe = lang === "python" + ? /^(?:def|async\s+def)\s+(\w+)/ + : lang === "go" + ? /^func\s+(?:\([^)]+\)\s+)?(\w+)/ + : lang === "rust" + ? /fn\s+(\w+)/ + : /(?:function|async\s+function)\s+(\w+)|(\w+)\s*[:=]\s*(?:async\s+)?(?:function|\([^)]*\)\s*=>)/; + + const callRe = /(\w+)\s*\(/g; + + for (let i = 0; i < lines.length; i++) { + const fnMatch = lines[i].match(fnStartRe); + if (fnMatch) currentFn = fnMatch[1] ?? fnMatch[2] ?? currentFn; + + callRe.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = callRe.exec(lines[i])) !== null) { + const callee = m[1]; + if (callee && !["if", "for", "while", "switch", "catch", "function", "return"].includes(callee)) { + calls.push({ caller: currentFn, callee, line: i + 1 }); + } + } + } + return calls; +} + +export async function getCodeStructure(opts: { rootDir: string; filePath: string }): Promise { + const fullPath = resolve(opts.rootDir, opts.filePath); + const ext = extname(fullPath); + const lang = getGrammarName(ext); + + if (!lang) return `Unsupported file type: ${ext}. Supported: .ts, .js, .py, .go, .rs, .java, etc.`; + + let content: string; + try { + content = await readFile(fullPath, "utf-8"); + } catch { + return `File not found: ${opts.filePath}`; + } + + const symbols = await parseWithTreeSitter(content, ext) ?? []; + const imports = extractImports(content, lang); + const exports = extractExports(content, lang); + const calls = extractCalls(content, lang); + const lineCount = content.split("\n").length; + + const result: StructureResult = { + file: opts.filePath, + language: lang, + imports, + exports, + calls: calls.slice(0, 50), + symbolCount: symbols.length, + lineCount, + }; + + const lines: string[] = [ + `# Code Structure: ${result.file}`, + `Language: ${result.language} | Lines: ${result.lineCount} | Symbols: ${result.symbolCount}`, + "", + ]; + + if (result.imports.length > 0) { + lines.push(`## Imports (${result.imports.length})`); + result.imports.forEach(i => lines.push(` L${i.line}: ${i.source} -> ${i.names.join(", ")}`)); + lines.push(""); + } + + if (result.exports.length > 0) { + lines.push(`## Exports (${result.exports.length})`); + result.exports.forEach(e => lines.push(` L${e.line}: ${e.kind} ${e.name}`)); + lines.push(""); + } + + if (result.calls.length > 0) { + lines.push(`## Call Graph (top ${result.calls.length})`); + const grouped = new Map(); + result.calls.forEach(c => { + if (!grouped.has(c.caller)) grouped.set(c.caller, []); + grouped.get(c.caller)!.push(c.callee); + }); + grouped.forEach((callees, caller) => { + const unique = [...new Set(callees)]; + lines.push(` ${caller} -> ${unique.join(", ")}`); + }); + } + + return lines.join("\n"); +} diff --git a/src/tools/feature-hub.ts b/src/tools/feature-hub.ts index 7ce6ee2..cb9c5f4 100644 --- a/src/tools/feature-hub.ts +++ b/src/tools/feature-hub.ts @@ -1,12 +1,21 @@ -// Bundled skeleton view of all files linked from a hub map-of-content -// FEATURE: Hierarchical context management via feature hub graph +// Feature hub ranking and retrieval with semantic keyword and hybrid modes +// FEATURE: Hub discovery scoring and full-project fallback context exploration -import { resolve, extname } from "path"; import { readFile, stat } from "fs/promises"; -import { parseHubFile, discoverHubs, findOrphanedFiles, type HubInfo } from "../core/hub.js"; -import { getFileSkeleton } from "./file-skeleton.js"; +import { resolve } from "path"; +import { fetchEmbedding } from "../core/embeddings.js"; +import { discoverHubs, findOrphanedFiles, parseHubFile } from "../core/hub.js"; import { walkDirectory } from "../core/walker.js"; +export type HubSearchMode = "semantic" | "keyword" | "both"; + +export interface FindHubOptions { + rootDir: string; + query?: string; + mode?: HubSearchMode; + topK?: number; +} + export interface FeatureHubOptions { rootDir: string; hubPath?: string; @@ -14,121 +23,173 @@ export interface FeatureHubOptions { showOrphans?: boolean; } -async function fileExists(p: string): Promise { +interface RankedHub { + path: string; + title: string; + links: number; + semanticScore: number; + keywordScore: number; + totalScore: number; + summary: string; +} + +function splitTerms(text: string): string[] { + return text + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9_]+/) + .filter((token) => token.length > 1); +} + +function keywordCoverage(queryTerms: Set, content: string): number { + if (queryTerms.size === 0) return 0; + const terms = new Set(splitTerms(content)); + let matched = 0; + for (const term of queryTerms) if (terms.has(term)) matched += 1; + return matched / queryTerms.size; +} + +function cosine(a: number[], b: number[]): number { + const len = Math.min(a.length, b.length); + if (len === 0) return 0; + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < len; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom === 0 ? 0 : dot / denom; +} + +async function fileExists(path: string): Promise { try { - await stat(p); + await stat(path); return true; } catch { return false; } } -async function findHubByName(rootDir: string, name: string): Promise { - const hubs = await discoverHubs(rootDir); - const lower = name.toLowerCase(); - - const exact = hubs.find((h) => h.toLowerCase() === `${lower}.md` || h.toLowerCase().endsWith(`/${lower}.md`)); - if (exact) return exact; - - const partial = hubs.find((h) => h.toLowerCase().includes(lower)); - return partial ?? null; +async function buildHubSummary(rootDir: string, hubPath: string): Promise<{ title: string; links: number; summary: string }> { + const info = await parseHubFile(resolve(rootDir, hubPath)); + const linkText = info.links.map((link) => `${link.target} ${link.description ?? ""}`).join(" "); + return { title: info.title, links: info.links.length, summary: `${hubPath} ${info.title} ${linkText}`.trim() }; } -export async function getFeatureHub(options: FeatureHubOptions): Promise { - const { rootDir, showOrphans } = options; - const out: string[] = []; - - if (!options.hubPath && !options.featureName && !showOrphans) { - const hubs = await discoverHubs(rootDir); - if (hubs.length === 0) { - return "No hub files found. Create a .md file with [[path/to/file]] links to establish a feature hub."; - } - out.push(`Feature Hubs (${hubs.length}):`); - out.push(""); - for (const h of hubs) { - const info = await parseHubFile(resolve(rootDir, h)); - out.push(` ${h} | ${info.title} | ${info.links.length} links`); - } - return out.join("\n"); +async function getAllHubContext(rootDir: string): Promise { + const hubs = await discoverHubs(rootDir); + if (hubs.length === 0) return "No hub files found."; + const lines: string[] = [`All hubs (${hubs.length}):`, ""]; + for (const hubPath of hubs) { + const info = await parseHubFile(resolve(rootDir, hubPath)); + lines.push(`${hubPath} | ${info.title} | ${info.links.length} links`); + for (const link of info.links) lines.push(` - [[${link.target}${link.description ? `|${link.description}` : ""}]]`); + lines.push(""); } + return lines.join("\n"); +} - if (showOrphans) { - const entries = await walkDirectory({ rootDir, depthLimit: 10 }); - const filePaths = entries.filter((e) => !e.isDirectory).map((e) => e.relativePath); - const orphans = await findOrphanedFiles(rootDir, filePaths); - if (orphans.length === 0) return "No orphaned files. All source files are linked to a hub."; +function scoreByMode(mode: HubSearchMode, semanticScore: number, keywordScore: number): number { + if (mode === "semantic") return semanticScore; + if (mode === "keyword") return keywordScore; + return semanticScore * 0.7 + keywordScore * 0.3; +} - out.push(`Orphaned Files (${orphans.length}):`); - out.push("These files are not linked to any feature hub:"); - out.push(""); - for (const o of orphans) out.push(` ⚠ ${o}`); - out.push(""); - out.push("Fix: Add [[" + orphans[0] + "]] to the appropriate hub .md file."); - return out.join("\n"); - } +async function rankHubs(rootDir: string, query: string, mode: HubSearchMode, topK: number): Promise { + const hubs = await discoverHubs(rootDir); + if (hubs.length === 0) return []; + const summaries = await Promise.all(hubs.map((hubPath) => buildHubSummary(rootDir, hubPath))); + const queryTerms = new Set(splitTerms(query)); + const [queryVector] = await fetchEmbedding(query); + const summaryVectors = await fetchEmbedding(summaries.map((item) => item.summary)); + return summaries + .map((item, index) => { + const semanticScore = Math.max(cosine(queryVector, summaryVectors[index]), 0); + const keywordScore = keywordCoverage(queryTerms, item.summary); + return { + path: hubs[index], + title: item.title, + links: item.links, + semanticScore, + keywordScore, + totalScore: scoreByMode(mode, semanticScore, keywordScore), + summary: item.summary, + }; + }) + .sort((a, b) => b.totalScore - a.totalScore) + .slice(0, Math.max(1, topK)); +} - let hubRelPath = options.hubPath; - if (!hubRelPath && options.featureName) { - hubRelPath = (await findHubByName(rootDir, options.featureName)) ?? undefined; - if (!hubRelPath) { - return `No hub found for feature "${options.featureName}". Available hubs:\n` + - (await discoverHubs(rootDir)).map((h) => ` - ${h}`).join("\n") || " (none)"; - } +export async function findHub(options: FindHubOptions): Promise { + const mode = options.mode ?? "both"; + const topK = options.topK ?? 5; + if (!options.query?.trim()) return getAllHubContext(options.rootDir); + const ranked = await rankHubs(options.rootDir, options.query, mode, topK); + if (ranked.length === 0) return "No hub files found."; + const lines = [`Top ${ranked.length} hubs for "${options.query}" (${mode} mode):`, ""]; + for (let i = 0; i < ranked.length; i++) { + const hub = ranked[i]; + lines.push(`${i + 1}. ${hub.path} (${Math.round(hub.totalScore * 1000) / 10}%)`); + lines.push(` Title: ${hub.title} | Links: ${hub.links}`); + lines.push(` Semantic: ${Math.round(hub.semanticScore * 1000) / 10}% | Keyword: ${Math.round(hub.keywordScore * 1000) / 10}%`); + lines.push(` Status: ${await fileExists(resolve(options.rootDir, hub.path)) ? "ok" : "missing"}`); + lines.push(""); } + return lines.join("\n"); +} - if (!hubRelPath) return "Provide hub_path, feature_name, or set show_orphans=true."; +async function findHubByName(rootDir: string, name: string): Promise { + const hubs = await discoverHubs(rootDir); + const lower = name.toLowerCase(); + const exact = hubs.find((hub) => hub.toLowerCase() === `${lower}.md` || hub.toLowerCase().endsWith(`/${lower}.md`)); + if (exact) return exact; + return hubs.find((hub) => hub.toLowerCase().includes(lower)) ?? null; +} - const hubFull = resolve(rootDir, hubRelPath); - if (!(await fileExists(hubFull))) { - return `Hub file not found: ${hubRelPath}`; +export async function getFeatureHub(options: FeatureHubOptions): Promise { + if (options.showOrphans) { + const entries = await walkDirectory({ rootDir: options.rootDir, depthLimit: 10 }); + const files = entries.filter((entry) => !entry.isDirectory).map((entry) => entry.relativePath); + const orphans = await findOrphanedFiles(options.rootDir, files); + if (orphans.length === 0) return "No orphaned files. All source files are linked to a hub."; + return [`Orphaned Files (${orphans.length}):`, "", ...orphans.map((path) => ` - ${path}`)].join("\n"); } - - const hub = await parseHubFile(hubFull); - - out.push(`Hub: ${hub.title}`); - out.push(`Path: ${hubRelPath}`); - out.push(`Links: ${hub.links.length}`); - if (hub.crossLinks.length > 0) { - out.push(`Cross-links: ${hub.crossLinks.map((c) => c.hubName).join(", ")}`); + if (!options.hubPath && !options.featureName) { + const hubs = await discoverHubs(options.rootDir); + if (hubs.length === 0) return "No hub files found."; + const lines = [`Feature Hubs (${hubs.length}):`, ""]; + for (const hubPath of hubs) { + const info = await parseHubFile(resolve(options.rootDir, hubPath)); + lines.push(` ${hubPath} | ${info.title} | ${info.links.length} links`); + } + return lines.join("\n"); } - out.push(""); - out.push("---"); - out.push(""); - - const resolved: string[] = []; + const hubPath = options.hubPath ?? await findHubByName(options.rootDir, options.featureName ?? ""); + if (!hubPath) return `No hub found for feature "${options.featureName}".`; + const fullPath = resolve(options.rootDir, hubPath); + if (!await fileExists(fullPath)) return `Hub file not found: ${hubPath}`; + const hub = await parseHubFile(fullPath); + const lines = [`Hub: ${hub.title}`, `Path: ${hubPath}`, `Links: ${hub.links.length}`, ""]; const missing: string[] = []; - for (const link of hub.links) { - const linkFull = resolve(rootDir, link.target); - if (await fileExists(linkFull)) { - resolved.push(link.target); - } else { - missing.push(link.target); - } + const targetPath = resolve(options.rootDir, link.target); + const exists = await fileExists(targetPath); + lines.push(`- ${link.target}${link.description ? ` | ${link.description}` : ""}${exists ? "" : " | missing"}`); + if (!exists) missing.push(link.target); } - - for (const filePath of resolved) { - const ext = extname(filePath); - const desc = hub.links.find((l) => l.target === filePath)?.description; - - if (desc) out.push(`## ${filePath} - ${desc}`); - else out.push(`## ${filePath}`); - - try { - const skeleton = await getFileSkeleton({ rootDir, filePath }); - out.push(skeleton); - } catch { - const content = await readFile(resolve(rootDir, filePath), "utf-8"); - out.push(content.split("\n").slice(0, 20).join("\n")); - } - out.push(""); - } - if (missing.length > 0) { - out.push("---"); - out.push(`Missing Links (${missing.length}):`); - for (const m of missing) out.push(` ✗ ${m}`); + lines.push("", `Missing Links (${missing.length})`); + for (const item of missing) lines.push(` - ${item}`); } + return lines.join("\n"); +} - return out.join("\n"); +export async function readHubContent(rootDir: string, hubPath: string): Promise { + const fullPath = resolve(rootDir, hubPath); + if (!await fileExists(fullPath)) return `Hub not found: ${hubPath}`; + return readFile(fullPath, "utf-8"); } diff --git a/src/tools/init.ts b/src/tools/init.ts new file mode 100644 index 0000000..6258b72 --- /dev/null +++ b/src/tools/init.ts @@ -0,0 +1,99 @@ +// Project initializer creates contextplus directories and generates initial embeddings +// FEATURE: One-time workspace bootstrap for hubs embeddings config memories and search + +import { mkdir, writeFile, access } from "fs/promises"; +import { join, resolve } from "path"; +import { getContextTree } from "./context-tree.js"; +import { walkDirectory } from "../core/walker.js"; +import { isSupportedFile } from "../core/parser.js"; +import { refreshFileSearchEmbeddings } from "./semantic-search.js"; +import { refreshIdentifierEmbeddings } from "./semantic-identifiers.js"; + +export interface InitOptions { + rootDir: string; + targetPath?: string; + skipEmbeddings?: boolean; +} + +interface InitResult { + projectRoot: string; + createdPaths: string[]; + contextTreePath: string; + embeddingsGenerated: number; +} + +async function ensureDirectory(path: string): Promise { + await mkdir(path, { recursive: true }); +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function writeIfNotExists(path: string, content: string): Promise { + if (!(await fileExists(path))) { + await writeFile(path, content, "utf-8"); + } +} + +async function writeDefaultFiles(projectRoot: string): Promise { + const files: Record = { + [join(projectRoot, ".contextplus", "hubs", "README.md")]: "# Context+ Hubs\n\nUse markdown files with [[path/to/file]] links to group features.\n", + [join(projectRoot, ".contextplus", "memories", "graph.json")]: '{"nodes":{},"edges":{}}', + [join(projectRoot, ".contextplus", "external_memories", "sessions.json")]: "[]", + [join(projectRoot, ".contextplus", "external_memories", "memories.json")]: "[]", + }; + await Promise.all(Object.entries(files).map(([path, content]) => writeIfNotExists(path, content))); +} + +async function writeContextTreeSnapshot(projectRoot: string): Promise { + const tree = await getContextTree({ rootDir: projectRoot, includeSymbols: true, maxTokens: 50_000 }); + const outputPath = join(projectRoot, ".contextplus", "config", "context-tree.txt"); + await writeFile(outputPath, `${tree}\n`, "utf-8"); + return outputPath; +} + +async function generateInitialEmbeddings(projectRoot: string): Promise { + const entries = await walkDirectory({ rootDir: projectRoot, depthLimit: 0 }); + const files = entries.filter((e) => !e.isDirectory && isSupportedFile(e.path)).map((e) => e.relativePath); + if (files.length === 0) return 0; + const [fileCount, identifierCount] = await Promise.all([ + refreshFileSearchEmbeddings({ rootDir: projectRoot, relativePaths: files }), + refreshIdentifierEmbeddings({ rootDir: projectRoot, relativePaths: files }), + ]); + return fileCount + identifierCount; +} + +export async function initProject(options: InitOptions): Promise { + const projectRoot = options.targetPath ? resolve(options.rootDir, options.targetPath) : resolve(options.rootDir); + const directories = [ + join(projectRoot, ".contextplus"), + join(projectRoot, ".contextplus", "hubs"), + join(projectRoot, ".contextplus", "embeddings"), + join(projectRoot, ".contextplus", "config"), + join(projectRoot, ".contextplus", "memories"), + join(projectRoot, ".contextplus", "memories", "nodes"), + join(projectRoot, ".contextplus", "external_memories"), + ]; + await Promise.all(directories.map((path) => ensureDirectory(path))); + await writeDefaultFiles(projectRoot); + const contextTreePath = await writeContextTreeSnapshot(projectRoot); + const embeddingsGenerated = options.skipEmbeddings ? 0 : await generateInitialEmbeddings(projectRoot); + return { projectRoot, createdPaths: directories, contextTreePath, embeddingsGenerated }; +} + +export function formatInitResult(result: InitResult): string { + const lines = [ + `Initialized Context+ project at ${result.projectRoot}`, + "Created:", + ...result.createdPaths.map((path) => `- ${path}`), + `Context tree snapshot: ${result.contextTreePath}`, + ]; + if (result.embeddingsGenerated > 0) lines.push(`Generated ${result.embeddingsGenerated} initial embeddings`); + return lines.join("\n"); +} diff --git a/src/tools/memory-tools.ts b/src/tools/memory-tools.ts index 925ef3c..de67a84 100644 --- a/src/tools/memory-tools.ts +++ b/src/tools/memory-tools.ts @@ -1,10 +1,21 @@ -// MCP tool wrappers for memory graph operations and interlinked RAG -// FEATURE: Memory Tools — upsert, relate, search, prune, interlink, traverse - -import type { NodeType, RelationType, TraversalResult } from "../core/memory-graph.js"; -import { upsertNode, createRelation, searchGraph, pruneStaleLinks, addInterlinkedContext, retrieveWithTraversal, getGraphStats } from "../core/memory-graph.js"; - -export interface UpsertMemoryNodeOptions { +// Memory tool wrappers exposing graph operations updates and contextual traversal +// FEATURE: Project memory APIs with auto-pruning and typed output formatting + +import type { MemorySearchMode, NodeType, RelationType, TraversalResult } from "../core/memory-graph.js"; +import { + addInterlinkedContext, + createRelation, + deleteMemory, + getGraphStats, + reloadMemoryNodesFromFiles, + retrieveWithTraversal, + searchGraph, + updateMemoryContent, + upsertNode, + pruneStaleLinks, +} from "../core/memory-graph.js"; + +export interface CreateMemoryOptions { rootDir: string; type: NodeType; label: string; @@ -21,122 +32,162 @@ export interface CreateRelationOptions { metadata?: Record; } -export interface SearchMemoryGraphOptions { +export interface SearchMemoryOptions { rootDir: string; query: string; maxDepth?: number; topK?: number; edgeFilter?: RelationType[]; + mode?: MemorySearchMode; } -export interface PruneStaleLinksOptions { - rootDir: string; - threshold?: number; -} - -export interface AddInterlinkedContextOptions { +export interface BulkMemoryOptions { rootDir: string; items: Array<{ type: NodeType; label: string; content: string; metadata?: Record }>; autoLink?: boolean; } -export interface RetrieveWithTraversalOptions { +export interface ExploreMemoryOptions { rootDir: string; startNodeId: string; maxDepth?: number; edgeFilter?: RelationType[]; } -function formatTraversalResult(result: TraversalResult): string { +export interface UpdateMemoryOptions { + rootDir: string; + nodeId: string; + content: string; + metadata?: Record; +} + +export interface DeleteMemoryOptions { + rootDir: string; + nodeId?: string; + edgeId?: string; + sourceId?: string; + targetId?: string; + relation?: RelationType; +} + +function summarizeTraversal(result: TraversalResult): string { return [ - ` [${result.node.type}] ${result.node.label} (depth: ${result.depth}, score: ${result.relevanceScore})`, + ` [${result.node.type}] ${result.node.label} (depth ${result.depth}, score ${result.relevanceScore})`, ` Content: ${result.node.content.slice(0, 120)}${result.node.content.length > 120 ? "..." : ""}`, result.pathRelations.length > 1 ? ` Path: ${result.pathRelations.join(" ")}` : "", - ` ID: ${result.node.id} | Accessed: ${result.node.accessCount}x`, + ` ID: ${result.node.id} | Accessed: ${result.node.accessCount}`, ].filter(Boolean).join("\n"); } -export async function toolUpsertMemoryNode(options: UpsertMemoryNodeOptions): Promise { - const node = await upsertNode(options.rootDir, options.type, options.label, options.content, options.metadata); +export async function toolCreateMemory(options: CreateMemoryOptions): Promise { + const node = await upsertNode(options.rootDir, options.type, options.label, options.content, options.metadata ?? {}); const stats = await getGraphStats(options.rootDir); return [ - `✅ Memory node upserted: ${node.label}`, - ` ID: ${node.id}`, - ` Type: ${node.type}`, - ` Access count: ${node.accessCount}`, - `\nGraph: ${stats.nodes} nodes, ${stats.edges} edges`, + `✅ Memory saved: ${node.label}`, + `ID: ${node.id}`, + `Type: ${node.type}`, + `Access count: ${node.accessCount}`, + `Graph: ${stats.nodes} nodes, ${stats.edges} edges`, ].join("\n"); } export async function toolCreateRelation(options: CreateRelationOptions): Promise { - const edge = await createRelation(options.rootDir, options.sourceId, options.targetId, options.relation, options.weight, options.metadata); - if (!edge) return `❌ Failed: one or both node IDs not found (source: ${options.sourceId}, target: ${options.targetId})`; - + const edge = await createRelation( + options.rootDir, + options.sourceId, + options.targetId, + options.relation, + options.weight, + options.metadata, + ); + if (!edge) return `❌ Failed: node not found for relation (${options.sourceId} -> ${options.targetId})`; const stats = await getGraphStats(options.rootDir); return [ - `✅ Relation created: ${options.sourceId} --[${edge.relation}]--> ${options.targetId}`, - ` Edge ID: ${edge.id}`, - ` Weight: ${edge.weight}`, - `\nGraph: ${stats.nodes} nodes, ${stats.edges} edges`, + `✅ Relation saved: ${options.sourceId} --[${edge.relation}]--> ${options.targetId}`, + `Edge ID: ${edge.id}`, + `Weight: ${edge.weight}`, + `Graph: ${stats.nodes} nodes, ${stats.edges} edges`, ].join("\n"); } -export async function toolSearchMemoryGraph(options: SearchMemoryGraphOptions): Promise { - const result = await searchGraph(options.rootDir, options.query, options.maxDepth, options.topK, options.edgeFilter); - if (result.direct.length === 0) return `No memory nodes found for: "${options.query}"\nGraph has ${result.totalNodes} nodes, ${result.totalEdges} edges.`; - - const sections: string[] = [`Memory Graph Search: "${options.query}"`, `Graph: ${result.totalNodes} nodes, ${result.totalEdges} edges\n`]; - - sections.push("Direct Matches:"); - for (const hit of result.direct) sections.push(formatTraversalResult(hit)); - - if (result.neighbors.length > 0) { - sections.push("\nLinked Neighbors:"); - for (const neighbor of result.neighbors) sections.push(formatTraversalResult(neighbor)); +export async function toolSearchMemory(options: SearchMemoryOptions): Promise { + const result = await searchGraph(options.rootDir, options.query, options.maxDepth, options.topK, { + mode: options.mode, + edgeFilter: options.edgeFilter, + }); + if (result.direct.length === 0) { + return `No memory nodes found for "${options.query}". Graph: ${result.totalNodes} nodes, ${result.totalEdges} edges.`; } - + const sections = [ + `Memory search: "${options.query}"`, + `Graph: ${result.totalNodes} nodes, ${result.totalEdges} edges`, + "", + "Direct matches:", + ...result.direct.map(summarizeTraversal), + ]; + if (result.neighbors.length > 0) sections.push("", "Linked neighbors:", ...result.neighbors.map(summarizeTraversal)); return sections.join("\n"); } -export async function toolPruneStaleLinks(options: PruneStaleLinksOptions): Promise { - const result = await pruneStaleLinks(options.rootDir, options.threshold); - return [ - `🧹 Pruning complete`, - ` Removed: ${result.removed} stale links/orphan nodes`, - ` Remaining edges: ${result.remaining}`, - ].join("\n"); +export async function toolBulkMemory(options: BulkMemoryOptions): Promise { + const result = await addInterlinkedContext(options.rootDir, options.items, options.autoLink ?? true); + const stats = await getGraphStats(options.rootDir); + const lines = [ + `✅ Bulk memory completed: ${result.nodes.length} nodes`, + `Similarity edges created: ${result.edges.length}`, + "", + "Nodes:", + ...result.nodes.map((node) => ` [${node.type}] ${node.label} -> ${node.id}`), + ]; + if (result.edges.length > 0) lines.push("", "Edges:", ...result.edges.map((edge) => ` ${edge.source} --[${edge.relation}:${Math.round(edge.weight * 100) / 100}]--> ${edge.target}`)); + lines.push("", `Graph total: ${stats.nodes} nodes, ${stats.edges} edges`); + return lines.join("\n"); } -export async function toolAddInterlinkedContext(options: AddInterlinkedContextOptions): Promise { - const result = await addInterlinkedContext(options.rootDir, options.items, options.autoLink); - const sections = [ - `✅ Added ${result.nodes.length} interlinked nodes`, - result.edges.length > 0 ? ` Auto-linked: ${result.edges.length} similarity edges (threshold ≥ 0.72)` : " No auto-links above threshold", - "\nNodes:", - ]; +export async function toolExploreMemory(options: ExploreMemoryOptions): Promise { + const result = await retrieveWithTraversal(options.rootDir, options.startNodeId, options.maxDepth, options.edgeFilter); + if (result.length === 0) return `❌ Node not found: ${options.startNodeId}`; + return [`Traversal from ${result[0].node.label} (depth ${options.maxDepth ?? 2})`, "", ...result.map(summarizeTraversal)].join("\n"); +} - for (const node of result.nodes) { - sections.push(` [${node.type}] ${node.label} → ${node.id}`); - } +export async function toolUpdateMemory(options: UpdateMemoryOptions): Promise { + const node = await updateMemoryContent(options.rootDir, options.nodeId, options.content, options.metadata ?? {}); + if (!node) return `Node not found: ${options.nodeId}`; + return [`Memory updated: ${node.label}`, `ID: ${node.id}`, `Type: ${node.type}`, `Access count: ${node.accessCount}`].join("\n"); +} - if (result.edges.length > 0) { - sections.push("\nEdges:"); - for (const edge of result.edges) { - sections.push(` ${edge.source} --[${edge.relation} w:${Math.round(edge.weight * 100) / 100}]--> ${edge.target}`); - } - } +export async function toolDeleteMemory(options: DeleteMemoryOptions): Promise { + const result = await deleteMemory(options.rootDir, { + nodeId: options.nodeId, + edgeId: options.edgeId, + sourceId: options.sourceId, + targetId: options.targetId, + relation: options.relation, + }); + return ["Delete memory completed", `Removed nodes: ${result.removedNodes}`, `Removed edges: ${result.removedEdges}`].join("\n"); +} - const stats = await getGraphStats(options.rootDir); - sections.push(`\nGraph total: ${stats.nodes} nodes, ${stats.edges} edges`); - return sections.join("\n"); +export async function toolRefreshMemoryFromFiles(rootDir: string): Promise { + return `Memory refresh completed. Updated nodes: ${await reloadMemoryNodesFromFiles(rootDir)}`; } -export async function toolRetrieveWithTraversal(options: RetrieveWithTraversalOptions): Promise { - const results = await retrieveWithTraversal(options.rootDir, options.startNodeId, options.maxDepth, options.edgeFilter); - if (results.length === 0) return `❌ Node not found: ${options.startNodeId}`; +export async function toolUpsertMemoryNode(options: CreateMemoryOptions): Promise { + return toolCreateMemory(options); +} - const sections = [`Traversal from: ${results[0].node.label} (depth limit: ${options.maxDepth ?? 2})\n`]; - for (const result of results) sections.push(formatTraversalResult(result)); +export async function toolSearchMemoryGraph(options: SearchMemoryOptions): Promise { + return toolSearchMemory(options); +} - return sections.join("\n"); +export async function toolPruneStaleLinks(options: { rootDir: string; threshold?: number }): Promise { + const result = await pruneStaleLinks(options.rootDir, options.threshold); + return ["🧹 Pruning complete", `Removed: ${result.removed}`, `Remaining edges: ${result.remaining}`].join("\n"); +} + +export async function toolAddInterlinkedContext(options: BulkMemoryOptions): Promise { + return toolBulkMemory(options); +} + +export async function toolRetrieveWithTraversal(options: ExploreMemoryOptions): Promise { + return toolExploreMemory(options); } diff --git a/src/tools/propose-commit.ts b/src/tools/propose-commit.ts index 6596beb..b70349d 100644 --- a/src/tools/propose-commit.ts +++ b/src/tools/propose-commit.ts @@ -1,135 +1,115 @@ -// Code commit gatekeeper enforcing 2-line headers, no inline comments -// Validates abstraction rules and creates shadow restore points before writing +// Checkpoint write guard validates files and creates local undo snapshots +// FEATURE: Safe file writes with validation warnings and checkpoint metadata -import { writeFile, mkdir } from "fs/promises"; -import { resolve, dirname, extname } from "path"; +import { mkdir, writeFile } from "fs/promises"; +import { dirname, extname, resolve } from "path"; import { createRestorePoint } from "../git/shadow.js"; import { isSupportedFile } from "../core/parser.js"; -export interface ProposeCommitOptions { +export interface CheckpointOptions { rootDir: string; filePath: string; newContent: string; } -interface ValidationError { +interface ValidationIssue { rule: string; message: string; line?: number; } -function validateHeader(lines: string[], ext: string): ValidationError[] { - const errors: ValidationError[] = []; - const commentPrefixes: Record = { - ".ts": "//", ".tsx": "//", ".js": "//", ".jsx": "//", - ".rs": "//", ".go": "//", ".c": "//", ".cpp": "//", - ".java": "//", ".cs": "//", ".swift": "//", ".kt": "//", - ".py": "#", ".rb": "#", ".lua": "--", ".zig": "//", - }; - - const prefix = commentPrefixes[ext]; - if (!prefix) return errors; - - const headerLines = lines.slice(0, 5).filter((l) => l.startsWith(prefix)); - if (headerLines.length < 2) { - errors.push({ - rule: "header", - message: `Missing 2-line file header. First 2 lines must be ${prefix} comments explaining the file.`, - }); +const COMMENT_PREFIX_BY_EXT: Record = { + ".ts": "//", + ".tsx": "//", + ".js": "//", + ".jsx": "//", + ".mjs": "//", + ".cjs": "//", + ".rs": "//", + ".go": "//", + ".c": "//", + ".cpp": "//", + ".java": "//", + ".cs": "//", + ".swift": "//", + ".kt": "//", + ".zig": "//", + ".py": "#", + ".rb": "#", + ".lua": "--", +}; + +function validateHeader(lines: string[], ext: string): ValidationIssue[] { + const prefix = COMMENT_PREFIX_BY_EXT[ext]; + if (!prefix) return []; + const issues: ValidationIssue[] = []; + if (!lines[0]?.trim().startsWith(prefix)) { + issues.push({ rule: "header", message: `line 1 must start with ${prefix}` }); } - - if (headerLines.length >= 2 && !headerLines[1].toUpperCase().includes("FEATURE:")) { - errors.push({ - rule: "feature-tag", - message: `Line 2 should include a FEATURE: tag (e.g., "${prefix} FEATURE: Feature Name"). Links files to feature hubs.`, - }); + if (!lines[1]?.trim().startsWith(prefix)) { + issues.push({ rule: "header", message: `line 2 must start with ${prefix}` }); } - - return errors; + return issues; } -function validateNoInlineComments(lines: string[], ext: string): ValidationError[] { - const errors: ValidationError[] = []; - const isScriptLang = [".py", ".rb"].includes(ext); - const commentPrefix = isScriptLang ? "#" : "//"; - +function validateNoInlineComments(lines: string[], ext: string): ValidationIssue[] { + const prefix = COMMENT_PREFIX_BY_EXT[ext]; + if (!prefix) return []; + const issues: ValidationIssue[] = []; for (let i = 2; i < lines.length; i++) { const trimmed = lines[i].trim(); - if (trimmed.startsWith(commentPrefix) && !trimmed.startsWith("#!") && !trimmed.startsWith("#include")) { - errors.push({ - rule: "no-comments", - message: `Unauthorized comment found on line ${i + 1}: ${trimmed.substring(0, 80)}`, - line: i + 1, - }); - } + if (!trimmed.startsWith(prefix)) continue; + if (prefix === "#" && (trimmed.startsWith("#!") || trimmed.startsWith("#include"))) continue; + issues.push({ rule: "no-comments", message: "comment outside header", line: i + 1 }); } - - return errors; + return issues; } -function validateAbstraction(lines: string[]): ValidationError[] { - const errors: ValidationError[] = []; - let nestingDepth = 0; +function validateAbstraction(lines: string[]): ValidationIssue[] { + let nesting = 0; let maxNesting = 0; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - nestingDepth += (line.match(/{/g) || []).length; - nestingDepth -= (line.match(/}/g) || []).length; - maxNesting = Math.max(maxNesting, nestingDepth); - } - - if (maxNesting > 6) { - errors.push({ - rule: "nesting", - message: `Nesting depth of ${maxNesting} detected. Maximum recommended is 3-4 levels. Flatten the structure.`, - }); - } - - if (lines.length > 1000) { - errors.push({ - rule: "file-length", - message: `File is ${lines.length} lines. Maximum recommended is 500-1000. Consider splitting.`, - }); + for (const line of lines) { + nesting += (line.match(/{/g) ?? []).length; + nesting -= (line.match(/}/g) ?? []).length; + if (nesting > maxNesting) maxNesting = nesting; } + const issues: ValidationIssue[] = []; + if (maxNesting > 6) issues.push({ rule: "nesting", message: `nesting depth ${maxNesting} exceeds recommended 4` }); + if (lines.length > 1000) issues.push({ rule: "file-length", message: `file length ${lines.length} exceeds recommended 1000` }); + return issues; +} - return errors; +function formatIssues(issues: ValidationIssue[]): string[] { + return issues.map((issue) => `${issue.line ? `L${issue.line} ` : ""}[${issue.rule}] ${issue.message}`); } -export async function proposeCommit(options: ProposeCommitOptions): Promise { +export async function checkpoint(options: CheckpointOptions): Promise { const fullPath = resolve(options.rootDir, options.filePath); - const ext = extname(fullPath); const lines = options.newContent.split("\n"); - const allErrors: ValidationError[] = []; - - if (isSupportedFile(fullPath)) { - allErrors.push(...validateHeader(lines, ext)); - allErrors.push(...validateNoInlineComments(lines, ext)); - } - allErrors.push(...validateAbstraction(lines)); - - const commentErrors = allErrors.filter((e) => e.rule === "no-comments"); - if (commentErrors.length > 5) { + const ext = extname(fullPath).toLowerCase(); + const issues = [ + ...(isSupportedFile(fullPath) ? validateHeader(lines, ext) : []), + ...(isSupportedFile(fullPath) ? validateNoInlineComments(lines, ext) : []), + ...validateAbstraction(lines), + ]; + const commentViolations = issues.filter((issue) => issue.rule === "no-comments"); + if (commentViolations.length > 5) { return [ - `REJECTED: ${allErrors.length} violations found.\n`, - ...allErrors.slice(0, 10).map((e) => ` ❌ [${e.rule}] ${e.message}`), - allErrors.length > 10 ? ` ... and ${allErrors.length - 10} more violations` : "", - "\nFix all violations and resubmit.", - ].join("\n"); + `REJECTED: ${issues.length} validation issues`, + ...formatIssues(issues.slice(0, 12)), + issues.length > 12 ? `... ${issues.length - 12} more issues` : "", + ].filter(Boolean).join("\n"); } - - const warnings = allErrors.filter((e) => e.rule !== "no-comments" || commentErrors.length <= 5); - - await createRestorePoint(options.rootDir, [options.filePath], `Pre-commit: ${options.filePath}`); + const restorePoint = await createRestorePoint(options.rootDir, [options.filePath], `Checkpoint before writing ${options.filePath}`); await mkdir(dirname(fullPath), { recursive: true }); await writeFile(fullPath, options.newContent, "utf-8"); + return [ + `✅ File saved: ${options.filePath}`, + `Restore point: ${restorePoint.id}`, + ...(issues.length > 0 ? ["⚠ Warnings:", ...formatIssues(issues)] : ["Warnings: none"]), + ].join("\n"); +} - const result = [`✅ File saved: ${options.filePath}`]; - if (warnings.length > 0) { - result.push(`\n⚠ ${warnings.length} warning(s):`); - for (const w of warnings) result.push(` ⚠ [${w.rule}] ${w.message}`); - } - result.push(`\nRestore point created. Use undo tools if needed.`); - - return result.join("\n"); +export async function proposeCommit(options: CheckpointOptions): Promise { + return checkpoint(options); } diff --git a/src/tools/research.ts b/src/tools/research.ts new file mode 100644 index 0000000..fb3447f --- /dev/null +++ b/src/tools/research.ts @@ -0,0 +1,117 @@ +// Unified research tool combining code, memory, and ACP search +// Aggregates results from all context sources for comprehensive queries + +import { searchCodebase } from "./search.js"; +import { toolSearchMemory } from "./memory-tools.js"; +import { toolSearchACP } from "./acp-tools.js"; + +interface ResearchResult { + source: "code" | "memory" | "acp"; + summary: string; + score?: number; +} + +export async function research(opts: { + rootDir: string; + query: string; + sources?: ("code" | "memory" | "acp")[]; + topK?: number; +}): Promise { + const sources = opts.sources ?? ["code", "memory", "acp"]; + const topK = opts.topK ?? 5; + const results: ResearchResult[] = []; + + const tasks: Promise[] = []; + + if (sources.includes("code")) { + tasks.push( + searchCodebase({ + rootDir: opts.rootDir, + query: opts.query, + mode: "both", + topK, + }).then(text => { + results.push({ source: "code", summary: text }); + }).catch(() => { + results.push({ source: "code", summary: "Code search unavailable" }); + }) + ); + } + + if (sources.includes("memory")) { + tasks.push( + toolSearchMemory({ + rootDir: opts.rootDir, + query: opts.query, + mode: "both", + topK, + }).then(text => { + results.push({ source: "memory", summary: text }); + }).catch(() => { + results.push({ source: "memory", summary: "Memory search unavailable" }); + }) + ); + } + + if (sources.includes("acp")) { + tasks.push( + toolSearchACP({ + rootDir: opts.rootDir, + query: opts.query, + }).then(text => { + results.push({ source: "acp", summary: text }); + }).catch(() => { + results.push({ source: "acp", summary: "ACP search unavailable" }); + }) + ); + } + + await Promise.all(tasks); + + const lines: string[] = [ + `# Research Results: "${opts.query}"`, + `Sources: ${sources.join(", ")} | Top K: ${topK}`, + "", + ]; + + for (const r of results) { + lines.push(`## ${r.source.toUpperCase()}`); + lines.push(r.summary); + lines.push(""); + } + + return lines.join("\n"); +} + +export async function discoverRelated(opts: { + rootDir: string; + filePath: string; + topK?: number; +}): Promise { + const topK = opts.topK ?? 10; + + const codeResults = await searchCodebase({ + rootDir: opts.rootDir, + query: `files related to ${opts.filePath}`, + searchType: "file", + mode: "both", + topK, + }); + + const memoryResults = await toolSearchMemory({ + rootDir: opts.rootDir, + query: opts.filePath, + mode: "both", + topK: 5, + }); + + return [ + `# Related Context: ${opts.filePath}`, + "", + "## Related Files", + codeResults, + "", + "## Related Memories", + memoryResults, + ].join("\n"); +} diff --git a/src/tools/search.ts b/src/tools/search.ts new file mode 100644 index 0000000..cb7c890 --- /dev/null +++ b/src/tools/search.ts @@ -0,0 +1,105 @@ +// Unified search tool combining file and identifier semantic capabilities together +// FEATURE: Hybrid search routing with keyword semantic and combined modes + +import { semanticIdentifierSearch } from "./semantic-identifiers.js"; +import { semanticCodeSearch } from "./semantic-search.js"; + +export type SearchType = "identifier" | "file" | "hybrid"; +export type SearchMode = "semantic" | "keyword" | "both"; + +export interface SearchOptions { + rootDir: string; + query: string; + searchType?: SearchType; + mode?: SearchMode; + topK?: number; + topCallsPerIdentifier?: number; + includeKinds?: string[]; + semanticWeight?: number; + keywordWeight?: number; + minSemanticScore?: number; + minKeywordScore?: number; + minCombinedScore?: number; + requireKeywordMatch?: boolean; + requireSemanticMatch?: boolean; +} + +interface WeightConfig { + semanticWeight?: number; + keywordWeight?: number; + requireKeywordMatch?: boolean; + requireSemanticMatch?: boolean; +} + +function resolveModeWeights(options: SearchOptions): WeightConfig { + if (options.mode === "semantic") { + return { + semanticWeight: 1, + keywordWeight: 0, + requireSemanticMatch: true, + requireKeywordMatch: false, + }; + } + if (options.mode === "keyword") { + return { + semanticWeight: 0, + keywordWeight: 1, + requireSemanticMatch: false, + requireKeywordMatch: true, + }; + } + return { + semanticWeight: options.semanticWeight, + keywordWeight: options.keywordWeight, + requireSemanticMatch: options.requireSemanticMatch, + requireKeywordMatch: options.requireKeywordMatch, + }; +} + +async function runFileSearch(options: SearchOptions): Promise { + const weights = resolveModeWeights(options); + return semanticCodeSearch({ + rootDir: options.rootDir, + query: options.query, + topK: options.topK, + semanticWeight: weights.semanticWeight, + keywordWeight: weights.keywordWeight, + minSemanticScore: options.minSemanticScore, + minKeywordScore: options.minKeywordScore, + minCombinedScore: options.minCombinedScore, + requireKeywordMatch: weights.requireKeywordMatch, + requireSemanticMatch: weights.requireSemanticMatch, + }); +} + +async function runIdentifierSearch(options: SearchOptions): Promise { + const weights = resolveModeWeights(options); + return semanticIdentifierSearch({ + rootDir: options.rootDir, + query: options.query, + topK: options.topK, + topCallsPerIdentifier: options.topCallsPerIdentifier, + includeKinds: options.includeKinds, + semanticWeight: weights.semanticWeight, + keywordWeight: weights.keywordWeight, + }); +} + +export async function searchCodebase(options: SearchOptions): Promise { + const searchType = options.searchType ?? "hybrid"; + if (searchType === "file") return runFileSearch(options); + if (searchType === "identifier") return runIdentifierSearch(options); + const [fileResults, identifierResults] = await Promise.all([ + runFileSearch(options), + runIdentifierSearch(options), + ]); + return [ + `Hybrid search results for: "${options.query}"`, + "", + "File results:", + fileResults, + "", + "Identifier results:", + identifierResults, + ].join("\n"); +} diff --git a/src/tools/semantic-identifiers.ts b/src/tools/semantic-identifiers.ts index c694d59..3dcb29e 100644 --- a/src/tools/semantic-identifiers.ts +++ b/src/tools/semantic-identifiers.ts @@ -6,6 +6,7 @@ import { walkDirectory } from "../core/walker.js"; import { analyzeFile, flattenSymbols, isSupportedFile } from "../core/parser.js"; import { fetchEmbedding, + getEmbeddingBatchConcurrency, getEmbeddingBatchSize, loadEmbeddingCache, saveEmbeddingCache, @@ -434,10 +435,24 @@ export async function refreshIdentifierEmbeddings(options: { rootDir: string; re const cache = await loadEmbeddingCache(options.rootDir, IDENTIFIER_CACHE_FILE); const pending: { key: string; hash: string; text: string }[] = []; + const docsPerPath = new Array(uniquePaths.length); + const prepConcurrency = Math.min(uniquePaths.length, Math.max(1, getEmbeddingBatchConcurrency() * 4)); + let nextPathIndex = 0; for (const relativePath of uniquePaths) { removeFileScopedCacheEntries(cache, relativePath); - const docs = await buildIdentifierDocsForFile(options.rootDir, relativePath); + } + + await Promise.all(Array.from({ length: prepConcurrency }, async () => { + while (true) { + const pathIndex = nextPathIndex++; + if (pathIndex >= uniquePaths.length) return; + docsPerPath[pathIndex] = await buildIdentifierDocsForFile(options.rootDir, uniquePaths[pathIndex]); + } + })); + + for (let i = 0; i < uniquePaths.length; i++) { + const docs = docsPerPath[i] ?? []; for (const doc of docs) { const key = `id:${doc.id}`; const hash = hashContent(doc.text); diff --git a/src/tools/semantic-search.ts b/src/tools/semantic-search.ts index c511e81..7c015ce 100644 --- a/src/tools/semantic-search.ts +++ b/src/tools/semantic-search.ts @@ -5,6 +5,7 @@ import { walkDirectory } from "../core/walker.js"; import { analyzeFile, flattenSymbols, isSupportedFile } from "../core/parser.js"; import { fetchEmbedding, + getEmbeddingBatchConcurrency, getEmbeddingBatchSize, loadEmbeddingCache, saveEmbeddingCache, @@ -197,9 +198,22 @@ export async function refreshFileSearchEmbeddings(options: { rootDir: string; re const cache = await loadEmbeddingCache(options.rootDir, SEARCH_CACHE_FILE); const pending: { path: string; hash: string; text: string }[] = []; + const prepared = new Array<{ path: string; doc: SearchDocument | null }>(uniquePaths.length); + const prepConcurrency = Math.min(uniquePaths.length, Math.max(1, getEmbeddingBatchConcurrency() * 4)); + let nextPathIndex = 0; + + await Promise.all(Array.from({ length: prepConcurrency }, async () => { + while (true) { + const pathIndex = nextPathIndex++; + if (pathIndex >= uniquePaths.length) return; + const path = uniquePaths[pathIndex]; + prepared[pathIndex] = { path, doc: await buildSearchDocumentForFile(options.rootDir, path) }; + } + })); - for (const relativePath of uniquePaths) { - const doc = await buildSearchDocumentForFile(options.rootDir, relativePath); + for (const item of prepared) { + const relativePath = item.path; + const doc = item.doc; if (!doc) { delete cache[relativePath]; continue; diff --git a/src/tools/static-analysis.ts b/src/tools/static-analysis.ts index 48bf883..b95458d 100644 --- a/src/tools/static-analysis.ts +++ b/src/tools/static-analysis.ts @@ -1,14 +1,15 @@ -// Static analysis runner using native linters and compilers -// Delegates dead code detection to deterministic tools, not LLM guessing +// Project lint runner plus skill scoring for standards and hygiene +// FEATURE: Deterministic lint checks with per-file and global skill report -import { exec } from "child_process"; -import { stat } from "fs/promises"; -import { resolve, extname } from "path"; +import { execFile } from "child_process"; +import { stat, readFile } from "fs/promises"; +import { extname, resolve } from "path"; import { promisify } from "util"; +import { walkDirectory } from "../core/walker.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); -export interface StaticAnalysisOptions { +export interface LintOptions { rootDir: string; targetPath?: string; } @@ -19,30 +20,84 @@ interface LintResult { exitCode: number; } +interface SkillIssue { + file: string; + line: number; + reason: string; +} + +interface SkillFileScore { + file: string; + score: number; + issues: SkillIssue[]; +} + const LINTER_MAP: Record = { ".ts": { cmd: "npx", args: ["tsc", "--noEmit", "--pretty"] }, ".tsx": { cmd: "npx", args: ["tsc", "--noEmit", "--pretty"] }, - ".js": { cmd: "npx", args: ["eslint", "--no-eslintrc", "--rule", '{"no-unused-vars": "warn"}'] }, + ".js": { cmd: "npx", args: ["eslint", "--no-eslintrc", "--rule", '{"no-unused-vars":"warn"}'] }, ".py": { cmd: "python", args: ["-m", "py_compile"] }, ".rs": { cmd: "cargo", args: ["check", "--message-format=short"] }, ".go": { cmd: "go", args: ["vet"] }, }; +const COMMENT_PREFIX_BY_EXT: Record = { + ".ts": "//", + ".tsx": "//", + ".js": "//", + ".jsx": "//", + ".mjs": "//", + ".cjs": "//", + ".rs": "//", + ".go": "//", + ".java": "//", + ".cs": "//", + ".c": "//", + ".cpp": "//", + ".hpp": "//", + ".h": "//", + ".swift": "//", + ".kt": "//", + ".zig": "//", + ".py": "#", + ".rb": "#", + ".lua": "--", +}; + +function toIntegerOr(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function normalizeLineLength(line: string): number { + return line.replace(/\t/g, " ").length; +} + +function hasDisallowedComment(line: string, prefix: string): boolean { + const trimmed = line.trim(); + if (!trimmed.startsWith(prefix)) return false; + if (prefix === "#" && (trimmed.startsWith("#!") || trimmed.startsWith("#include"))) return false; + return true; +} + +function scoreFromIssues(issues: SkillIssue[]): number { + return Math.max(0, 100 - issues.length * 5); +} + 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 }); - return { tool: cmd, output: (stdout + stderr).trim(), exitCode: 0 }; - } catch (err: any) { - return { tool: cmd, output: (err.stdout ?? "") + (err.stderr ?? ""), exitCode: err.code ?? 1 }; + const { stdout, stderr } = await execFileAsync(cmd, args, { cwd, timeout: 30_000, maxBuffer: 1024 * 512 }); + return { tool: cmd, output: `${stdout}${stderr}`.trim(), exitCode: 0 }; + } catch (error) { + const err = error as { code?: number; stdout?: string; stderr?: string }; + return { tool: cmd, output: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim(), exitCode: err.code ?? 1 }; } } async function detectAvailableLinter(rootDir: string, ext: string): Promise<{ cmd: string; args: string[] } | null> { const config = LINTER_MAP[ext]; if (!config) return null; - - if (ext === ".ts" || ext === ".tsx") { + if ([".ts", ".tsx"].includes(ext)) { try { await stat(resolve(rootDir, "tsconfig.json")); return config; @@ -50,7 +105,6 @@ async function detectAvailableLinter(rootDir: string, ext: string): Promise<{ cm return null; } } - if (ext === ".rs") { try { await stat(resolve(rootDir, "Cargo.toml")); @@ -59,7 +113,6 @@ async function detectAvailableLinter(rootDir: string, ext: string): Promise<{ cm return null; } } - if (ext === ".go") { try { await stat(resolve(rootDir, "go.mod")); @@ -68,38 +121,83 @@ async function detectAvailableLinter(rootDir: string, ext: string): Promise<{ cm return null; } } - return config; } -export async function runStaticAnalysis(options: StaticAnalysisOptions): Promise { +async function evaluateFileSkill(rootDir: string, file: string): Promise { + const absolutePath = resolve(rootDir, file); + const ext = extname(file).toLowerCase(); + const prefix = COMMENT_PREFIX_BY_EXT[ext]; + if (!prefix) return { file, score: 100, issues: [] }; + const lines = (await readFile(absolutePath, "utf-8")).split("\n"); + const issues: SkillIssue[] = []; + if (!lines[0]?.trim().startsWith(prefix)) issues.push({ file, line: 1, reason: "missing first header comment" }); + if (!lines[1]?.trim().startsWith(prefix)) issues.push({ file, line: 2, reason: "missing second header comment" }); + for (let i = 2; i < lines.length; i++) { + if (hasDisallowedComment(lines[i], prefix)) issues.push({ file, line: i + 1, reason: "comment outside top header" }); + if (normalizeLineLength(lines[i]) > 160) issues.push({ file, line: i + 1, reason: "line too long" }); + } + return { file, score: scoreFromIssues(issues), issues }; +} + +async function runSkillChecks(rootDir: string): Promise { + const entries = await walkDirectory({ rootDir, depthLimit: 0 }); + const files = entries.filter((entry) => !entry.isDirectory).map((entry) => entry.relativePath); + const scores = await Promise.all(files.map((file) => evaluateFileSkill(rootDir, file))); + const relevant = scores.filter((item) => item.issues.length > 0 || item.score < 100); + const totalScore = scores.length === 0 + ? 100 + : Math.round((scores.reduce((sum, item) => sum + item.score, 0) / scores.length) * 10) / 10; + const lines: string[] = [ + "Skill Report", + `Project score: ${totalScore}/100`, + `Files checked: ${scores.length}`, + `Files needing fixes: ${relevant.length}`, + ]; + if (relevant.length > 0) { + lines.push("", "Files and lines needing fixes:"); + for (const item of relevant.sort((a, b) => a.file.localeCompare(b.file))) { + lines.push(`- ${item.file} (${item.score}/100)`); + for (const issue of item.issues.slice(0, 20)) lines.push(` L${issue.line}: ${issue.reason}`); + if (item.issues.length > 20) lines.push(` ... ${item.issues.length - 20} more issue(s)`); + } + } + return lines.join("\n"); +} + +export async function runLint(options: LintOptions): Promise { const targetPath = options.targetPath ? resolve(options.rootDir, options.targetPath) : options.rootDir; const ext = extname(targetPath); - if (ext) { const linter = await detectAvailableLinter(options.rootDir, ext); - if (!linter) return `No linter configured for ${ext} files.`; - - const args = [...linter.args]; - if ([".js", ".ts", ".tsx"].includes(ext)) args.push(targetPath); - else if (ext === ".py") args.push(targetPath); - + const skillReport = await runSkillChecks(options.rootDir); + if (!linter) return [`No linter configured for ${ext}.`, "", skillReport].join("\n"); + const args = [...linter.args, ...([".js", ".ts", ".tsx", ".py"].includes(ext) ? [targetPath] : [])]; const result = await runCommand(linter.cmd, args, options.rootDir); - - if (result.exitCode === 0 && !result.output) return "No issues found. Code is clean."; - return `Static analysis (${result.tool}):\n\n${result.output.substring(0, 5000)}`; + const lintBody = result.exitCode === 0 && !result.output + ? "No lint issues found." + : `Lint output (${result.tool}):\n\n${result.output.slice(0, 5000)}`; + return [lintBody, "", skillReport].join("\n"); } - - const results: string[] = []; - for (const [fileExt] of Object.entries(LINTER_MAP)) { - const linter = await detectAvailableLinter(options.rootDir, fileExt); + const lintSections: string[] = []; + for (const extension of Object.keys(LINTER_MAP)) { + const linter = await detectAvailableLinter(options.rootDir, extension); if (!linter) continue; - const result = await runCommand(linter.cmd, linter.args, options.rootDir); - if (result.output) { - results.push(`[${result.tool}] ${fileExt} files:\n${result.output.substring(0, 2000)}`); - } + if (result.output) lintSections.push(`[${result.tool}] ${extension}:\n${result.output.slice(0, 2000)}`); } + const skillReport = await runSkillChecks(options.rootDir); + return [ + lintSections.length > 0 ? lintSections.join("\n\n") : "No linters available or no lint output.", + "", + skillReport, + ].join("\n"); +} + +export async function runStaticAnalysis(options: LintOptions): Promise { + return runLint(options); +} - return results.length > 0 ? results.join("\n\n") : "No linters available or no issues found."; +export function getLintBatchSize(): number { + return Math.max(1, toIntegerOr(process.env.CONTEXTPLUS_LINT_BATCH_SIZE, 4)); } diff --git a/test/_memory_graph_fixtures/.contextplus/memories/graph.json b/test/_memory_graph_fixtures/.contextplus/memories/graph.json new file mode 100644 index 0000000..fa25822 --- /dev/null +++ b/test/_memory_graph_fixtures/.contextplus/memories/graph.json @@ -0,0 +1,406 @@ +{ + "nodes": { + "mn-1774873271691-c9dftt": { + "id": "mn-1774873271691-c9dftt", + "type": "concept", + "label": "Auth Flow", + "contentPath": ".contextplus/memories/nodes/mn-1774873271691-c9dftt-auth-flow.md", + "createdAt": 1774873271691, + "lastAccessed": 1774873271691, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873271735-p32jzu": { + "id": "mn-1774873271735-p32jzu", + "type": "note", + "label": "Test Note", + "contentPath": ".contextplus/memories/nodes/mn-1774873271735-p32jzu-test-note.md", + "createdAt": 1774873271735, + "lastAccessed": 1774873271751, + "accessCount": 2, + "metadata": {} + }, + "mn-1774873271766-5jf11n": { + "id": "mn-1774873271766-5jf11n", + "type": "file", + "label": "config.ts", + "contentPath": ".contextplus/memories/nodes/mn-1774873271766-5jf11n-config-ts.md", + "createdAt": 1774873271766, + "lastAccessed": 1774873271766, + "accessCount": 1, + "metadata": { + "language": "typescript" + } + }, + "mn-1774873271783-ecbfi5": { + "id": "mn-1774873271783-ecbfi5", + "type": "concept", + "label": "Edge A", + "contentPath": ".contextplus/memories/nodes/mn-1774873271783-ecbfi5-edge-a.md", + "createdAt": 1774873271783, + "lastAccessed": 1774873272270, + "accessCount": 2, + "metadata": {} + }, + "mn-1774873271800-2bens7": { + "id": "mn-1774873271800-2bens7", + "type": "concept", + "label": "Edge B", + "contentPath": ".contextplus/memories/nodes/mn-1774873271800-2bens7-edge-b.md", + "createdAt": 1774873271800, + "lastAccessed": 1774873272271, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873271819-7ket78": { + "id": "mn-1774873271819-7ket78", + "type": "symbol", + "label": "Dup A", + "contentPath": ".contextplus/memories/nodes/mn-1774873271819-7ket78-dup-a.md", + "createdAt": 1774873271819, + "lastAccessed": 1774873271889, + "accessCount": 2, + "metadata": {} + }, + "mn-1774873271838-lj6hek": { + "id": "mn-1774873271838-lj6hek", + "type": "symbol", + "label": "Dup B", + "contentPath": ".contextplus/memories/nodes/mn-1774873271838-lj6hek-dup-b.md", + "createdAt": 1774873271838, + "lastAccessed": 1774873271889, + "accessCount": 2, + "metadata": {} + }, + "mn-1774873271856-qgghkw": { + "id": "mn-1774873271856-qgghkw", + "type": "concept", + "label": "Search Target", + "contentPath": ".contextplus/memories/nodes/mn-1774873271856-qgghkw-search-target.md", + "createdAt": 1774873271856, + "lastAccessed": 1774873271889, + "accessCount": 2, + "metadata": {} + }, + "mn-1774873271899-x39qgy": { + "id": "mn-1774873271899-x39qgy", + "type": "concept", + "label": "Nav Root", + "contentPath": ".contextplus/memories/nodes/mn-1774873271899-x39qgy-nav-root.md", + "createdAt": 1774873271899, + "lastAccessed": 1774873271942, + "accessCount": 2, + "metadata": {} + }, + "mn-1774873271916-o28xez": { + "id": "mn-1774873271916-o28xez", + "type": "concept", + "label": "Nav Child", + "contentPath": ".contextplus/memories/nodes/mn-1774873271916-o28xez-nav-child.md", + "createdAt": 1774873271916, + "lastAccessed": 1774873271942, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873271946-um5hw1": { + "id": "mn-1774873271946-um5hw1", + "type": "note", + "label": "Prune A", + "contentPath": ".contextplus/memories/nodes/mn-1774873271946-um5hw1-prune-a.md", + "createdAt": 1774873271946, + "lastAccessed": 1774873272270, + "accessCount": 2, + "metadata": {} + }, + "mn-1774873271964-7bzxfb": { + "id": "mn-1774873271964-7bzxfb", + "type": "note", + "label": "Prune B", + "contentPath": ".contextplus/memories/nodes/mn-1774873271964-7bzxfb-prune-b.md", + "createdAt": 1774873271964, + "lastAccessed": 1774873271964, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873271980-ugpzd6": { + "id": "mn-1774873271980-ugpzd6", + "type": "concept", + "label": "Interlink A", + "contentPath": ".contextplus/memories/nodes/mn-1774873271980-ugpzd6-interlink-a.md", + "createdAt": 1774873271980, + "lastAccessed": 1774873271980, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873271996-u3iqy2": { + "id": "mn-1774873271996-u3iqy2", + "type": "concept", + "label": "Interlink B", + "contentPath": ".contextplus/memories/nodes/mn-1774873271996-u3iqy2-interlink-b.md", + "createdAt": 1774873271996, + "lastAccessed": 1774873271996, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272011-w40kkb": { + "id": "mn-1774873272011-w40kkb", + "type": "note", + "label": "Interlink Note", + "contentPath": ".contextplus/memories/nodes/mn-1774873272011-w40kkb-interlink-note.md", + "createdAt": 1774873272011, + "lastAccessed": 1774873272270, + "accessCount": 2, + "metadata": {} + }, + "mn-1774873272068-p8p2yv": { + "id": "mn-1774873272068-p8p2yv", + "type": "concept", + "label": "No Link A", + "contentPath": ".contextplus/memories/nodes/mn-1774873272068-p8p2yv-no-link-a.md", + "createdAt": 1774873272068, + "lastAccessed": 1774873272068, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272083-7ots4e": { + "id": "mn-1774873272083-7ots4e", + "type": "concept", + "label": "No Link B", + "contentPath": ".contextplus/memories/nodes/mn-1774873272083-7ots4e-no-link-b.md", + "createdAt": 1774873272083, + "lastAccessed": 1774873272083, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272097-7wnw1r": { + "id": "mn-1774873272097-7wnw1r", + "type": "concept", + "label": "Traversal Root", + "contentPath": ".contextplus/memories/nodes/mn-1774873272097-7wnw1r-traversal-root.md", + "createdAt": 1774873272097, + "lastAccessed": 1774873272141, + "accessCount": 2, + "metadata": {} + }, + "mn-1774873272112-dihum0": { + "id": "mn-1774873272112-dihum0", + "type": "symbol", + "label": "Traversal Child 1", + "contentPath": ".contextplus/memories/nodes/mn-1774873272112-dihum0-traversal-child-1.md", + "createdAt": 1774873272112, + "lastAccessed": 1774873272143, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272126-h947j4": { + "id": "mn-1774873272126-h947j4", + "type": "symbol", + "label": "Traversal Child 2", + "contentPath": ".contextplus/memories/nodes/mn-1774873272126-h947j4-traversal-child-2.md", + "createdAt": 1774873272126, + "lastAccessed": 1774873272144, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272147-siuk36": { + "id": "mn-1774873272147-siuk36", + "type": "concept", + "label": "Filter Root", + "contentPath": ".contextplus/memories/nodes/mn-1774873272147-siuk36-filter-root.md", + "createdAt": 1774873272147, + "lastAccessed": 1774873272194, + "accessCount": 2, + "metadata": {} + }, + "mn-1774873272163-2qrxay": { + "id": "mn-1774873272163-2qrxay", + "type": "symbol", + "label": "Filter Dep", + "contentPath": ".contextplus/memories/nodes/mn-1774873272163-2qrxay-filter-dep.md", + "createdAt": 1774873272163, + "lastAccessed": 1774873272195, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272179-nigi6v": { + "id": "mn-1774873272179-nigi6v", + "type": "note", + "label": "Filter Ref", + "contentPath": ".contextplus/memories/nodes/mn-1774873272179-nigi6v-filter-ref.md", + "createdAt": 1774873272179, + "lastAccessed": 1774873272179, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272200-c0nc70": { + "id": "mn-1774873272200-c0nc70", + "type": "concept", + "label": "MCP Test Node", + "contentPath": ".contextplus/memories/nodes/mn-1774873272200-c0nc70-mcp-test-node.md", + "createdAt": 1774873272200, + "lastAccessed": 1774873272200, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272219-gm9lna": { + "id": "mn-1774873272219-gm9lna", + "type": "concept", + "label": "Rel MCP A", + "contentPath": ".contextplus/memories/nodes/mn-1774873272219-gm9lna-rel-mcp-a.md", + "createdAt": 1774873272219, + "lastAccessed": 1774873272219, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272236-7nuatl": { + "id": "mn-1774873272236-7nuatl", + "type": "concept", + "label": "Rel MCP B", + "contentPath": ".contextplus/memories/nodes/mn-1774873272236-7nuatl-rel-mcp-b.md", + "createdAt": 1774873272236, + "lastAccessed": 1774873272236, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272274-y59jwt": { + "id": "mn-1774873272274-y59jwt", + "type": "note", + "label": "Bulk A", + "contentPath": ".contextplus/memories/nodes/mn-1774873272274-y59jwt-bulk-a.md", + "createdAt": 1774873272274, + "lastAccessed": 1774873272274, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272290-baba5h": { + "id": "mn-1774873272290-baba5h", + "type": "note", + "label": "Bulk B", + "contentPath": ".contextplus/memories/nodes/mn-1774873272290-baba5h-bulk-b.md", + "createdAt": 1774873272290, + "lastAccessed": 1774873272290, + "accessCount": 1, + "metadata": {} + }, + "mn-1774873272362-hdcbx9": { + "id": "mn-1774873272362-hdcbx9", + "type": "concept", + "label": "Trav MCP Root", + "contentPath": ".contextplus/memories/nodes/mn-1774873272362-hdcbx9-trav-mcp-root.md", + "createdAt": 1774873272362, + "lastAccessed": 1774873272378, + "accessCount": 2, + "metadata": {} + } + }, + "edges": { + "me-1774873271933-9o411r": { + "id": "me-1774873271933-9o411r", + "source": "mn-1774873271899-x39qgy", + "target": "mn-1774873271916-o28xez", + "relation": "contains", + "weight": 1, + "createdAt": 1774873271933, + "metadata": {} + }, + "me-1774873272141-3ghhys": { + "id": "me-1774873272141-3ghhys", + "source": "mn-1774873272097-7wnw1r", + "target": "mn-1774873272112-dihum0", + "relation": "contains", + "weight": 1, + "createdAt": 1774873272141, + "metadata": {} + }, + "me-1774873272141-9iyty9": { + "id": "me-1774873272141-9iyty9", + "source": "mn-1774873272097-7wnw1r", + "target": "mn-1774873272126-h947j4", + "relation": "contains", + "weight": 1, + "createdAt": 1774873272141, + "metadata": {} + }, + "me-1774873272194-5m7t3n": { + "id": "me-1774873272194-5m7t3n", + "source": "mn-1774873272147-siuk36", + "target": "mn-1774873272163-2qrxay", + "relation": "depends_on", + "weight": 1, + "createdAt": 1774873272194, + "metadata": {} + }, + "me-1774873272194-kughs6": { + "id": "me-1774873272194-kughs6", + "source": "mn-1774873272147-siuk36", + "target": "mn-1774873272179-nigi6v", + "relation": "references", + "weight": 1, + "createdAt": 1774873272194, + "metadata": {} + }, + "me-1774873272250-j7evm5": { + "id": "me-1774873272250-j7evm5", + "source": "mn-1774873272219-gm9lna", + "target": "mn-1774873272236-7nuatl", + "relation": "implements", + "weight": 1, + "createdAt": 1774873272250, + "metadata": {} + }, + "me-1774873272305-xq4tzt": { + "id": "me-1774873272305-xq4tzt", + "source": "mn-1774873272274-y59jwt", + "target": "mn-1774873271735-p32jzu", + "relation": "similar_to", + "weight": 0.7465303747910469, + "createdAt": 1774873272305, + "metadata": {} + }, + "me-1774873272314-rg5tbk": { + "id": "me-1774873272314-rg5tbk", + "source": "mn-1774873272274-y59jwt", + "target": "mn-1774873271946-um5hw1", + "relation": "similar_to", + "weight": 0.7897889984277666, + "createdAt": 1774873272314, + "metadata": {} + }, + "me-1774873272315-dwwc1d": { + "id": "me-1774873272315-dwwc1d", + "source": "mn-1774873272274-y59jwt", + "target": "mn-1774873271964-7bzxfb", + "relation": "similar_to", + "weight": 0.7719257210679106, + "createdAt": 1774873272315, + "metadata": {} + }, + "me-1774873272335-0kkqt1": { + "id": "me-1774873272335-0kkqt1", + "source": "mn-1774873272290-baba5h", + "target": "mn-1774873271783-ecbfi5", + "relation": "similar_to", + "weight": 0.7293958286181793, + "createdAt": 1774873272335, + "metadata": {} + }, + "me-1774873272337-pbpiu3": { + "id": "me-1774873272337-pbpiu3", + "source": "mn-1774873272290-baba5h", + "target": "mn-1774873271819-7ket78", + "relation": "similar_to", + "weight": 0.762993545281919, + "createdAt": 1774873272337, + "metadata": {} + }, + "me-1774873272338-z0bkwk": { + "id": "me-1774873272338-z0bkwk", + "source": "mn-1774873272290-baba5h", + "target": "mn-1774873271838-lj6hek", + "relation": "similar_to", + "weight": 0.7612576370042818, + "createdAt": 1774873272338, + "metadata": {} + } + } +} \ No newline at end of file