feat(embedding,vision): candle embeddings with offline fallback, on-demand clipboard vision capture, and concurrency audit

This commit is contained in:
Riz Ashraf committed 2026-10-07 06:36:09 +01:00
1 parent 5bd8b1587a
commit e4a0fe72df
47 files changed
+5542 -2753

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
/target /target
/target-wsl
cargo-llvm-cov.exe cargo-llvm-cov.exe
llvm-cov.zip llvm-cov.zip
Generated
+1040 -545
View File
File diff suppressed because it is too large. Load diff
+29 -5
View File
@@ -48,6 +48,15 @@ The server consolidates granular single-purpose tools into 12 concise, action-or
## Key Features & Capabilities ## Key Features & Capabilities
### 📜 VCS-Agnostic Code Change Ledger & Recent Deltas (`/api/ledger` & `memory://session/delta`)
Maintains an audit ledger of all file modifications, commit hashes / SVN revisions (`vcs_revision`), repository branches, upstream URLs, and AI change summaries with deterministic length bounds. Fully agnostic across Git, Subversion (SVN), and Mercurial (Hg). Exposed via the Brain Monitor Web UI (`/api/ledger`) and accessible as a passive context resource (`memory://session/delta`).
### 💻 Terminal & Process Telemetry (`/terminal/history`)
Tracks active shell instances (PowerShell, Bash, Nushell, Zsh), command history, working directories, and exit codes in real time. Enables LLMs and the Brain Monitor UI to maintain total visibility over terminal execution contexts.
### 📋 Enriched Task Board, ADRs & Technical Debt Backlog
Supports structured priorities (`low`, `medium`, `high`, `urgent`), assigned subagent roles, automated verification commands, architectural decision alternatives and consequences, and granular technical debt tracking (line ranges, workarounds, effort estimates).
### 🕸️ Multi-Hop Subgraph Expansion (`get_subgraph`) ### 🕸️ Multi-Hop Subgraph Expansion (`get_subgraph`)
Performs a Breadth-First Search (BFS) around a target root entity node up to a specified depth ($N$ hops), returning all connected sub-entities and relationships in a single call. Performs a Breadth-First Search (BFS) around a target root entity node up to a specified depth ($N$ hops), returning all connected sub-entities and relationships in a single call.
@@ -81,11 +90,20 @@ Records structured diagnostic hypotheses, test evidence, and verification status
Computes structured diffs of pinned files and active task IDs between two saved context workspaces. Computes structured diffs of pinned files and active task IDs between two saved context workspaces.
### 🔒 Resilient Storage & Serde Parameter Tolerances ### 🔒 Resilient Storage & Serde Parameter Tolerances
* **Atomic Store Write Locks**: Retains write guards during both in-memory updates and `redb` database serialization to eliminate lock-release TOCTOU race conditions. * **Explicit Fail-Fast Persistence Safety**: Replaced unsafe silent fallback to temporary databases (`/tmp/mcp_store_fallback_*`) with an explicit open retry and fail-fast panic unless `MCP_ALLOW_TMP_FALLBACK=1` is explicitly set, preventing silent data loss.
* **Database Quarantine Protection**: Automatically flags corrupted database keys during deserialization (`is_corrupted = true`) to prevent corrupt states from being overwritten with empty defaults on subsequent writes. * **Store Write Lock Minimization**: Releases write lock immediately following in-memory mutation, serializing JSON payloads under read locks to allow non-blocking concurrent readers.
* **Serde Relation Parameter Aliases**: `create_relations` accepts flexible aliases (`source` / `target` / `relationType` / `type`) so LLMs never encounter parameter validation errors. * **Two-Phase Graph Condensation**: Employs a non-destructive 2-phase commit in `condense_graph_worker` (reading without clearing, inserting into the knowledge graph, and only pruning summarized records by timestamp/content upon verified success).
* **Redb Transient Lock Backoff**: Added exponential backoff retry loop (3 attempts, 150ms delay) on Redb table lock acquisition to gracefully handle concurrent access contention.
* **Offloaded Background Index Rebuilds**: Heavy graph cloning and Tantivy re-indexing in `rebuild_index` are offloaded to `tokio::task::spawn_blocking` to prevent starving Tokio async worker pools.
* **Watch-Based Non-Destructive Shutdown**: Server cancellation signals utilize `tokio::sync::watch` rather than `mpsc` to allow multi-consumer broadcast notifications.
* **Async Mutex Deadlock Elimination**: Converted shared state and Neovim connection locks (`shutdown_tx`, `NVIM_CONN`, `ACTIVE_SOCKET`, `HEADLESS_PROC`) to `tokio::sync::Mutex` to prevent worker thread pool starvation across `.await` points.
* **Telemetry Session Deduplication & Channel Pruning**: Added `LAST_SESSION` in-memory state deduplication for UDP telemetry writes (eliminating disk I/O thrashing) and distinguished WebSocket `TrySendError::Full` backpressure vs `TrySendError::Closed` client pruning.
* **Graph Adjacency Indexing**: Leverages `KnowledgeGraph::build_adjacency_map` to build $O(1)$ lookup adjacency lists for fast BFS shortest path graph queries.
* **Serde Parameter & Enum Ergonomics**: Consolidated tools support flexible aliases (`source`/`from`, `target`/`to`, `relationType`/`relation_type`, `parent_id`/`parentId`, `camelCase`/`PascalCase`/`snake_case`) so LLM tool invocations never fail due to parameter discrepancies.
* **Embedding Input Safeguards**: `generate_embedding_async` returns explicit errors for empty string inputs instead of 0-length fallback vectors, guaranteeing vector dimension compatibility in `cosine_similarity`.
* **Path Traversal Security Guards**: Enforces path canonicalization (`validate_safe_path`) to reject parent relative directory traversal (`..`) across process and file log endpoints.
* **Proactive Watcher Memory Eviction**: Caps file watcher `last_processed` map size at 1,000 items and purges items older than 10 minutes to prevent long-running memory leaks.
* **Pre-cached Embedding Search**: Reuses pre-computed snippet embeddings (`snippet.embedding`), bypassing ONNX inference latency during in-memory semantic searches. * **Pre-cached Embedding Search**: Reuses pre-computed snippet embeddings (`snippet.embedding`), bypassing ONNX inference latency during in-memory semantic searches.
### 📡 Real-time WebSocket Memory Sync (`ws://127.0.0.1:3000/ws`) ### 📡 Real-time WebSocket Memory Sync (`ws://127.0.0.1:3000/ws`)
Broadcasting event pipeline streams real-time graph, task, and activity mutations directly to the Brain Monitor UI. Broadcasting event pipeline streams real-time graph, task, and activity mutations directly to the Brain Monitor UI.
@@ -160,13 +178,19 @@ To view the dashboard, open your browser at:
* **Kanban Board:** Track active Tasks and trigger status transitions directly from the browser. * **Kanban Board:** Track active Tasks and trigger status transitions directly from the browser.
* **Clipboard Inspector:** Review OS-level clipboard image captures via `/api/clipboard/capture`. * **Clipboard Inspector:** Review OS-level clipboard image captures via `/api/clipboard/capture`.
* **Live WebSocket Telemetry:** Real-time UI updates triggered by server state changes. * **Live WebSocket Telemetry:** Real-time UI updates triggered by server state changes.
* **Code Change Ledger:** Chronological audit trail of all code edits, commits, and summaries.
--- ---
## High-Performance Concurrency & Resilience Guarantees ## High-Performance Concurrency & Resilience Guarantees
* **Atomic Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` release write lock guards immediately after applying state mutations, performing JSON serialization under read guards to prevent blocking concurrent readers during state serialization.
* **Async Commit Index Reader Auto-Reload**: `MemoryIndex::commit()` automatically reloads index searchers upon background commit completion, eliminating search latency and stale reader windows.
* **Async Channel Backpressure (`push_async`)**: `Store::modify_async` uses `DbWriteQueue::push_async` with `tx.send(task).await` backpressure to guarantee database write persistence under heavy async write loads without dropping write transactions. * **Async Channel Backpressure (`push_async`)**: `Store::modify_async` uses `DbWriteQueue::push_async` with `tx.send(task).await` backpressure to guarantee database write persistence under heavy async write loads without dropping write transactions.
* **Atomic Search Index Swaps**: `MemoryState::rebuild_index` constructs and populates a new `MemoryIndex` instance in isolation before performing an atomic pointer swap (`*self.search_index.write().await = new_idx`), eliminating transient empty search result windows. * **Atomic Search Index Swaps**: `MemoryState::rebuild_index` constructs and populates a new `MemoryIndex` instance in isolation before performing an atomic pointer swap (`*self.search_index.write().await = new_idx`), eliminating transient empty search result windows.
* **SIMD-Friendly Single-Pass Cosine Similarity**: `cosine_similarity` calculates dot product and Euclidean norm squares in a single linear pass over float vectors, enabling SIMD compiler auto-vectorization. * **Dynamic Character Micro-Batching**: `generate_embeddings_async` dynamically batches text payloads up to a 16,000 character budget inside `spawn_blocking`, eliminating heap spikes during high-volume vector indexing while keeping SIMD pipelines saturated.
* **Bounded Telemetry Detail Records**: Activity and terminal telemetry buffers enforce a 4,000 character truncation ceiling on log details (`ActivityRecord`, `TerminalHistory`) to prevent unbounded RAM growth under heavy RPC traffic.
* **Zero-Allocation Stream Formatting**: Graph condensation loops (`condense_graph_worker`) use `std::fmt::Write` string stream buffers to format subgraphs without allocating temporary string intermediates.
* **SIMD-Friendly Single-Pass Cosine Similarity**: `cosine_similarity` calculates dot product and Euclidean norm squares in a single iterator fold pass over float vectors, enabling SIMD compiler auto-vectorization.
* **Safe Stream Decoding on Log Tails**: Log tail operations (`get_recent_logs`) read raw bytes and decode using lossy UTF-8 conversion (`String::from_utf8_lossy`) to ensure resilience when seeking across multi-byte UTF-8 boundaries. * **Safe Stream Decoding on Log Tails**: Log tail operations (`get_recent_logs`) read raw bytes and decode using lossy UTF-8 conversion (`String::from_utf8_lossy`) to ensure resilience when seeking across multi-byte UTF-8 boundaries.
* **Serde Parameter & Enum Tolerance**: All action enums (`StickyNoteAction`, `SnippetSearchMode`, `Relation`) support case-insensitive variants and field aliases (`source`/`from`, `target`/`to`, `relationType`/`relation_type`) to ensure seamless execution when LLMs pass varied string formatting. * **Serde Parameter & Enum Tolerance**: All action enums (`StickyNoteAction`, `SnippetSearchMode`, `Relation`) support case-insensitive variants and field aliases (`source`/`from`, `target`/`to`, `relationType`/`relation_type`) to ensure seamless execution when LLMs pass varied string formatting.
+16 -5
View File
@@ -10,11 +10,20 @@ description: Strict guidelines for interacting with the mcp-memory server, ensur
- If a port conflict occurs (e.g., a Rust panic `AddrInUse` during a `git push` gatekeeper check), **STOP** and immediately notify the user. Do not attempt to auto-resolve the conflict by killing the existing memory server process. - If a port conflict occurs (e.g., a Rust panic `AddrInUse` during a `git push` gatekeeper check), **STOP** and immediately notify the user. Do not attempt to auto-resolve the conflict by killing the existing memory server process.
## 2. Proactive "Central Brain" Usage ## 2. Proactive "Central Brain" Usage
> [!NOTE] Two-Tier Memory Architecture
> 1. **Tier 1 (Static Markdown)**: Repository rules, coding style, tech constraints, and architectural boundaries belong in static git-tracked markdown (`rules/*.md`, `instructions.md`) and system prompts for 0ms latency and deterministic turn-0 enforcement.
> 2. **Tier 2 (Structured DB & Telemetry)**: The MCP Memory server specializes in high-volume, dynamic data: file modification ledgers (`audit_ledger`), terminal command history, error resolutions (`log_error_fix`), active tasks, and preflight context aggregation.
The MCP Memory server is the central brain. You must be PROACTIVE, not reactive, in using it: The MCP Memory server is the central brain. You must be PROACTIVE, not reactive, in using it:
- **Session Starts & Context Drops**: Always begin by calling `tasks` (action: "list"), `pinned_files` (action: "list"), and `sticky_notes` (action: "read"). - **Session Starts & Context Drops**: Always begin by calling `tasks` (action: "list"), `pinned_files` (action: "list"), and `sticky_notes` (action: "read").
- **Sticky Notes**: Use `sticky_notes` (action: "add") for transient, session-scoped operational constraints (e.g., "Do not touch file X until Y is done"). - **Sticky Notes**: Use `sticky_notes` (action: "add") for transient, session-scoped operational constraints (e.g., "Do not touch file X until Y is done"). Deletion supports both 1-based index (standard) and 0-based index 0.
- **Error Fixes**: The moment a tricky, undocumented, or environment-specific bug is resolved (e.g., Bitbucket markdown rendering quirks, nuanced framework bugs), IMMEDIATELY call `log_error_fix`. Do not wait for the user to ask. - **Error Fixes**: The moment a tricky, undocumented, or environment-specific bug is resolved (e.g., Bitbucket markdown rendering quirks, nuanced framework bugs), IMMEDIATELY call `log_error_fix`. Supply `repo_name`, `error_category`, and `stack_trace` so future searches can perform embedding-based match.
- **Tech Debt**: If you notice an anti-pattern (e.g., nested `if` statements, arrow anti-pattern) but deliberately skip fixing it to focus on a feature, IMMEDIATELY call `log_tech_debt`. - **Tech Debt**: If you notice an anti-pattern (e.g., nested `if` statements, arrow anti-pattern) but deliberately skip fixing it to focus on a feature, IMMEDIATELY call `tech_debt` (action: "log") with `description`, `file_path`, `line_range`, `workaround`, `effort_estimate`, and `severity`.
- **Architectural Decisions (ADR)**: When selecting design patterns, crate choices, or system structure, call `decisions` (action: "log") with `author`, `affected_components`, `alternatives_considered`, `decision`, and `consequence`.
- **Task Management**: When creating tasks, supply `priority` ('low'|'medium'|'high'|'urgent'), `assigned_agent` (e.g. subagent role), `verification_command` (automated test command), and `acceptance_criteria`.
- **VCS & SVN Agnosticism**: Supply `vcs_type` ('git'|'svn'|'hg'), `vcs_revision` (git hash or svn revision like 'r12345'), and `upstream_url` to `log_code_change` and workspace tools.
- **Terminal & Shell Context**: Terminal sessions and commands are automatically tracked in the server. Query `/terminal/history` or recent logs when analyzing shell execution context.
## 3. Delegation ## 3. Delegation
Continue to use the `MemoryLibrarian` subagent to log routine code changes (`log_code_change`) in the background to prevent cluttering the main conversation context. Continue to use the `MemoryLibrarian` subagent to log routine code changes (`log_code_change`) in the background to prevent cluttering the main conversation context.
@@ -23,8 +32,10 @@ Continue to use the `MemoryLibrarian` subagent to log routine code changes (`log
- **Batch Mutating Operations**: When creating or updating multiple graph entities, code snippets, or observations, always batch items into a single tool call array (e.g. `create_entities` with multiple items) to leverage the server's single-pass transaction flush. - **Batch Mutating Operations**: When creating or updating multiple graph entities, code snippets, or observations, always batch items into a single tool call array (e.g. `create_entities` with multiple items) to leverage the server's single-pass transaction flush.
- **High-Signal Tool Confirmations**: Tool call execution responses return structured, informative summaries (entity names, types, created counts, and edge paths). Agents DO NOT need to invoke follow-up `open_nodes` calls purely to confirm successful creation. - **High-Signal Tool Confirmations**: Tool call execution responses return structured, informative summaries (entity names, types, created counts, and edge paths). Agents DO NOT need to invoke follow-up `open_nodes` calls purely to confirm successful creation.
- **Tantivy Search Reader Refresh**: Search queries (`omni_search`, `search_nodes`) automatically reload pending commits prior to executing searches, ensuring immediate visibility of newly created items. - **Tantivy Search Reader Refresh**: Search queries (`omni_search`, `search_nodes`) automatically reload pending commits prior to executing searches, ensuring immediate visibility of newly created items.
- **Bounded Telemetry Buffers**: High-volume telemetry logs (`error_fixes` max 300, `ledger` max 500, `handoff_memos` max 200, `session_summaries` max 200, `agent_signals` max 500) enforce deterministic length caps to guarantee low memory footprints over long sessions.
## 5. Pure Native Rust Invariants & Subprocess Prohibitions ## 5. Pure Native Rust Invariants & Security
- **Zero External Subprocesses**: Native system handlers (`clipboard`, `ast`, `search`, `db`) MUST use pure native Rust crates (`arboard`, `tree-sitter`, `tantivy`, `psycopg`). Subprocess calls to `powershell.exe`, `wl-paste`, `xclip`, or `cmd.exe` are strictly banned in native handlers. - **Zero External Subprocesses**: Native system handlers (`clipboard`, `ast`, `search`, `db`) MUST use pure native Rust crates (`arboard`, `tree-sitter`, `tantivy`, `psycopg`). Subprocess calls to `powershell.exe`, `wl-paste`, `xclip`, or `cmd.exe` are strictly banned in native handlers.
- **Transient Lock Handling**: Transient OS handle collisions (e.g. Win32 OLE `OpenClipboard` locks) must be handled natively with retry loops and backoffs in Rust. - **Transient Lock Handling**: Transient OS handle collisions (e.g. Win32 OLE `OpenClipboard` locks) must be handled natively with retry loops and backoffs in Rust.
- **Atomic Serialization Scope**: All store updates (`Store::modify` / `modify_async`) perform state mutation and JSON serialization inside an atomic write lock scope to guarantee thread-safe `DbWriteQueue` synchronization.
- **Path Traversal Guards**: AST and file handler operations enforce path canonicalization (`validate_safe_path`) to prevent directory traversal vulnerabilities (`..`).
+10 -7
View File
@@ -21,7 +21,7 @@ The Antigravity CLI (`agy`) acts as the MCP Client and automatically manages the
When `agy` starts up, it reads `mcp_config.json`. If it finds `"win-nvim": { "command": "C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-nvim.exe" }`, it will spawn that binary as a background subprocess using standard `stdio`. When `agy` starts up, it reads `mcp_config.json`. If it finds `"win-nvim": { "command": "C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-nvim.exe" }`, it will spawn that binary as a background subprocess using standard `stdio`.
3. **Communication:** 3. **Communication:**
- The LLM requests to use a tool (e.g., `nvim_goto_line`). - The LLM requests to use a consolidated tool (e.g., `nvim_view` with action `goto_line`, or `nvim_execute_lua`).
- The `agy` CLI sends a JSON-RPC request to the `mcp-memory-nvim` subprocess via its `stdin`. - The `agy` CLI sends a JSON-RPC request to the `mcp-memory-nvim` subprocess via its `stdin`.
- The Rust MCP Server receives the request, connects to the Neovim active socket/pipe (`~/.gemini/active_nvim.txt` or `\\.\pipe\nvim.*`), sends the Msgpack-RPC command, and writes the JSON-RPC response back to `stdout`. - The Rust MCP Server receives the request, connects to the Neovim active socket/pipe (`~/.gemini/active_nvim.txt` or `\\.\pipe\nvim.*`), sends the Msgpack-RPC command, and writes the JSON-RPC response back to `stdout`.
- The `agy` CLI reads the response from `stdout` and returns it to the LLM context. - The `agy` CLI reads the response from `stdout` and returns it to the LLM context.
@@ -29,9 +29,12 @@ The Antigravity CLI (`agy`) acts as the MCP Client and automatically manages the
## Capabilities & Requirements ## Capabilities & Requirements
To use this architecture, Neovim must run the `gemini-integration.lua` script to broadcast its active socket to `~/.gemini/active_nvim.txt`. To use this architecture, Neovim must run the `gemini-integration.lua` script to broadcast its active socket to `~/.gemini/active_nvim.txt`.
The MCP server provides core tools including: The MCP server provides 7 cohesive domain tools:
1. **`nvim_goto_line`** 1. **`nvim_buffer`** (actions: `get_active`, `read`, `open`, `create_scratch`, `save`, `reload`, `close`, `list`, `search`)
2. **`nvim_set_diagnostics`** 2. **`nvim_window`** (actions: `list`, `get_active`, `focus`, `split`, `close`)
3. **`nvim_get_active_buffer`** 3. **`nvim_view`** (actions: `goto_line`, `get_cursor`, `get_viewport`, `get_selection`)
4. **`nvim_get_cursor`** 4. **`nvim_diagnostics`** (actions: `get`, `set`, `set_quickfix`)
5. **`nvim_get_visual_selection`** 5. **`nvim_visual`** (actions: `preview`, `extmark`, `highlight`, `clear_highlight`)
6. **`nvim_execute_lua`** (direct Lua execution escape hatch)
7. **`nvim_system`** (actions: `get_info`, `get_messages`, `send_to_terminal`)
+2 -1
View File
@@ -3,5 +3,6 @@
When interacting with the user's Neovim editor (e.g., opening a file, moving the cursor, reading the active buffer, setting diagnostics), you MUST ALWAYS use the MCP tools provided by the `win-nvim` (Neovim) MCP server. When interacting with the user's Neovim editor (e.g., opening a file, moving the cursor, reading the active buffer, setting diagnostics), you MUST ALWAYS use the MCP tools provided by the `win-nvim` (Neovim) MCP server.
- You are strictly forbidden from using bash scripts, `nvim --server`, or other raw terminal/shell hacks to remote-control Neovim. - You are strictly forbidden from using bash scripts, `nvim --server`, or other raw terminal/shell hacks to remote-control Neovim.
- You must rely entirely on the MCP tool registry (e.g., `nvim_goto_line`, `nvim_get_active_buffer`, `nvim_get_cursor`, `nvim_get_visual_selection`, `nvim_set_diagnostics`). - You must rely entirely on the consolidated MCP tool registry (`nvim_buffer`, `nvim_window`, `nvim_view`, `nvim_diagnostics`, `nvim_visual`, `nvim_execute_lua`, `nvim_system`).
- If the tool is eagerly loaded, use it natively as an agent tool. If lazy-loaded, invoke it via the `call_mcp_tool` mechanism. - If the tool is eagerly loaded, use it natively as an agent tool. If lazy-loaded, invoke it via the `call_mcp_tool` mechanism.
+22 -6
View File
@@ -1,10 +1,26 @@
# Rust Guidelines & Quirks # Rust Guidelines & Quirks
## Concurrency & Locking ## Concurrency & Async Locking
- **Lock Poisoning Protection:** NEVER use `.unwrap()` when acquiring a `Mutex` or `RwLock` (e.g., `lock.write().unwrap()`). ALWAYS use `.unwrap_or_else(|e| e.into_inner())` to gracefully recover the underlying data from poisoned locks and prevent cascading panics across threads or async tasks. - **Lock Poisoning & Async Safety:** Use `tokio::sync::RwLock` for state shared across Tokio async tasks (such as active WebSocket clients) to avoid blocking worker threads during broadcast fanouts. For synchronous locks, prefer non-poisoning structures or recover cleanly using `.unwrap_or_else(|e| e.into_inner())`.
- **Panic-Free Architecture:** Avoid `.unwrap()` anywhere in production code. Use `.expect()` for startup initialization errors, and `.unwrap_or_else()`, `.unwrap_or_default()`, or proper `Result` propagation for runtime operations. - **Atomic Store Write Lock Minimization:** Minimize write lock duration by executing JSON serialization under read lock guards, keeping write guards strictly to in-memory state mutations.
- **Two-Phase Graph Condensation:** When performing summarization or condensation across stores (`condense_graph_worker`), implement a two-phase commit: read non-destructively and synthesize observations first, insert into the knowledge graph, and only prune summarized source records by timestamp/content after successful insertion.
- **Watch-Based Non-Destructive Shutdown Channels:** Use `tokio::sync::watch` rather than `tokio::sync::mpsc` for cancellation signaling to allow multiple workers to observe shutdown state without consuming or starving sibling workers.
- **Redb Transient Lock Resilience:** Implement retry loops with exponential backoff on table or database lock contention before aborting or panicking.
- **Safe RPC Request Tracking:** Clean up pending request maps (`PENDING_REQUESTS.remove(&msgid)`) upon timeouts or channel disconnects to prevent orphan memory leaks.
- **Panic-Free Architecture:** Avoid raw `.unwrap()` in production runtime paths. Use `.unwrap_or_default()`, or proper `Result` propagation for runtime operations.
- **Background Worker Task Supervision:** Always track `tokio::task::JoinHandle` handles for background workers (`ttl_sweeper_worker`, `index_committer_worker`, `condense_graph_worker`) and log thread exit or panic events cleanly.
- **Offload Heavy Index Rebuilds:** In `MemoryState::rebuild_index`, offload full graph cloning and Tantivy document re-indexing into `tokio::task::spawn_blocking` to avoid stalling async worker threads.
## Embedding & Memory Optimizations
- **Dynamic Character Batching:** In embedding generation (`generate_embeddings_async`), dynamically chunk batches based on total character size (e.g. 16,384 chars) rather than static item counts to prevent OOM spikes on large files while maximizing SIMD throughput.
- **SIMD Cosine Similarity:** Compute dot-product and norm accumulators in single-pass iterator folds to facilitate vector auto-vectorization across CPU instruction sets (`AVX2`/`NEON`).
- **Memory Truncation Bounds:** Enforce a 4KB ceiling on telemetry detail strings (`ActivityRecord`, `TerminalHistory`) before queuing items into ring buffers to bound heap usage.
## IDE & Rust-Analyzer Quirks ## IDE & Rust-Analyzer Quirks
- **Boolean NOT Operator (E0600):** Avoid using the unary `!` operator on complex boolean expressions inside closures (e.g., `!(a == b && c == d)`). `rust-analyzer` may lose track of the type boundary and falsely report an E0600 error (`cannot apply unary operator ! to type bool`). Rewrite these expressions using De Morgan's laws (e.g., `a != b || c != d`). - **Boolean NOT Operator (E0600):** Avoid using the unary `!` operator on complex boolean expressions inside closures (e.g., `!(a == b && c == d)`). Rewrite these expressions using De Morgan's laws (e.g., `a != b || c != d`).
- **Option::None Shadowing:** If `rust-analyzer` throws a `non_snake_case` warning for `None` during pattern matching (often caused by wildcard imports like `use crate::models::*;` shadowing standard prelude variants), explicitly namespace the variant as `std::option::Option::None` to satisfy the LSP. - **Option::None Shadowing:** If `rust-analyzer` throws a `non_snake_case` warning for `None` during pattern matching (often caused by wildcard imports like `use crate::models::*;`), explicitly namespace as `std::option::Option::None`.
- **Deep Cloning across Thread Boundaries:** When moving large structs (like entities with large text vectors) into a `tokio::task::spawn_blocking` closure for indexing or processing, construct the required primitive payloads or target structs on the main thread *before* the closure to avoid `.clone()`ing the entire massive struct across the `'static` boundary. - **Deep Cloning across Thread Boundaries:** Construct target primitive payloads or target structs on the main thread *before* moving into `tokio::task::spawn_blocking` closures to avoid cloning massive structs across thread boundaries.
## Windows MSVC & Test Concurrency
- **ONNX Runtime / Fastembed Concurrency Resilience:** ONNX Runtime (`ort.dll` via `fastembed`) model initialization is strictly managed via a thread-safe singleton (`OnceLock<Mutex<TextEmbedding>>`) behind a process-wide `INIT_MUTEX`. The historical `0xc0000374 STATUS_HEAP_CORRUPTION` crash under uncoordinated C-ABI initializations is fully resolved. Full parallel test execution (`cargo test --workspace` or `cargo nextest run --workspace`) across all CPU cores without `--test-threads=1` is safe, recommended, and standard across all platforms.
+61 -17
View File
@@ -21,20 +21,25 @@ To prevent graph fragmentation and ensure seamless LLM context retrieval:
## 2. Consolidated Smart Tools Architecture ## 2. Consolidated Smart Tools Architecture
> [!IMPORTANT] The Two-Tier Context Paradigm
> * **Tier 1 (Static Markdown)**: Repository rules, constraints, architectural patterns, and developer preferences are maintained directly in static git-tracked files (`rules/*.md`, `instructions.md`) and system prompts. This guarantees 0ms turn-0 availability without relying on proactive agent tool retrieval.
> * **Tier 2 (Telemetry & Ephemeral DB)**: High-frequency operational history—the Code Change Ledger (`audit_ledger`), terminal command history, compiler error fixes (`log_error_fix`), active tasks, and preflight context—is handled by the MCP Memory Server and surfaced via the Brain Monitor Web UI and MCP resources.
The server consolidates granular single-purpose tools into domain-named smart tools. Always prefer the consolidated tools over legacy aliases: The server consolidates granular single-purpose tools into domain-named smart tools. Always prefer the consolidated tools over legacy aliases:
* **`tasks`**: Complete task lifecycle management. * **`tasks`**: Complete task lifecycle management.
- `action: "add"`: Create a new task (requires `title`, optional `description`, `git_branch`, `parent_id`, `dependencies`). - `action: "add"`: Create a new task (requires `title`, optional `description`, `git_branch`, `repo_name`, `priority: "low" | "medium" | "high" | "urgent"`, `assigned_agent`, `verification_command`, `parent_id`, `dependencies`).
- `action: "update"`: Update task status (requires `id`, `status: "pending" | "completed" | "cancelled"`). - `action: "update"`: Update task status (requires `id`, `status: "pending" | "completed" | "cancelled"`).
- `action: "delete"`: Delete task and child tasks (requires `id`). - `action: "delete"`: Delete task and child tasks (requires `id`).
- `action: "list"`: List active tasks (optional `git_branch`, `summary_level: "compact" | "detailed" | "full"`, `max_tokens`). - `action: "list"`: List active tasks (optional `git_branch`, `summary_level: "compact" | "detailed" | "full"`, `max_tokens`).
- `action: "set_criteria"`: Set acceptance criteria (requires `id`, `criteria: Vec<String>`). - `action: "set_criteria"`: Set acceptance criteria (requires `id`, `criteria: Vec<String>`).
- `action: "verify"`: Verify criteria met (requires `id`). - `action: "verify"`: Verify criteria met (requires `id`, optional `proof`).
* **`milestones`**: Milestone tracking. * **`milestones`**: Milestone tracking.
- `action: "add"`: Create milestone (requires `title`). - `action: "add"`: Create milestone (requires `title`, optional `namespace`, `target_date`, `description`, `deliverables: Vec<String>`, `repo_name`).
- `action: "update"`: Update milestone status (requires `id`, `status`). - `action: "update"`: Update milestone status (requires `id`, `status: "active" | "completed" | "cancelled"`).
- `action: "list"`: List milestones. - `action: "list"`: List milestones (optional `namespace`).
* **`sticky_notes`**: Ephemeral scratchpad notes with TTL. * **`sticky_notes`**: Ephemeral scratchpad notes with TTL.
- `action: "add"`: Add note (requires `content`, optional `ttl_seconds`, `session_only`). - `action: "add"`: Add note (requires `content`, optional `ttl_seconds`, `session_only`).
@@ -43,48 +48,48 @@ The server consolidates granular single-purpose tools into domain-named smart to
- `action: "clear"`: Clear all sticky notes. - `action: "clear"`: Clear all sticky notes.
* **`handoff_memos`**: Session handoff notes for future agents. * **`handoff_memos`**: Session handoff notes for future agents.
- `action: "leave"`: Leave a memo (requires `content`). - `action: "leave"`: Leave a memo (requires `content`, optional `vcs_revision`, `repo_name`, `git_branch`, `blockers: Vec<String>`, `action_items: Vec<String>`, `expires_at`).
- `action: "read"`: Read active handoff memos. - `action: "read"`: Read active handoff memos.
- `action: "clear"`: Clear memos. - `action: "clear"`: Clear memos.
* **`pinned_files`**: Focus file working set. * **`pinned_files`**: Focus file working set.
- `action: "pin"`: Pin file to focus set (requires `path`). - `action: "pin"`: Pin file to focus set (requires `path`, optional `pin_reason`, `symbol_or_line`, `repo_name`, `git_branch`).
- `action: "unpin"`: Unpin file from focus set (requires `path`). - `action: "unpin"`: Unpin file from focus set (requires `path`).
- `action: "list"`: List pinned files. - `action: "list"`: List pinned files (optional `namespace`).
* **`context_workspaces`**: Workspace context state snapshots. * **`context_workspaces`**: Workspace context state snapshots.
- `action: "save"`: Save context workspace (requires `name`). - `action: "save"`: Save context workspace (requires `name`, optional `pinned_files`, `active_task_ids`, `description`, `git_branch`, `vcs_revision`, `repo_name`).
- `action: "load"`: Restore saved context workspace (requires `name`). - `action: "load"`: Restore saved context workspace (requires `name`).
- `action: "list"`: List saved context workspaces. - `action: "list"`: List saved context workspaces.
- `action: "delete"`: Delete saved context workspace (requires `name`). - `action: "delete"`: Delete saved context workspace (requires `name`).
- `action: "diff"`: Compare two saved context workspaces (requires `name`, `other_name`). - `action: "diff"`: Compare two saved context workspaces (requires `name`, `other_name`).
* **`pr_checklist`**: Pre-commit and PR checklist. * **`pr_checklist`**: Pre-commit and PR checklist.
- `action: "add"`: Add checklist item (requires `description`). - `action: "add"`: Add checklist item (requires `description`, optional `category: "Security" | "Testing" | "Formatting" | ...`, `automated_check`, `is_checked`, `repo_name`).
- `action: "get"`: Get PR checklist items. - `action: "get"`: Get PR checklist items.
- `action: "clear"`: Clear PR checklist. - `action: "clear"`: Clear PR checklist.
* **`snippets`**: Reusable code snippet vault. * **`snippets`**: Reusable code snippet vault.
- `action: "store"`: Store snippet (requires `query` as name, optional `language`, `code`, `description`, `tags`). - `action: "store"`: Store snippet (requires `query` as name, optional `language`, `code`, `description`, `tags`, `origin_file`, `line_range`, `repo_name`).
- `action: "search"`: Search snippet vault (optional `query`, `tags`, `hybrid: true`). - `action: "search"`: Search snippet vault (optional `query`, `tags`, `hybrid: true`).
- `action: "delete"`: Delete snippet (requires `id`). - `action: "delete"`: Delete snippet (requires `id`).
- `action: "tag"`: Attach classification tags (requires `id`, `tags: Vec<String>`). - `action: "tag"`: Attach classification tags (requires `id`, `tags: Vec<String>`).
* **`decisions`**: Architectural Decision Records (ADRs). * **`decisions`**: Architectural Decision Records (ADRs).
- `action: "log"`: Log ADR (requires `title`, optional `status`, `context`, `decision`, `consequences`). - `action: "log"`: Log ADR (requires `title`, optional `status: "accepted" | "proposed" | "deprecated" | "superseded"`, `context`, `decision`, `consequence`, `author`, `affected_components: Vec<String>`, `alternatives_considered: Vec<String>`, `supersedes`, `repo_name`).
- `action: "query"`: Query ADRs (optional `query`). - `action: "query"`: Query ADRs (optional `query`).
- `action: "delete"`: Delete ADR (requires `id`). - `action: "delete"`: Delete ADR (requires `id`).
* **`tech_debt`**: Engineering debt backlog. * **`tech_debt`**: Engineering debt backlog.
- `action: "log"`: Log debt item (requires `description`, optional `ideal_solution`, `git_commit`, `git_branch`, `symbol_references`, `line_range`). - `action: "log"`: Log debt item (requires `description`, optional `ideal_solution`, `file_path`, `line_range`, `workaround`, `effort_estimate`, `severity: "low" | "medium" | "high" | "critical"`, `git_commit`, `git_branch`, `symbol_references`, `repo_name`).
- `action: "resolve"`: Resolve debt item (requires `id`). - `action: "resolve"`: Resolve debt item (requires `id`).
- `action: "list"`: List debt items (optional `include_resolved`). - `action: "list"`: List debt items (optional `include_resolved`).
* **`environment`**: Infrastructure and requirements tracking. * **`environment`**: Infrastructure and requirements tracking.
- `action: "update_fingerprint"`: Update tool versions. - `action: "update_fingerprint"`: Update tool versions (requires `tool_versions: Map<String, String>`, optional `repo_name`).
- `action: "read_fingerprint"`: Read tool versions fingerprint. - `action: "read_fingerprint"`: Read tool versions fingerprint.
- `action: "log_requirement"`: Log environment variable requirement (requires `key`). - `action: "log_requirement"`: Log environment variable requirement (requires `key`, `description`, `is_secret`, optional `default_value`, `validation_regex`, `repo_name`).
- `action: "register"`: Register target environment (requires `name`). - `action: "register"`: Register target environment (requires `name`, `url`, optional `description`, `requires_vpn`, `env_type: "dev" | "staging" | "qa" | "prod"`, `healthcheck_endpoint`, `ssh_host`, `repo_name`).
- `action: "get_details"`: Read full environment details. - `action: "get_details"`: Read full environment details.
* **`clipboard`**: OS Clipboard management. * **`clipboard`**: OS Clipboard management.
@@ -94,6 +99,26 @@ The server consolidates granular single-purpose tools into domain-named smart to
--- ---
## 3. VCS & SVN Agnosticism & Multi-Repo Provenance
To support diverse enterprise repositories (Git, Subversion / SVN, Mercurial / Hg, Monorepos):
* **`vcs_type`**: Designates the VCS engine (`"git"`, `"svn"`, `"hg"`, `"perforce"`, or `"none"`).
* **`vcs_revision`**: Agnostic commit hash or SVN revision identifier (e.g., `"r12458"`, `"3e4f7a9"`).
* **`upstream_url`**: Canonical remote repository URL (e.g. `https://svn.corp/repo/trunk`, `git@bitbucket.org:org/repo.git`).
* **`repo_name`**: Logical project or repository identifier allowing multiple repositories to share or partition memory namespaces cleanly without collision.
* **Audit Ledger (`log_code_change`)**: Enriched with `vcs_type`, `vcs_revision`, `upstream_url`, `author`, `diff_summary`, and extensible `metadata: HashMap<String, String>`.
---
## 4. Terminal & Process Telemetry
The server ingests and tracks active terminal commands and sessions:
* **Active Terminals**: Tracks PIDs, shell interpreters (`pwsh`, `bash`, `nu`, `zsh`), current working directories (`cwd`), command exit codes, and timestamps.
* **Terminal History Endpoint**: `/terminal/history` exposes recent shell commands and output streams to dashboard and LLMs to prevent lost shell context.
* **Quality Gate Enforcement**: `GateRecord` captures pre-flight and pre-push validation passes with `gate_type`, `enforcer`, `status`, `validation_log`, and `repo_name`.
---
## 4. High-Signal Tool Responses & Performance Guidelines ## 4. High-Signal Tool Responses & Performance Guidelines
To optimize context usage, response times, and LLM reasoning efficiency: To optimize context usage, response times, and LLM reasoning efficiency:
@@ -157,8 +182,16 @@ To maintain maximum security, speed, and cross-platform reliability:
--- ---
## 10. High-Performance Concurrency & Resilience Guarantees ## 10. High-Performance Concurrency & Resilience Guarantees
* **Explicit Fail-Fast Persistence Safety**: Replaced silent fallback to temporary databases (`/tmp/mcp_store_fallback_*`) with an explicit open retry and fail-fast panic unless `MCP_ALLOW_TMP_FALLBACK=1` is explicitly set, preventing silent data loss.
* **Async Mutex Deadlock Elimination**: Converted shared state and Neovim connection locks (`shutdown_tx`, `NVIM_CONN`, `ACTIVE_SOCKET`, `HEADLESS_PROC`) to `tokio::sync::Mutex` to prevent worker thread pool starvation across `.await` points.
* **Telemetry Session Deduplication & Channel Pruning**: Added `LAST_SESSION` in-memory state deduplication for UDP telemetry writes (eliminating disk I/O thrashing) and distinguished WebSocket `TrySendError::Full` backpressure vs `TrySendError::Closed` client pruning.
* **Graph Adjacency Indexing**: Leverages `KnowledgeGraph::build_adjacency_map` to build $O(1)$ lookup adjacency lists for fast BFS shortest path graph queries.
* **Atomic Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` release write guards immediately after in-memory state mutations, serializing JSON payloads under read locks to allow non-blocking concurrent readers.
* **Async Commit Index Reader Auto-Reload**: `MemoryIndex::commit()` automatically triggers `reader.reload()` upon completing background commits, guaranteeing immediate visibility of newly indexed document terms.
* **Zero-Allocation NDJSON Reader**: `mcp-stdio` reclaims line buffers via `std::mem::take` and in-place trimming to eliminate heap allocations during high-frequency NDJSON message parsing. * **Zero-Allocation NDJSON Reader**: `mcp-stdio` reclaims line buffers via `std::mem::take` and in-place trimming to eliminate heap allocations during high-frequency NDJSON message parsing.
* **Micro-Batched Fastembed Inference**: `generate_embeddings_async` chunks text arrays into 32-item micro-batches inside `spawn_blocking`, eliminating RAM/CPU spikes during batch indexing. * **Dynamic Character Micro-Batched Fastembed Inference**: `generate_embeddings_async` dynamically batches text arrays using a 16,000 character budget ceiling inside `spawn_blocking`, preventing heap spikes during vector indexing while keeping ONNX SIMD execution saturated.
* **Bounded Telemetry Detail Records**: Activity and terminal telemetry buffers enforce a 4,000 character truncation ceiling on log details (`ActivityRecord`, `TerminalHistory`) to prevent unbounded RAM growth under high RPC throughput.
* **Zero-Allocation Stream Formatting**: Graph condensation loops (`condense_graph_worker`) format node/relation subgraphs into stream buffers via `std::fmt::Write`, eliminating intermediate String allocation overhead.
* **Single-Handle OS Clipboard Retries & Image Downsampling**: `WriteClipboardHandler` initializes OS clipboard handles once per operation and downsamples images exceeding $2048 \times 2048$ resolution before writing raw RGBA bytes. * **Single-Handle OS Clipboard Retries & Image Downsampling**: `WriteClipboardHandler` initializes OS clipboard handles once per operation and downsamples images exceeding $2048 \times 2048$ resolution before writing raw RGBA bytes.
* **Zero Transaction Drop Persistence Guarantee**: `Store::modify` automatically spawns an async task to execute `push_async` with channel backpressure if `push` encounters queue saturation, ensuring zero data loss under spike write loads. * **Zero Transaction Drop Persistence Guarantee**: `Store::modify` automatically spawns an async task to execute `push_async` with channel backpressure if `push` encounters queue saturation, ensuring zero data loss under spike write loads.
* **Non-Blocking Read Lock Sticky Notes**: `StickyNotesHandler` (`action = "read"`) queries notes using shared read locks, executing write pruning only when expired items exist. * **Non-Blocking Read Lock Sticky Notes**: `StickyNotesHandler` (`action = "read"`) queries notes using shared read locks, executing write pruning only when expired items exist.
@@ -183,5 +216,16 @@ To maintain maximum security, speed, and cross-platform reliability:
* **Directory Tree Depth Safeguard**: `ReadDirectoryArchitectureHandler` caps directory recursion at depth 10 to prevent stack overflow on deep or cyclic directory structures. * **Directory Tree Depth Safeguard**: `ReadDirectoryArchitectureHandler` caps directory recursion at depth 10 to prevent stack overflow on deep or cyclic directory structures.
* **Deterministic Total-Order Score Ranking**: `OmniSearchHandler` uses `f64::total_cmp` for Reciprocal Rank Fusion (RRF) score sorting, guaranteeing deterministic NaN-safe search result ordering. * **Deterministic Total-Order Score Ranking**: `OmniSearchHandler` uses `f64::total_cmp` for Reciprocal Rank Fusion (RRF) score sorting, guaranteeing deterministic NaN-safe search result ordering.
* **RPC Timeout Memory Hygiene**: `nvim-core` maintains request hygiene by removing pending request entries from static RPC maps upon timeout or channel drop, eliminating orphan memory leaks. * **RPC Timeout Memory Hygiene**: `nvim-core` maintains request hygiene by removing pending request entries from static RPC maps upon timeout or channel drop, eliminating orphan memory leaks.
* **Embedding Input Safeguard**: `generate_embedding_async` returns explicit errors for empty/0-length text inputs instead of returning empty vectors, preventing downstream vector dimension mismatches during cosine similarity calculations.
* **Path Traversal Security Guards**: `validate_safe_path` enforces path canonicalization and rejects relative parent traversal components (`..`) across file and process log handlers (`GetRecentLogsTool`, `WatchProcessLogsTool`).
* **Watcher Map Memory Eviction**: Proactive daemon file watcher in `watcher.rs` caps `last_processed` map size at 1,000 entries and purges entries older than 10 minutes to prevent monotonic memory leakage.
* **Comprehensive Serde Casing Aliases**: All 11 consolidated tool action enums (`TaskAction`, `MilestoneAction`, `PinnedFileAction`, `ContextWorkspaceAction`, `PrChecklistAction`, `SnippetAction`, `DecisionAction`, `TechDebtAction`, `EnvAction`, `ClipboardAction`, `HandoffMemoAction`) include serde alias attributes supporting `snake_case`, `camelCase`, `PascalCase`, and uppercase variants for maximum LLM casing resilience.
* **Two-Phase Graph Condensation**: `condense_graph_worker` uses a 2-phase commit (non-destructive `read_with` -> graph insert -> prune by timestamp/content) to prevent data loss if summarization or graph insertion fails.
* **Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` unblock concurrent readers during JSON serialization by releasing the write lock immediately after mutating memory state.
* **Redb Database Lock Retry Backoff**: `init_db` retries transient Redb lock contention with exponential backoff (3 attempts, 150ms delay) before falling back.
* **Offloaded Background Index Rebuilds**: `MemoryState::rebuild_index` offloads graph snapshot cloning and Tantivy document re-indexing into `tokio::task::spawn_blocking` to avoid stalling async event loops.
* **Broadcast Watch-Based Shutdown Channels**: Background workers utilize `tokio::sync::watch` for broadcast shutdown notifications without consuming cancellation signals.
* **Consolidated Neovim Tool Suite (v2)**: The Neovim server exposes 7 consolidated domain tools (`nvim_buffer`, `nvim_window`, `nvim_view`, `nvim_diagnostics`, `nvim_visual`, `nvim_execute_lua`, `nvim_system`) with comprehensive action dispatching.
* **Fallback Vector Search Parity**: In-memory vector search fallback indexes Knowledge Graph entities, observations, and error fixes when external vector databases are unavailable.
+31 -17
View File
@@ -13,25 +13,20 @@ pub async fn read_mcp_message<R: tokio::io::AsyncRead + Unpin>(
return None; return None;
} }
let trimmed_start = line.trim_start(); let trimmed = line.trim();
if trimmed_start.starts_with('{') { if trimmed.starts_with('{') || trimmed.starts_with('[') {
let mut val = std::mem::take(&mut line); return Some(trimmed.to_string());
let trimmed_len = val.trim_end().len();
val.truncate(trimmed_len);
let start = val.len() - val.trim_start().len();
if start > 0 {
val.drain(..start);
}
return Some(val);
} }
let line = line.trim_end(); let trimmed_line = line.trim_end();
if line.is_empty() { if trimmed_line.is_empty() {
break; break;
} }
if line.as_bytes().len() >= 15 && line.as_bytes()[..15].eq_ignore_ascii_case(b"content-length:") { if trimmed_line.as_bytes().len() >= 15
length = line[15..].trim().parse().unwrap_or(0); && trimmed_line.as_bytes()[..15].eq_ignore_ascii_case(b"content-length:")
{
length = trimmed_line[15..].trim().parse().unwrap_or(0);
} }
} }
@@ -42,7 +37,13 @@ pub async fn read_mcp_message<R: tokio::io::AsyncRead + Unpin>(
} }
let mut buffer = Vec::with_capacity(length.min(64 * 1024)); let mut buffer = Vec::with_capacity(length.min(64 * 1024));
if stdin.take(length as u64).read_to_end(&mut buffer).await.is_err() || buffer.len() != length { if stdin
.take(length as u64)
.read_to_end(&mut buffer)
.await
.is_err()
|| buffer.len() != length
{
return None; return None;
} }
@@ -54,12 +55,26 @@ mod tests {
use super::*; use super::*;
use std::io::Cursor; use std::io::Cursor;
#[tokio::test]
async fn test_read_batch_ndjson_message() {
let input = "[{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}]\n";
let mut reader = BufReader::new(Cursor::new(input));
let msg = read_mcp_message(&mut reader).await;
assert_eq!(
msg,
Some("[{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}]".to_string())
);
}
#[tokio::test] #[tokio::test]
async fn test_read_ndjson_message() { async fn test_read_ndjson_message() {
let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n"; let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n";
let mut reader = BufReader::new(Cursor::new(input)); let mut reader = BufReader::new(Cursor::new(input));
let msg = read_mcp_message(&mut reader).await; let msg = read_mcp_message(&mut reader).await;
assert_eq!(msg, Some("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}".to_string())); assert_eq!(
msg,
Some("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}".to_string())
);
} }
#[tokio::test] #[tokio::test]
@@ -103,4 +118,3 @@ mod tests {
assert_eq!(msg, None); assert_eq!(msg, None);
} }
} }
+58 -12
View File
@@ -1,11 +1,12 @@
use std::sync::Arc; use std::sync::Arc;
use std::sync::LazyLock; use std::sync::LazyLock;
use tokio::io::AsyncBufReadExt;
pub static ACTIVE_SOCKET: LazyLock<Arc<std::sync::Mutex<Option<String>>>> = pub static ACTIVE_SOCKET: LazyLock<Arc<tokio::sync::Mutex<Option<String>>>> =
LazyLock::new(|| Arc::new(std::sync::Mutex::new(None))); LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None)));
pub static HEADLESS_PROC: LazyLock<Arc<std::sync::Mutex<Option<tokio::process::Child>>>> = pub static HEADLESS_PROC: LazyLock<Arc<tokio::sync::Mutex<Option<tokio::process::Child>>>> =
LazyLock::new(|| Arc::new(std::sync::Mutex::new(None))); LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None)));
pub async fn spawn_headless_nvim() -> Result<String, String> { pub async fn spawn_headless_nvim() -> Result<String, String> {
if std::process::Command::new("nvim") if std::process::Command::new("nvim")
@@ -17,25 +18,71 @@ pub async fn spawn_headless_nvim() -> Result<String, String> {
} }
#[cfg(windows)] #[cfg(windows)]
let socket_name = format!(r"\\.\pipe\agy-headless-nvim-{}", std::process::id()); let socket_name = format!(
r"\\.\pipe\agy-headless-nvim-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
#[cfg(unix)] #[cfg(unix)]
let socket_name = format!("/tmp/agy-headless-nvim-{}.sock", std::process::id()); let socket_name = format!(
"/tmp/agy-headless-nvim-{}-{}.sock",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let child = tokio::process::Command::new("nvim") let mut child = tokio::process::Command::new("nvim")
.arg("--headless") .arg("--headless")
.arg("--listen") .arg("--listen")
.arg(&socket_name) .arg(&socket_name)
.arg("--cmd")
.arg("lua vim.schedule(function() io.stdout:write('READY\\n') io.stdout:flush() end)")
.stdin(std::process::Stdio::piped()) .stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.kill_on_drop(true) .kill_on_drop(true)
.spawn() .spawn()
.map_err(|e| format!("Failed to spawn headless Neovim: {}", e))?; .map_err(|e| format!("Failed to spawn headless Neovim: {}", e))?;
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; if let Some(stdout) = child.stdout.take() {
let mut reader = tokio::io::BufReader::new(stdout);
let mut ready_line = String::new();
match tokio::time::timeout(
tokio::time::Duration::from_millis(2000),
reader.read_line(&mut ready_line),
)
.await
{
Ok(Ok(_)) => {
if ready_line.trim() != "READY" {
let _ = child.kill().await;
return Err(format!(
"Headless Neovim child output unexpected banner: {:?}",
ready_line
));
}
}
Ok(Err(e)) => {
let _ = child.kill().await;
return Err(format!(
"Failed to read readiness from headless Neovim: {}",
e
));
}
Err(_) => {
let _ = child.kill().await;
return Err("Timeout waiting for headless Neovim to become ready".to_string());
}
}
}
let old_child = { let old_child = {
let mut lock = HEADLESS_PROC.lock().unwrap_or_else(|e| e.into_inner()); let mut lock = HEADLESS_PROC.lock().await;
let old = lock.take(); let old = lock.take();
*lock = Some(child); *lock = Some(child);
old old
@@ -50,7 +97,7 @@ pub async fn spawn_headless_nvim() -> Result<String, String> {
pub async fn kill_headless_nvim() { pub async fn kill_headless_nvim() {
let child_to_kill = { let child_to_kill = {
let mut proc_lock = HEADLESS_PROC.lock().unwrap_or_else(|e| e.into_inner()); let mut proc_lock = HEADLESS_PROC.lock().await;
proc_lock.take() proc_lock.take()
}; };
if let Some(mut child) = child_to_kill { if let Some(mut child) = child_to_kill {
@@ -71,4 +118,3 @@ mod tests {
} }
} }
} }
+42 -19
View File
@@ -2,36 +2,59 @@
When connected to this Neovim MCP server (`win-nvim`), you have powerful tools to interact directly with the active Neovim editor. When connected to this Neovim MCP server (`win-nvim`), you have powerful tools to interact directly with the active Neovim editor.
## The Tool Arsenal ## The Consolidated Tool Arsenal (v2)
The following tools are available: The server consolidates granular Neovim operations into 7 smart domain tools:
- **File/Buffer Mgmt:** - **`nvim_buffer`**: Buffer and file management.
`nvim_open_file`, `nvim_open_buffer`, `nvim_close_buffer`, `nvim_reload_buffer`, `nvim_save_buffer`, `nvim_list_buffers` - `action: "open_file"`: Open file in buffer (args: `file`, `line`, `col`).
- **Window Mgmt:** - `action: "open"`: Open buffer (args: `bufnr`).
`nvim_split_window`, `nvim_close_window`, `nvim_list_windows`, `nvim_get_active_window`, `nvim_set_active_window` - `action: "close"`: Close buffer (args: `bufnr`, `force`).
- **State Reading:** - `action: "reload"`: Reload buffer from disk (args: `bufnr`).
`nvim_get_active_buffer`, `nvim_get_cursor`, `nvim_goto_line`, `nvim_get_visual_selection`, `nvim_get_viewport`, `nvim_get_messages` - `action: "save"`: Save buffer to disk (args: `bufnr`).
- **Diagnostics & Visuals:** - `action: "list"`: List all loaded buffers.
`nvim_set_diagnostics`, `nvim_get_diagnostics`, `nvim_highlight_lines`, `nvim_set_extmark`, `nvim_set_quickfix` - **`nvim_window`**: Window split and focus management.
- **God Mode:** - `action: "split"`: Split window (args: `direction: "horizontal" | "vertical"`, `file`).
`nvim_execute_lua` - `action: "close"`: Close window (args: `winnr`).
- `action: "list"`: List open windows.
- `action: "get_active"`: Get active window details.
- `action: "set_active"`: Set active window focus (args: `winnr`).
- **`nvim_view`**: Editor viewport and navigation.
- `action: "get_active_buffer"`: Get active buffer details.
- `action: "get_cursor"`: Get current cursor line/col.
- `action: "goto_line"`: Jump cursor to line (args: `line`, `col`).
- `action: "get_viewport"`: Get visible line range in viewport.
- `action: "get_messages"`: Get Neovim command-line messages.
- **`nvim_diagnostics`**: LSP diagnostics querying and publishing.
- `action: "get"`: Get diagnostics (args: `bufnr`, `severity`).
- `action: "set"`: Set buffer diagnostics (args: `bufnr`, `diagnostics`).
- **`nvim_visual`**: Visual highlighting, extmarks, and quickfix.
- `action: "get_selection"`: Get current visual selection text and range.
- `action: "highlight_lines"`: Highlight line ranges (args: `bufnr`, `hl_group`, `start_line`, `end_line`).
- `action: "set_extmark"`: Place virtual text or sign extmarks (args: `bufnr`, `ns_id`, `line`, `col`, `opts`).
- `action: "set_quickfix"`: Populate quickfix list (args: `items`, `title`).
- **`nvim_execute_lua`**: God Mode arbitrary Lua evaluation.
- Arguments: `code: String`.
- **`nvim_system`**: System diagnostics and connection heartbeat.
- `action: "ping"`: Heartbeat test.
- `action: "status"`: Server and socket bridge health status.
## 1. Using Dedicated Tools First ## 1. Using Consolidated Domain Tools First
Always prefer the specific dedicated tools (like `nvim_open_file`, `nvim_highlight_lines`, etc.) over writing raw Lua scripts. These tools are tested and safe. Always prefer the specific consolidated tools (like `nvim_buffer`, `nvim_window`, `nvim_visual`, etc.) over writing raw Lua scripts. These tools are strongly typed, tested, and safe.
## 2. Lua God Mode (`nvim_execute_lua`) ## 2. Lua God Mode (`nvim_execute_lua`)
If you need to access *any* Neovim API that does not have a dedicated tool (e.g., complex buffer edits, changing options, LSP interactions), you MUST use `nvim_execute_lua` as your escape hatch. If you need to access *any* Neovim API that does not have a dedicated tool (e.g., complex buffer edits, changing options, custom LSP interactions), you MUST use `nvim_execute_lua` as your escape hatch.
### CRITICAL RULES for `nvim_execute_lua`: ### CRITICAL RULES for `nvim_execute_lua`:
1. **Never Block:** Never use interactive prompts (`vim.fn.input`, `vim.ui.select`, `vim.fn.confirm`) or confirmation flags in regex substitutions (e.g., `%s/old/new/gc`). This will cause the headless MCP bridge to deadlock forever. 1. **Never Block:** Never use interactive prompt functions or interactive confirmation flags in substitutions (e.g., `%s/old/new/gc`). This will cause the headless MCP bridge to deadlock forever.
2. **Visual Feedback:** Always trigger a notification using `require("notify")("Antigravity: [Action]", "info", { title = "Antigravity" })`. 2. **Visual Feedback:** Always trigger a notification using `require("notify")("Antigravity: [Action]", "info", { title = "Antigravity" })`.
3. **Auto-Save:** If you modify a file buffer, always save it using `vim.cmd('write')` within the same Lua script so external tools can see the changes, unless you explicitly want to pause for manual human review. 3. **Auto-Save:** If you modify a file buffer, always save it using `vim.cmd('write')` within the same Lua script so external tools can see the changes, unless you explicitly want to pause for manual human review.
4. **Buffer Focus:** When making changes to a specific buffer, always ensure the active window is switched to that buffer, and optionally move the cursor so the human can see the change visually. 4. **Buffer Focus & Centering:** When making changes to a buffer, switch the active window to that buffer, jump to the edited line/column (`vim.api.nvim_win_set_cursor(0, {line, col})`), and center the viewport horizontally and vertically (`vim.cmd('normal! zz'); vim.cmd('normal! ' .. col .. '|zs')`) so the user immediately sees the change in full context.
## 3. The "Unix is NOT King" Rule ## 3. The "Unix is NOT King" Rule
You should **ALWAYS prioritize Neovim tools over basic unix terminal utilities** (like `cat`, `grep`, `sed`, `awk`, or PowerShell equivalents) for file read/writes and search/replace. If an interactive Neovim session is not currently open, the server will automatically spawn a persistent headless Neovim daemon in the background to execute your commands. You should **ALWAYS prioritize Neovim tools over basic unix terminal utilities** (like `cat`, `grep`, `sed`, `awk`, or PowerShell equivalents) for file read/writes and search/replace. If an interactive Neovim session is not currently open, the server will automatically connect or spawn a persistent headless Neovim daemon in the background to execute your commands.
**CRITICAL PAIR-PROGRAMMING EXCEPTION:** **CRITICAL PAIR-PROGRAMMING EXCEPTION:**
While the headless background instance is great for autonomous, routine tasks, if you are performing collaborative "pair programming" activities, complex refactors that require visual engagement, or step-by-step human review, **DO NOT** execute them blindly in the background. Instead, explicitly ask the user to open a Neovim UI first so they can visually follow along. Use Neovim as your primary AST-aware interface to the codebase at all times. While the headless background instance is great for autonomous, routine tasks, if you are performing collaborative "pair programming" activities, complex refactors that require visual engagement, or step-by-step human review, **DO NOT** execute them blindly in the background. Instead, explicitly ask the user to open a Neovim UI first so they can visually follow along. Use Neovim as your primary AST-aware interface to the codebase at all times.
## 4. Tool Schema Discovery ## 4. Tool Schema Discovery
Do **NOT** grep or search the Rust source code to find tool schemas or arguments. All lazy-loaded MCP tool schemas are automatically cached as JSON files on your disk. To understand a tool`s arguments, directly read `~/.gemini/antigravity-cli/mcp/win-nvim/<tool_name>.json` (or linux-nvim), or simply guess the arguments if it is a basic tool like `nvim_open_file` (e.g., `{"file": "/path/to/file"}`). Do **NOT** grep or search the Rust source code to find tool schemas or arguments. All lazy-loaded MCP tool schemas are automatically cached as JSON files on your disk. To understand a tool's arguments, directly read `~/.gemini/antigravity-cli/mcp/win-nvim/<tool_name>.json` (or linux-nvim).
+690 -637
View File
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -27,7 +27,11 @@ tracing-appender = "0.2.5"
rmcp = { version = "3.4.0", features = ["server"] } rmcp = { version = "3.4.0", features = ["server"] }
thiserror = "2.0.20" thiserror = "2.0.20"
tower = { version = "0.5.3", features = ["util"] } tower = { version = "0.5.3", features = ["util"] }
fastembed = "7.1.0" candle-core = "0.11.0"
candle-nn = "0.11.0"
candle-transformers = "0.11.0"
tokenizers = { version = "0.23.1", default-features = false, features = ["onig"] }
hf-hub = { version = "1.0.0", features = ["blocking"] }
arboard = "3.6.1" arboard = "3.6.1"
image = "0.25.10" image = "0.25.10"
base64 = "0.23.1" base64 = "0.23.1"
@@ -42,7 +46,6 @@ tree-sitter-cpp = "0.23.4"
tree-sitter-go = "0.25.0" tree-sitter-go = "0.25.0"
ignore = "0.4.33" ignore = "0.4.33"
notify = "7" notify = "7"
qdrant-client = "1.19.0"
blake3 = "1.8" blake3 = "1.8"
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
@@ -54,3 +57,5 @@ winres = "0.1.12"
tempfile = "3.27.0" tempfile = "3.27.0"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59.0", features = ["Win32_System_DataExchange", "Win32_System_Memory"] }
+1
View File
@@ -95,6 +95,7 @@ pub async fn gate_set_handler(
status, status,
reason: body.reason.clone(), reason: body.reason.clone(),
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
..Default::default()
}; };
app_state.handler.state.env.gates.modify(|gates| { app_state.handler.state.env.gates.modify(|gates| {
gates.retain(|g| !(g.action == record.action && g.target == record.target)); gates.retain(|g| !(g.action == record.action && g.target == record.target));
+1
View File
@@ -131,6 +131,7 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
expires_at: None, expires_at: None,
namespace: "global".to_string(), namespace: "global".to_string(),
timestamp: now, timestamp: now,
..Default::default()
}; };
mem_state.telemetry.handoff_memos.modify(|m| m.push(memo)); mem_state.telemetry.handoff_memos.modify(|m| m.push(memo));
+16 -6
View File
@@ -72,7 +72,11 @@ pub async fn nvim_telemetry_handler(
} }
if let Some(ref f) = payload.file { if let Some(ref f) = payload.file {
state.handler.state.record_activity("editor", &format!("Neovim {}: {}", payload.event, f), None); state.handler.state.record_activity(
"editor",
&format!("Neovim {}: {}", payload.event, f),
None,
);
} }
axum::Json(serde_json::json!({"status": "ok"})) axum::Json(serde_json::json!({"status": "ok"}))
@@ -99,12 +103,16 @@ pub async fn terminal_telemetry_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::Json(payload): axum::Json<TerminalHistory>, axum::Json(payload): axum::Json<TerminalHistory>,
) -> impl axum::response::IntoResponse { ) -> impl axum::response::IntoResponse {
state.handler.state.record_activity("terminal", &format!("Ran command: {}", payload.command), Some(&format!("exit_code: {}, cwd: {}", payload.exit_code, payload.cwd))); state.handler.state.record_activity(
"terminal",
&format!("Ran command: {}", payload.command),
Some(&format!(
"exit_code: {}, cwd: {}",
payload.exit_code, payload.cwd
)),
);
state state.handler.state.record_terminal_history(payload.clone());
.handler
.state
.record_terminal_history(payload.clone());
let ws_msg = serde_json::json!({ let ws_msg = serde_json::json!({
"type": "terminal_telemetry", "type": "terminal_telemetry",
@@ -163,6 +171,8 @@ mod tests {
cwd: "C:/Users/reazul.ashraf/workspace".to_string(), cwd: "C:/Users/reazul.ashraf/workspace".to_string(),
os: "windows".to_string(), os: "windows".to_string(),
timestamp: 12345, timestamp: 12345,
error_output: None,
..Default::default()
}; };
// Post the telemetry // Post the telemetry
+1
View File
@@ -233,6 +233,7 @@ mod tests {
error: None, error: None,
timestamp: 1728129000, timestamp: 1728129000,
session_id: None, session_id: None,
..Default::default()
}; };
// Broadcast task event // Broadcast task event
-92
View File
@@ -1,92 +0,0 @@
use crate::models::StickyNote;
use crate::state::MemoryState;
use arboard::Clipboard;
use std::sync::Arc;
use tokio::time::{Duration, sleep};
pub fn spawn_watcher(state: Arc<MemoryState>) {
tokio::spawn(async move {
let mut last_text = String::new();
let mut clipboard_opt: Option<Clipboard> = Clipboard::new().ok();
loop {
let is_enabled = {
let watch = state.clipboard_watch_mode.read().await;
*watch
};
if !is_enabled {
state.clipboard_notify.notified().await;
continue;
}
sleep(Duration::from_millis(1000)).await;
if clipboard_opt.is_none() {
clipboard_opt = Clipboard::new().ok();
}
if let Some(ref mut cb) = clipboard_opt {
match cb.get_text() {
Ok(text) => {
if text != last_text && !text.trim().is_empty() {
last_text = text.clone();
let note = StickyNote {
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
content: text.clone(),
expires_at: None,
};
state.code.sticky.modify(|notes| {
notes.push(note.clone());
if notes.len() > 200 {
let overflow = notes.len() - 200;
notes.drain(0..overflow);
}
});
state.record_activity(
"clipboard",
"Auto-ingested clipboard content into sticky notes",
Some(&text),
);
state.index_commit_notify.notify_waiters();
}
}
Err(_) => {
// Reset clipboard handle on error so it can be re-initialized cleanly
clipboard_opt = None;
}
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_spawn_watcher_lifecycle() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
// Set watch mode to disabled first
*state.clipboard_watch_mode.write().await = false;
spawn_watcher(state.clone());
tokio::time::sleep(Duration::from_millis(50)).await;
// Enable watch mode
*state.clipboard_watch_mode.write().await = true;
state.clipboard_notify.notify_waiters();
tokio::time::sleep(Duration::from_millis(150)).await;
}
}
+8 -39
View File
@@ -494,18 +494,14 @@
<div class="main-container"> <div class="main-container">
<div class="tabs"> <div class="tabs">
<button class="tab-button active" onclick="switchTab('activity-tab', this)">Live Activity</button> <button class="tab-button active" onclick="switchTab('activity-tab', this)">Live Activity</button>
<button class="tab-button" onclick="switchTab('ledger-tab', this)">Code Ledger</button>
<button class="tab-button" onclick="switchTab('graph-tab', this)">Graph Explorer</button> <button class="tab-button" onclick="switchTab('graph-tab', this)">Graph Explorer</button>
<button class="tab-button" onclick="switchTab('search-tab', this)">Omni-Search</button> <button class="tab-button" onclick="switchTab('search-tab', this)">Omni-Search</button>
<button class="tab-button" onclick="switchTab('task-tab', this)">Task Board</button> <button class="tab-button" onclick="switchTab('task-tab', this)">Task Board</button>
<button class="tab-button" onclick="switchTab('sticky-tab', this)">Sticky Notes</button>
<button class="tab-button" onclick="switchTab('preferences-tab', this)">Preferences</button>
<button class="tab-button" onclick="switchTab('techdebt-tab', this)">Tech Debt</button> <button class="tab-button" onclick="switchTab('techdebt-tab', this)">Tech Debt</button>
<button class="tab-button" onclick="switchTab('adrs-tab', this)">Decisions (ADR)</button> <button class="tab-button" onclick="switchTab('adrs-tab', this)">Decisions (ADR)</button>
<button class="tab-button" onclick="switchTab('workspaces-tab', this)">Workspaces</button>
<button class="tab-button" onclick="switchTab('pinned-tab', this)">Pinned Files</button>
<button class="tab-button" onclick="switchTab('memos-tab', this)">Handoffs</button> <button class="tab-button" onclick="switchTab('memos-tab', this)">Handoffs</button>
<button class="tab-button" onclick="switchTab('snippets-tab', this)">Snippets</button> <button class="tab-button" onclick="switchTab('snippets-tab', this)">Snippets</button>
<button class="tab-button" onclick="switchTab('pr-tab', this)">PRs</button>
<button class="tab-button" onclick="switchTab('terminal-tab', this)">Terminal</button> <button class="tab-button" onclick="switchTab('terminal-tab', this)">Terminal</button>
</div> </div>
@@ -550,19 +546,6 @@
</div> </div>
</div> </div>
<div id="preferences-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Global Preferences</h2>
<div id="preferences-container" style="display:flex; flex-direction:column; gap:15px; font-size: 0.9em; line-height: 1.4;"></div>
</div>
</div>
<div id="sticky-tab" class="tab-content">
<div class="panel sticky-panel" style="flex:1; display:flex; flex-direction:column;">
<h2>Sticky Notes</h2>
<div id="sticky-notes-container" style="flex:1; overflow-y:auto; display:flex; flex-wrap:wrap; gap:15px; align-items:flex-start; align-content:flex-start;"></div>
</div>
</div>
<div id="techdebt-tab" class="tab-content"> <div id="techdebt-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;"> <div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
@@ -580,20 +563,6 @@
</div> </div>
</div> </div>
<div id="workspaces-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Context Workspaces</h2>
<div id="workspaces-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="pinned-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Pinned Files</h2>
<div id="pinned-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="memos-tab" class="tab-content"> <div id="memos-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;"> <div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Handoff Memos & Session Summaries</h2> <h2>Handoff Memos & Session Summaries</h2>
@@ -610,13 +579,6 @@
</div> </div>
</div> </div>
<div id="pr-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>PR Checklists</h2>
<div id="pr-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="terminal-tab" class="tab-content"> <div id="terminal-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;"> <div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Terminal History</h2> <h2>Terminal History</h2>
@@ -624,6 +586,13 @@
</div> </div>
</div> </div>
<div id="ledger-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Code Change Ledger & Recent Deltas</h2>
<div id="ledger-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="activity-tab" class="tab-content active"> <div id="activity-tab" class="tab-content active">
<div class="panel activity-panel" style="flex:1; display:flex; flex-direction:column;"> <div class="panel activity-panel" style="flex:1; display:flex; flex-direction:column;">
<div id="activity-feed" style="flex:1;"></div> <div id="activity-feed" style="flex:1;"></div>
+106 -147
View File
@@ -23,36 +23,24 @@ function switchTab(tabId, btn) {
case "task-tab": case "task-tab":
loadTasks(); loadTasks();
break; break;
case "preferences-tab":
loadPreferences();
break;
case "sticky-tab":
loadStickyNotes();
break;
case "techdebt-tab": case "techdebt-tab":
loadTechDebt(); loadTechDebt();
break; break;
case "adrs-tab": case "adrs-tab":
loadADRs(); loadADRs();
break; break;
case "workspaces-tab":
loadWorkspaces();
break;
case "pinned-tab":
loadPinned();
break;
case "memos-tab": case "memos-tab":
loadMemos(); loadMemos();
break; break;
case "snippets-tab": case "snippets-tab":
loadSnippets(); loadSnippets();
break; break;
case "pr-tab":
loadPRs();
break;
case "terminal-tab": case "terminal-tab":
loadTerminal(); loadTerminal();
break; break;
case "ledger-tab":
loadLedger();
break;
} }
} }
function toggleTheme() { function toggleTheme() {
@@ -89,7 +77,10 @@ function getColorForType(type) {
for (let i = 0;i < type.length; i++) for (let i = 0;i < type.length; i++)
hash = type.charCodeAt(i) + ((hash << 5) - hash); hash = type.charCodeAt(i) + ((hash << 5) - hash);
const hue = Math.abs(hash) % 360; const hue = Math.abs(hash) % 360;
return { background: `hsl(${hue}, 70%, 60%)`, border: `hsl(${hue}, 70%, 40%)` }; return {
background: `hsl(${hue}, 70%, 60%)`,
border: `hsl(${hue}, 70%, 40%)`
};
} }
function zoomGraph(step) { function zoomGraph(step) {
if (!network) if (!network)
@@ -100,7 +91,9 @@ function zoomGraph(step) {
function resetGraph() { function resetGraph() {
if (!network) if (!network)
return; return;
network.fit({ animation: { duration: 500, easingFunction: "easeInOutQuad" } }); network.fit({
animation: { duration: 500, easingFunction: "easeInOutQuad" }
});
} }
function closeInspector() { function closeInspector() {
const inspector = document.getElementById("inspector-panel"); const inspector = document.getElementById("inspector-panel");
@@ -176,7 +169,9 @@ function updateGraphData() {
title: `${name} title: `${name}
Type: ${entity.entity_type}`, Type: ${entity.entity_type}`,
color: { background: color.bg || color.background, border: color.border }, color: { background: color.bg || color.background, border: color.border },
font: { color: document.documentElement.getAttribute("data-theme") === "dark" ? "#eee" : "#333" } font: {
color: document.documentElement.getAttribute("data-theme") === "dark" ? "#eee" : "#333"
}
}); });
nodeIds.add(name); nodeIds.add(name);
} }
@@ -188,7 +183,10 @@ Type: ${entity.entity_type}`,
to: r.to, to: r.to,
label: r.relation_type, label: r.relation_type,
arrows: "to", arrows: "to",
font: { color: document.documentElement.getAttribute("data-theme") === "dark" ? "#aaa" : "#666", strokeWidth: 0 } font: {
color: document.documentElement.getAttribute("data-theme") === "dark" ? "#aaa" : "#666",
strokeWidth: 0
}
}); });
} }
}); });
@@ -218,12 +216,28 @@ async function loadGraph() {
return; return;
const options = { const options = {
nodes: { shape: "dot", size: 16, font: { size: 12 } }, nodes: { shape: "dot", size: 16, font: { size: 12 } },
edges: { color: { inherit: "from", opacity: 0.6 }, font: { size: 10, align: "middle" }, smooth: { type: "continuous" } }, edges: {
color: { inherit: "from", opacity: 0.6 },
font: { size: 10, align: "middle" },
smooth: { type: "continuous" }
},
physics: { physics: {
enabled: true, enabled: true,
solver: "barnesHut", solver: "barnesHut",
barnesHut: { gravitationalConstant: -3000, centralGravity: 0.3, springLength: 95, springConstant: 0.04, damping: 0.09, avoidOverlap: 0.1 }, barnesHut: {
stabilization: { enabled: true, iterations: 150, updateInterval: 25, fit: true } gravitationalConstant: -3000,
centralGravity: 0.3,
springLength: 95,
springConstant: 0.04,
damping: 0.09,
avoidOverlap: 0.1
},
stabilization: {
enabled: true,
iterations: 150,
updateInterval: 25,
fit: true
}
}, },
interaction: { hover: true, tooltipDelay: 100, zoomView: false } interaction: { hover: true, tooltipDelay: 100, zoomView: false }
}; };
@@ -466,7 +480,17 @@ function parseActivityPayload(item) {
else if (item.content) else if (item.content)
summary = String(item.content); summary = String(item.content);
else { else {
const { timestamp: _t, time: _tm, updated_at: _u, category: _c, type: _tp, method: _m, params: _p, jsonrpc: _j, ...rest } = item; const {
timestamp: _t,
time: _tm,
updated_at: _u,
category: _c,
type: _tp,
method: _m,
params: _p,
jsonrpc: _j,
...rest
} = item;
if (Object.keys(rest).length > 0) { if (Object.keys(rest).length > 0) {
summary = JSON.stringify(rest); summary = JSON.stringify(rest);
} else { } else {
@@ -579,15 +603,11 @@ function requestDomainRefresh(domain) {
const tabMap = { const tabMap = {
graph: "graph-tab", graph: "graph-tab",
task: "task-tab", task: "task-tab",
sticky: "sticky-tab",
techdebt: "techdebt-tab", techdebt: "techdebt-tab",
snippets: "snippets-tab", snippets: "snippets-tab",
terminal: "terminal-tab", terminal: "terminal-tab",
memos: "memos-tab", ledger: "ledger-tab",
workspaces: "workspaces-tab", memos: "memos-tab"
pinned: "pinned-tab",
pr: "pr-tab",
preferences: "preferences-tab"
}; };
const targetTab = tabMap[domain]; const targetTab = tabMap[domain];
if (!targetTab || targetTab !== currentTabId) { if (!targetTab || targetTab !== currentTabId) {
@@ -605,9 +625,6 @@ function requestDomainRefresh(domain) {
case "task": case "task":
loadTasks(); loadTasks();
break; break;
case "sticky":
loadStickyNotes();
break;
case "techdebt": case "techdebt":
loadTechDebt(); loadTechDebt();
break; break;
@@ -617,21 +634,12 @@ function requestDomainRefresh(domain) {
case "terminal": case "terminal":
loadTerminal(); loadTerminal();
break; break;
case "ledger":
loadLedger();
break;
case "memos": case "memos":
loadMemos(); loadMemos();
break; break;
case "workspaces":
loadWorkspaces();
break;
case "pinned":
loadPinned();
break;
case "pr":
loadPRs();
break;
case "preferences":
loadPreferences();
break;
} }
}, 250); }, 250);
} }
@@ -650,24 +658,16 @@ function handleIncomingActivity(payload) {
requestDomainRefresh("graph"); requestDomainRefresh("graph");
} else if (method === "notifications/task/completed" || category.startsWith("TASK")) { } else if (method === "notifications/task/completed" || category.startsWith("TASK")) {
requestDomainRefresh("task"); requestDomainRefresh("task");
} else if (category === "STICKY_NOTE" || category === "STICKY") {
requestDomainRefresh("sticky");
} else if (category === "TECH_DEBT" || category === "ERROR_FIX" || category === "CHECKPOINT") { } else if (category === "TECH_DEBT" || category === "ERROR_FIX" || category === "CHECKPOINT") {
requestDomainRefresh("techdebt"); requestDomainRefresh("techdebt");
} else if (category === "SNIPPET") { } else if (category === "SNIPPET") {
requestDomainRefresh("snippets"); requestDomainRefresh("snippets");
} else if (category === "TERMINAL") { } else if (category === "TERMINAL") {
requestDomainRefresh("terminal"); requestDomainRefresh("terminal");
} else if (category === "CODE_CHANGE" || category === "LEDGER") {
requestDomainRefresh("ledger");
} else if (category === "MEMO" || category === "MILESTONE" || category === "HANDOFF_MEMO") { } else if (category === "MEMO" || category === "MILESTONE" || category === "HANDOFF_MEMO") {
requestDomainRefresh("memos"); requestDomainRefresh("memos");
} else if (category === "WORKSPACE" || category === "CONTEXT_WORKSPACE") {
requestDomainRefresh("workspaces");
} else if (category === "PINNED" || category === "PINNED_FILE") {
requestDomainRefresh("pinned");
} else if (category === "PR" || category === "PR_CHECKLIST") {
requestDomainRefresh("pr");
} else if (category === "PREFERENCE" || category === "PREFERENCES") {
requestDomainRefresh("preferences");
} }
const feed = document.getElementById("activity-feed"); const feed = document.getElementById("activity-feed");
if (feed) { if (feed) {
@@ -725,51 +725,6 @@ function setupWS() {
setupSSE(); setupSSE();
}; };
} }
async function loadPreferences() {
try {
const res = await fetch("/api/preferences");
const data = await res.json();
const container = document.getElementById("preferences-container");
if (!container)
return;
container.innerHTML = "";
if (!data || Object.keys(data).length === 0) {
container.innerHTML = '<div style="color:var(--text-secondary); text-align:center; padding:20px;">No global preferences found.</div>';
return;
}
for (const [key, pref] of Object.entries(data)) {
const date = new Date(pref.updated_at * 1000).toLocaleString();
container.innerHTML += `
<div style="background:var(--card-bg); border:1px solid var(--border-color); border-radius:6px; padding:15px; box-shadow:0 1px 3px rgba(0,0,0,0.1);">
<strong style="color:#3498db; display:block; margin-bottom:5px; font-size:1.1em;">${key}</strong>
<div style="margin-bottom:10px;">${pref.value}</div>
<div style="font-size:0.8em; color:var(--text-secondary); text-align:right;">Last Updated: ${date}</div>
</div>`;
}
} catch (e) {
console.error("Error loading preferences:", e);
}
}
async function loadStickyNotes() {
try {
const res = await fetch("/api/sticky");
const sticky = await res.json();
const container = document.getElementById("sticky-notes-container");
if (!container)
return;
container.innerHTML = "";
sticky.forEach((note) => {
const card = document.createElement("div");
card.className = "sticky-note";
const date = new Date(note.timestamp * 1000).toLocaleString();
card.innerHTML = `<div style="font-size:0.75em; color:#7f8c8d; margin-bottom:8px;">${date}</div>
<div>${note.content}</div>`;
container.appendChild(card);
});
} catch (err) {
console.error("Failed to load sticky notes", err);
}
}
async function loadGenericList(endpoint, containerId, formatter) { async function loadGenericList(endpoint, containerId, formatter) {
try { try {
const res = await fetch(endpoint); const res = await fetch(endpoint);
@@ -792,6 +747,50 @@ async function loadGenericList(endpoint, containerId, formatter) {
console.error(`Failed to load ${endpoint}`, err); console.error(`Failed to load ${endpoint}`, err);
} }
} }
function loadLedger() {
loadGenericList("/api/ledger?limit=100", "ledger-container", (item) => {
const vcs = (item.vcs_type || (item.revision && item.revision.startsWith("r") ? "svn" : "git")).toUpperCase();
const vcsColor = vcs === "SVN" ? "#e67e22" : "#3498db";
const rev = item.revision || item.git_commit;
const revLabel = vcs === "SVN" ? "Rev" : "Commit";
const branch = item.branch || item.git_branch || "None";
const repo = item.repo_name || item.namespace || "global";
const changeKind = (item.change_kind || "modified").toLowerCase();
let kindColor = "var(--accent-color)";
let kindLabel = "Modified";
if (changeKind === "added") {
kindColor = "var(--success-color)";
kindLabel = "+ Added";
} else if (changeKind === "deleted") {
kindColor = "var(--error-color)";
kindLabel = "- Deleted";
} else if (changeKind === "renamed") {
kindColor = "#e67e22";
kindLabel = "→ Renamed";
}
const symbols = Array.isArray(item.symbols) && item.symbols.length > 0 ? item.symbols.map((s) => `<span style="background:var(--card-bg); padding:1px 6px; border-radius:3px; font-size:0.75em; border:1px solid var(--border-color); font-family:monospace;">${escapeHtml(s)}</span>`).join(" ") : "";
const authorBadge = item.author ? `<span style="background:var(--canvas-bg); padding:2px 8px; border-radius:4px; border:1px solid var(--border-color); font-size:0.8em; color:var(--text-secondary);">\uD83D\uDC64 ${escapeHtml(item.author)}</span>` : "";
const lineBadge = item.line_range ? `<span style="background:var(--canvas-bg); padding:2px 8px; border-radius:4px; border:1px solid var(--border-color); font-size:0.8em; font-family:monospace;">${escapeHtml(item.line_range)}</span>` : "";
return `
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:10px;">
<span style="font-weight:bold; font-size:1.05em; color:var(--text-primary); font-family:monospace;">${escapeHtml(item.file_path || "Unknown")}</span>
<div style="display:flex; gap:6px; align-items:center;">
<span style="background:${vcsColor}; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; text-transform:uppercase;">${vcs}</span>
<span style="background:var(--canvas-bg); color:${kindColor}; border:1px solid ${kindColor}; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px;">${kindLabel}</span>
</div>
</div>
<div style="margin-top:6px; font-size:0.95em; color:var(--text-primary);">${escapeHtml(item.description || "")}</div>
${symbols ? `<div style="margin-top:8px; display:flex; gap:5px; flex-wrap:wrap; align-items:center;"><span style="font-size:0.8em; color:var(--text-secondary);">Symbols:</span> ${symbols}</div>` : ""}
<div style="margin-top:10px; font-size:0.85em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<span style="background:var(--canvas-bg); padding:2px 8px; border-radius:4px; font-weight:bold; border:1px solid var(--border-color); color:var(--text-primary);">\uD83D\uDCE6 ${escapeHtml(repo)}</span>
<span style="background:var(--canvas-bg); padding:2px 8px; border-radius:4px; font-family:monospace; border:1px solid var(--border-color);">${revLabel}: ${rev ? escapeHtml(rev.substring(0, 10)) : "None"}</span>
<span style="background:var(--canvas-bg); padding:2px 8px; border-radius:4px; border:1px solid var(--border-color);">\uD83C\uDF3F ${escapeHtml(branch)}</span>
${lineBadge}
${authorBadge}
</div>
`;
});
}
function loadTerminal() { function loadTerminal() {
loadGenericList("/api/terminal/history", "terminal-container", (item) => ` loadGenericList("/api/terminal/history", "terminal-container", (item) => `
<span style="font-weight:bold; font-size:1.05em; color:var(--text-primary);">${item.command}</span> <span style="font-weight:bold; font-size:1.05em; color:var(--text-primary);">${item.command}</span>
@@ -828,19 +827,6 @@ function loadADRs() {
${item.supersedes ? `<div style="font-size:0.9em; font-weight:bold; color:var(--warning-color);">Supersedes: ${item.supersedes}</div>` : ""} ${item.supersedes ? `<div style="font-size:0.9em; font-weight:bold; color:var(--warning-color);">Supersedes: ${item.supersedes}</div>` : ""}
`); `);
} }
function loadWorkspaces() {
loadGenericList("/api/context_workspaces", "workspaces-container", (item) => `
<strong>${item.name}</strong>
<div style="margin-bottom:8px;">${item.description || ""}</div>
<div style="font-family:monospace; font-size:0.85em; color:#3498db;">${(item.paths || []).join(", ")}</div>
`);
}
function loadPinned() {
loadGenericList("/api/pinned_files", "pinned-container", (item) => `
<strong>${item.path || item.file_path || item.id}</strong>
<div style="margin-top:5px; font-size:0.85em; color:var(--text-secondary);">${item.reason || item.description || "Pinned"}</div>
`);
}
function loadMemos() { function loadMemos() {
loadGenericList("/api/handoff_memos", "memos-container", (item) => ` loadGenericList("/api/handoff_memos", "memos-container", (item) => `
<strong>Memo from ${item.author || "System"}</strong> <strong>Memo from ${item.author || "System"}</strong>
@@ -858,27 +844,13 @@ function loadSnippets() {
<pre style="background:#111417; color:#d1d8e0; padding:10px; border-radius:4px; overflow-x:auto; font-size:0.85em;">${item.content || item.code || ""}</pre> <pre style="background:#111417; color:#d1d8e0; padding:10px; border-radius:4px; overflow-x:auto; font-size:0.85em;">${item.content || item.code || ""}</pre>
`); `);
} }
function loadPRs() {
loadGenericList("/api/pr_checklists", "pr-container", (item) => `
<strong>${item.name || "Checklist"}</strong>
<ul style="padding-left:20px; font-size:0.9em;">
${(item.items || []).map((i) => {
const check = i.is_completed ? "☑" : "☐";
const strike = i.is_completed ? "text-decoration:line-through; color:var(--text-secondary);" : "";
return `<li style="${strike}">${check} ${i.description}</li>`;
}).join("")}
</ul>
`);
}
function loadAllExtras() { function loadAllExtras() {
loadTerminal(); loadTerminal();
loadTechDebt(); loadTechDebt();
loadADRs(); loadADRs();
loadWorkspaces();
loadPinned();
loadMemos(); loadMemos();
loadSnippets(); loadSnippets();
loadPRs(); loadLedger();
} }
async function testClipboard() { async function testClipboard() {
const modal = document.getElementById("clipboard-modal"); const modal = document.getElementById("clipboard-modal");
@@ -962,35 +934,23 @@ function refreshActiveTab() {
case "task-tab": case "task-tab":
loadTasks(); loadTasks();
break; break;
case "sticky-tab":
loadStickyNotes();
break;
case "techdebt-tab": case "techdebt-tab":
loadTechDebt(); loadTechDebt();
break; break;
case "adrs-tab": case "adrs-tab":
loadADRs(); loadADRs();
break; break;
case "workspaces-tab":
loadWorkspaces();
break;
case "pinned-tab":
loadPinned();
break;
case "memos-tab": case "memos-tab":
loadMemos(); loadMemos();
break; break;
case "snippets-tab": case "snippets-tab":
loadSnippets(); loadSnippets();
break; break;
case "pr-tab":
loadPRs();
break;
case "terminal-tab": case "terminal-tab":
loadTerminal(); loadTerminal();
break; break;
case "preferences-tab": case "ledger-tab":
loadPreferences(); loadLedger();
break; break;
case "activity-tab": case "activity-tab":
loadActivityHistory(); loadActivityHistory();
@@ -1020,17 +980,13 @@ Object.assign(window, {
requestDomainRefresh, requestDomainRefresh,
handleIncomingActivity, handleIncomingActivity,
setupWS, setupWS,
loadPreferences,
loadStickyNotes,
loadGenericList, loadGenericList,
loadTerminal, loadTerminal,
loadLedger,
loadTechDebt, loadTechDebt,
loadADRs, loadADRs,
loadWorkspaces,
loadPinned,
loadMemos, loadMemos,
loadSnippets, loadSnippets,
loadPRs,
loadAllExtras, loadAllExtras,
testClipboard, testClipboard,
loadVersion, loadVersion,
@@ -1041,4 +997,7 @@ loadVersion();
loadActivityHistory(); loadActivityHistory();
setupWS(); setupWS();
var observer = new MutationObserver(() => updateGraphData()); var observer = new MutationObserver(() => updateGraphData());
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"]
});
+578 -408
View File
File diff suppressed because it is too large. Load diff
+98 -12
View File
@@ -4,7 +4,7 @@ use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
pub fn init_redb(base: &Path) -> Arc<Database> { pub fn init_redb(base: &Path) -> Arc<Database> {
let is_in_memory = base == Path::new(":memory:") || base.to_str() == Some(":memory:") || cfg!(test); let is_in_memory = base == Path::new(":memory:") || base.to_str() == Some(":memory:");
let db = if is_in_memory { let db = if is_in_memory {
Arc::new( Arc::new(
redb::Database::builder() redb::Database::builder()
@@ -13,17 +13,87 @@ pub fn init_redb(base: &Path) -> Arc<Database> {
) )
} else { } else {
let redb_path = base.join("mcp_store.redb"); let redb_path = base.join("mcp_store.redb");
if redb_path.exists() {
match redb::Database::open(&redb_path) {
Ok(db) => Arc::new(db),
Err(open_err) => {
let err_msg = format!(
"Failed to open existing redb database at {:?}: {}. Attempting to recreate database.",
redb_path, open_err
);
tracing::warn!("{}", err_msg);
match redb::Database::create(&redb_path) { match redb::Database::create(&redb_path) {
Ok(db) => Arc::new(db), Ok(db) => Arc::new(db),
Err(e) => { Err(create_err) => {
let err_msg = format!( if std::env::var("MCP_ALLOW_TMP_FALLBACK").unwrap_or_default() == "1" {
"Failed to open or create redb database at {:?}: {}. Falling back to temporary store.", let temp_path = std::env::temp_dir().join(format!(
redb_path, e "mcp_store_fallback_{}.redb",
uuid::Uuid::new_v4()
));
tracing::warn!(
"CRITICAL PERSISTENCE ALERT: MCP_ALLOW_TMP_FALLBACK=1 set. Using temporary redb database {:?}. Changes will be discarded upon application exit.",
temp_path
); );
tracing::error!("{}", err_msg); Arc::new(
let temp_path = std::env::temp_dir().join(format!("mcp_store_fallback_{}.redb", uuid::Uuid::new_v4())); redb::Database::create(&temp_path)
tracing::warn!("CRITICAL PERSISTENCE ALERT: Using temporary redb database {:?}. Changes will be discarded upon application exit.", temp_path); .expect("Failed to create fallback redb database"),
Arc::new(redb::Database::create(&temp_path).expect("Failed to create fallback redb database")) )
} else {
panic!(
"CRITICAL PERSISTENCE FAILURE: Unable to open or create primary database at {:?}: (open: {}, create: {}). To prevent silent data loss on restart, the application cannot start without accessible persistence.",
redb_path, open_err, create_err
);
}
}
}
}
}
} else {
match redb::Database::create(&redb_path) {
Ok(db) => Arc::new(db),
Err(create_err) => {
let mut db_opt = None;
let mut last_open_err = String::new();
for attempt in 1..=3 {
match redb::Database::open(&redb_path) {
Ok(db) => {
db_opt = Some(Arc::new(db));
break;
}
Err(open_err) => {
last_open_err = open_err.to_string();
if attempt < 3 {
tracing::warn!(
"Transient lock contention opening redb at {:?} (attempt {}/3: {}). Retrying...",
redb_path,
attempt,
open_err
);
std::thread::sleep(std::time::Duration::from_millis(150));
}
}
}
}
if let Some(db) = db_opt {
db
} else if std::env::var("MCP_ALLOW_TMP_FALLBACK").unwrap_or_default() == "1" {
let temp_path = std::env::temp_dir()
.join(format!("mcp_store_fallback_{}.redb", uuid::Uuid::new_v4()));
tracing::warn!(
"CRITICAL PERSISTENCE ALERT: MCP_ALLOW_TMP_FALLBACK=1 set. Using temporary redb database {:?}. Changes will be discarded upon application exit.",
temp_path
);
Arc::new(
redb::Database::create(&temp_path)
.expect("Failed to create fallback redb database"),
)
} else {
panic!(
"CRITICAL PERSISTENCE FAILURE: Unable to open or create primary database at {:?}: (create: {}, open: {}). To prevent silent data loss on restart, the application cannot start without accessible persistence.",
redb_path, create_err, last_open_err
);
}
}
} }
} }
}; };
@@ -55,6 +125,9 @@ pub fn init_redb(base: &Path) -> Arc<Database> {
("tech_debts", "tech_debts.json"), ("tech_debts", "tech_debts.json"),
("gates", "gates.json"), ("gates", "gates.json"),
("context_workspaces", "context_workspaces.json"), ("context_workspaces", "context_workspaces.json"),
("state_snapshots", "state_snapshots.json"),
("hypotheses", "hypotheses.json"),
("agent_signals", "agent_signals.json"),
]; ];
for (key, file_name) in stores.iter() { for (key, file_name) in stores.iter() {
@@ -73,9 +146,19 @@ pub fn init_redb(base: &Path) -> Arc<Database> {
&& serde_json::from_slice::<serde_json::Value>(&data).is_ok() && serde_json::from_slice::<serde_json::Value>(&data).is_ok()
{ {
if let Err(e) = table.insert(*key, data.as_slice()) { if let Err(e) = table.insert(*key, data.as_slice()) {
tracing::error!("Failed to insert migrated key '{}': {}", key, e); tracing::error!(
"Failed to insert migrated key '{}': {}",
key,
e
);
} else { } else {
let _ = std::fs::rename(&json_path, json_path.with_extension("json.migrated")); let migrated_path = json_path.with_extension("json.migrated");
if let Err(_) = std::fs::rename(&json_path, &migrated_path) {
if migrated_path.exists() {
let _ = std::fs::remove_file(&migrated_path);
let _ = std::fs::rename(&json_path, &migrated_path);
}
}
} }
} }
} }
@@ -89,7 +172,10 @@ pub fn init_redb(base: &Path) -> Arc<Database> {
} }
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to begin write transaction for redb migration: {}", e); tracing::error!(
"Failed to begin write transaction for redb migration: {}",
e
);
} }
} }
+276 -20
View File
@@ -1,29 +1,250 @@
#[allow(deprecated)]
use fastembed::{EmbeddingModel, TextEmbedding};
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::OnceLock; use std::sync::OnceLock;
static EMBEDDING_MODEL: OnceLock<Mutex<TextEmbedding>> = OnceLock::new(); use candle_core::{DType, Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::bert::{BertModel, Config};
use tokenizers::Tokenizer;
#[allow(deprecated)] struct CandleEmbeddingModel {
pub fn get_embedding_model() -> Result<&'static Mutex<TextEmbedding>, String> { model: BertModel,
tokenizer: Tokenizer,
device: Device,
}
impl CandleEmbeddingModel {
fn new() -> Result<Self, String> {
let client = hf_hub::HFClientSync::new().map_err(|e| e.to_string())?;
let repo = client.model("sentence-transformers", "all-MiniLM-L6-v2");
let config_file = repo
.download_file()
.filename("config.json")
.send()
.map_err(|e| format!("Failed to download config.json: {}", e))?;
let tokenizer_file = repo
.download_file()
.filename("tokenizer.json")
.send()
.map_err(|e| format!("Failed to download tokenizer.json: {}", e))?;
let weights_file = repo
.download_file()
.filename("model.safetensors")
.send()
.map_err(|e| format!("Failed to download model.safetensors: {}", e))?;
let config_str = std::fs::read_to_string(&config_file)
.map_err(|e| format!("Failed to read config.json: {}", e))?;
let config: Config = serde_json::from_str(&config_str)
.map_err(|e| format!("Failed to parse config.json: {}", e))?;
let mut tokenizer = Tokenizer::from_file(&tokenizer_file)
.map_err(|e| format!("Failed to load tokenizer: {}", e))?;
tokenizer.with_padding(Some(tokenizers::PaddingParams::default()));
let device = Device::Cpu;
let vb = unsafe {
VarBuilder::from_mmaped_safetensors(&[weights_file], DType::F32, &device)
.map_err(|e| format!("Failed to load safetensors: {}", e))?
};
let model =
BertModel::load(vb, &config).map_err(|e| format!("Failed to load BertModel: {}", e))?;
Ok(Self {
model,
tokenizer,
device,
})
}
fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, String> {
if texts.is_empty() {
return Ok(Vec::new());
}
let encodings = self
.tokenizer
.encode_batch(texts.to_vec(), true)
.map_err(|e| format!("Failed to encode texts: {}", e))?;
let batch_size = encodings.len();
if batch_size == 0 {
return Ok(Vec::new());
}
let seq_len = encodings[0].get_ids().len();
if seq_len == 0 {
return Ok(vec![vec![0.0; 384]; batch_size]);
}
let mut all_ids: Vec<u32> = Vec::with_capacity(batch_size * seq_len);
let mut all_type_ids: Vec<u32> = Vec::with_capacity(batch_size * seq_len);
let mut all_attention_mask: Vec<u32> = Vec::with_capacity(batch_size * seq_len);
for enc in &encodings {
all_ids.extend(enc.get_ids());
all_type_ids.extend(enc.get_type_ids());
all_attention_mask.extend(enc.get_attention_mask());
}
let input_ids = Tensor::from_vec(all_ids, (batch_size, seq_len), &self.device)
.map_err(|e| format!("Failed to build input_ids tensor: {}", e))?;
let token_type_ids = Tensor::from_vec(all_type_ids, (batch_size, seq_len), &self.device)
.map_err(|e| format!("Failed to build token_type_ids tensor: {}", e))?;
let attention_mask =
Tensor::from_vec(all_attention_mask, (batch_size, seq_len), &self.device)
.map_err(|e| format!("Failed to build attention_mask tensor: {}", e))?;
let sequence_output = self
.model
.forward(&input_ids, &token_type_ids, Some(&attention_mask))
.map_err(|e| format!("Bert forward failed: {}", e))?;
// Mean pooling: sum(sequence_output * mask) / clamp(sum(mask), min=1e-9)
let mask_f32 = attention_mask
.to_dtype(DType::F32)
.map_err(|e| e.to_string())?
.unsqueeze(2)
.map_err(|e| e.to_string())?;
let sum_embeddings = sequence_output
.broadcast_mul(&mask_f32)
.map_err(|e| e.to_string())?
.sum(1)
.map_err(|e| e.to_string())?;
let sum_mask = mask_f32
.sum(1)
.map_err(|e| e.to_string())?
.clamp(1e-9, f32::MAX)
.map_err(|e| e.to_string())?;
let mean_pooled = sum_embeddings
.broadcast_div(&sum_mask)
.map_err(|e| e.to_string())?;
// L2 Normalization
let norm = mean_pooled
.sqr()
.map_err(|e| e.to_string())?
.sum_keepdim(1)
.map_err(|e| e.to_string())?
.sqrt()
.map_err(|e| e.to_string())?;
let normalized = mean_pooled
.broadcast_div(&norm)
.map_err(|e| e.to_string())?;
normalized.to_vec2::<f32>().map_err(|e| e.to_string())
}
}
fn fallback_embed(text: &str) -> Vec<f32> {
const DIM: usize = 384;
let mut vec = vec![0.0f32; DIM];
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
vec[0] = 1.0;
return vec;
}
use std::hash::{Hash, Hasher};
for word in words {
let clean: String = word
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect();
if clean.is_empty() {
continue;
}
let mut hasher = std::collections::hash_map::DefaultHasher::new();
clean.hash(&mut hasher);
let h = hasher.finish();
let idx = (h as usize) % DIM;
let sign = if (h >> 32) & 1 == 0 { 1.0f32 } else { -1.0f32 };
vec[idx] += sign;
let chars: Vec<char> = clean.chars().collect();
for window in chars.windows(3) {
let mut h2 = std::collections::hash_map::DefaultHasher::new();
window.hash(&mut h2);
let hv = h2.finish();
let idx2 = (hv as usize) % DIM;
let s2 = if (hv >> 32) & 1 == 0 { 0.5f32 } else { -0.5f32 };
vec[idx2] += s2;
}
}
let norm_sq: f32 = vec.iter().map(|x| x * x).sum();
if norm_sq > 0.0 {
let norm = norm_sq.sqrt();
for x in vec.iter_mut() {
*x /= norm;
}
} else {
vec[0] = 1.0;
}
vec
}
enum EmbeddingModel {
Candle(CandleEmbeddingModel),
Fallback,
}
impl EmbeddingModel {
fn new() -> Self {
match CandleEmbeddingModel::new() {
Ok(model) => EmbeddingModel::Candle(model),
Err(e) => {
tracing::warn!(
"Failed to initialize Candle BERT model ({e}); falling back to deterministic offline embeddings."
);
EmbeddingModel::Fallback
}
}
}
fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, String> {
match self {
EmbeddingModel::Candle(model) => model.embed(texts),
EmbeddingModel::Fallback => Ok(texts.iter().map(|t| fallback_embed(t)).collect()),
}
}
}
static EMBEDDING_MODEL: OnceLock<Mutex<EmbeddingModel>> = OnceLock::new();
static INIT_MUTEX: Mutex<()> = Mutex::new(());
fn get_embedding_model() -> Result<&'static Mutex<EmbeddingModel>, String> {
if let Some(model) = EMBEDDING_MODEL.get() { if let Some(model) = EMBEDDING_MODEL.get() {
return Ok(model); return Ok(model);
} }
let options = fastembed::InitOptions::new(EmbeddingModel::AllMiniLML6V2) let _guard = INIT_MUTEX.lock().map_err(|e| e.to_string())?;
.with_show_download_progress(true); if let Some(model) = EMBEDDING_MODEL.get() {
return Ok(model);
let model = TextEmbedding::try_new(options).map_err(|e| e.to_string())?; }
let model = EmbeddingModel::new();
let _ = EMBEDDING_MODEL.set(Mutex::new(model)); let _ = EMBEDDING_MODEL.set(Mutex::new(model));
Ok(EMBEDDING_MODEL.get().unwrap()) Ok(EMBEDDING_MODEL.get().unwrap())
} }
pub async fn generate_embedding_async(text: String) -> Result<Vec<f32>, String> { pub async fn generate_embedding_async(text: String) -> Result<Vec<f32>, String> {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let model_mutex = get_embedding_model()?; let model_mutex = get_embedding_model()?;
let mut model = model_mutex.lock().map_err(|e| e.to_string())?; let model = model_mutex.lock().map_err(|e| e.to_string())?;
let embeddings = model.embed(vec![text], None).map_err(|e| e.to_string())?; let embeddings = model.embed(&[text])?;
Ok(embeddings.into_iter().next().unwrap_or_default()) let emb = embeddings
.into_iter()
.next()
.ok_or_else(|| "Embedding model returned no embeddings".to_string())?;
if emb.is_empty() {
return Err("Embedding model generated a 0-length vector".to_string());
}
Ok(emb)
}) })
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@@ -36,11 +257,29 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let mut dot_product = 0.0f32; let mut dot_product = 0.0f32;
let mut norm_a_sq = 0.0f32; let mut norm_a_sq = 0.0f32;
let mut norm_b_sq = 0.0f32; let mut norm_b_sq = 0.0f32;
for (&x, &y) in a.iter().zip(b.iter()) {
let chunks_a = a.chunks_exact(8);
let chunks_b = b.chunks_exact(8);
let remainder_a = chunks_a.remainder();
let remainder_b = chunks_b.remainder();
for (ca, cb) in chunks_a.zip(chunks_b) {
for i in 0..8 {
let x = ca[i];
let y = cb[i];
dot_product += x * y; dot_product += x * y;
norm_a_sq += x * x; norm_a_sq += x * x;
norm_b_sq += y * y; norm_b_sq += y * y;
} }
}
for (&x, &y) in remainder_a.iter().zip(remainder_b.iter()) {
dot_product += x * y;
norm_a_sq += x * x;
norm_b_sq += y * y;
}
let norm_a = norm_a_sq.sqrt(); let norm_a = norm_a_sq.sqrt();
let norm_b = norm_b_sq.sqrt(); let norm_b = norm_b_sq.sqrt();
if norm_a == 0.0 || norm_b == 0.0 { if norm_a == 0.0 || norm_b == 0.0 {
@@ -49,19 +288,38 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
dot_product / (norm_a * norm_b) dot_product / (norm_a * norm_b)
} }
} }
pub async fn generate_embeddings_async(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> { pub async fn generate_embeddings_async(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
if texts.is_empty() { if texts.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let model_mutex = get_embedding_model()?; let model_mutex = get_embedding_model()?;
let mut model = model_mutex.lock().map_err(|e| e.to_string())?; let model = model_mutex.lock().map_err(|e| e.to_string())?;
let mut all_embeddings = Vec::with_capacity(texts.len()); let mut all_embeddings = Vec::with_capacity(texts.len());
for chunk in texts.chunks(32) {
let chunk_vec = chunk.to_vec(); let mut current_chunk = Vec::new();
let chunk_embeddings = model.embed(chunk_vec, None).map_err(|e| e.to_string())?; let mut current_chars = 0;
const MAX_CHARS_PER_BATCH: usize = 16384;
for text in texts {
let text_len = text.len();
if !current_chunk.is_empty()
&& (current_chunk.len() >= 64 || current_chars + text_len > MAX_CHARS_PER_BATCH)
{
let chunk_vec = std::mem::take(&mut current_chunk);
let chunk_embeddings = model.embed(&chunk_vec)?;
all_embeddings.extend(chunk_embeddings);
current_chars = 0;
}
current_chars += text_len;
current_chunk.push(text);
}
if !current_chunk.is_empty() {
let chunk_embeddings = model.embed(&current_chunk)?;
all_embeddings.extend(chunk_embeddings); all_embeddings.extend(chunk_embeddings);
} }
Ok(all_embeddings) Ok(all_embeddings)
}) })
.await .await
@@ -93,7 +351,6 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_generate_embeddings_async_single_text() { async fn test_generate_embeddings_async_single_text() {
let text = "test text".to_string(); let text = "test text".to_string();
let res = generate_embeddings_async(vec![text.clone()]).await.unwrap(); let res = generate_embeddings_async(vec![text.clone()]).await.unwrap();
@@ -115,4 +372,3 @@ mod tests {
} }
} }
} }
+135 -24
View File
@@ -6,6 +6,24 @@ use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
use tree_sitter::{Node, Parser}; use tree_sitter::{Node, Parser};
fn validate_safe_path(path_str: &str) -> crate::error::Result<()> {
if path_str.trim().is_empty() || path_str.contains('\0') {
return Err(crate::error::AppError::Internal(
"Invalid file path: path is empty or contains null characters".to_string(),
));
}
let path = std::path::Path::new(path_str);
for component in path.components() {
if component == std::path::Component::ParentDir {
return Err(crate::error::AppError::Internal(format!(
"Path traversal forbidden: '{}' contains parent directory relative components",
path_str
)));
}
}
Ok(())
}
pub struct ReadFileSkeletonHandler; pub struct ReadFileSkeletonHandler;
#[async_trait] #[async_trait]
@@ -26,13 +44,13 @@ impl McpTool for ReadFileSkeletonHandler {
serde_json::from_value(args).map_err(|e| e.to_string())?; serde_json::from_value(args).map_err(|e| e.to_string())?;
let file_path = tool_args.file_path.clone(); let file_path = tool_args.file_path.clone();
validate_safe_path(&file_path)?;
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> { let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
let code = std::fs::read_to_string(&file_path).map_err(|e| { let code = std::fs::read_to_string(&file_path).map_err(|e| {
crate::error::AppError::Internal(format!("Failed to read file: {}", e)) crate::error::AppError::Internal(format!("Failed to read file: {}", e))
})?; })?;
let mut parser = Parser::new();
let ext = std::path::Path::new(&file_path) let ext = std::path::Path::new(&file_path)
.extension() .extension()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
@@ -49,10 +67,10 @@ impl McpTool for ReadFileSkeletonHandler {
_ => return Ok(code), _ => return Ok(code),
}; };
let mut parser = tree_sitter::Parser::new();
parser parser
.set_language(&language.into()) .set_language(&language.into())
.map_err(|e| e.to_string())?; .map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
let tree = parser.parse(&code, None).ok_or_else(|| { let tree = parser.parse(&code, None).ok_or_else(|| {
crate::error::AppError::Internal("Failed to parse code".to_string()) crate::error::AppError::Internal("Failed to parse code".to_string())
})?; })?;
@@ -150,6 +168,8 @@ impl McpTool for ReplaceAstNodeHandler {
serde_json::from_value(args).map_err(|e| e.to_string())?; serde_json::from_value(args).map_err(|e| e.to_string())?;
let file_path = tool_args.file_path.clone(); let file_path = tool_args.file_path.clone();
validate_safe_path(&file_path)?;
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> { let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
let code = std::fs::read_to_string(&file_path).map_err(|e| { let code = std::fs::read_to_string(&file_path).map_err(|e| {
crate::error::AppError::Internal(format!("Failed to read file: {}", e)) crate::error::AppError::Internal(format!("Failed to read file: {}", e))
@@ -198,8 +218,14 @@ impl McpTool for ReplaceAstNodeHandler {
| "method_definition" | "method_definition"
| "function" | "function"
), ),
"struct" => matches!(actual_kind, "struct_item" | "struct_declaration" | "struct_specifier"), "struct" => matches!(
"class" => matches!(actual_kind, "class_declaration" | "class_definition" | "class_item"), actual_kind,
"struct_item" | "struct_declaration" | "struct_specifier"
),
"class" => matches!(
actual_kind,
"class_declaration" | "class_definition" | "class_item"
),
"enum" => matches!(actual_kind, "enum_item" | "enum_declaration"), "enum" => matches!(actual_kind, "enum_item" | "enum_declaration"),
"trait" | "interface" => matches!( "trait" | "interface" => matches!(
actual_kind, actual_kind,
@@ -225,7 +251,11 @@ impl McpTool for ReplaceAstNodeHandler {
let mut cursor = node.walk(); let mut cursor = node.walk();
for child in node.children(&mut cursor) { for child in node.children(&mut cursor) {
let kind = child.kind(); let kind = child.kind();
if kind == "identifier" || kind == "name" || kind == "property_identifier" || kind == "field_identifier" { if kind == "identifier"
|| kind == "name"
|| kind == "property_identifier"
|| kind == "field_identifier"
{
let name = child.utf8_text(code.as_bytes()).unwrap_or(""); let name = child.utf8_text(code.as_bytes()).unwrap_or("");
if name == target_name { if name == target_name {
return Some(node); return Some(node);
@@ -254,12 +284,38 @@ impl McpTool for ReplaceAstNodeHandler {
let start_byte = node.start_byte(); let start_byte = node.start_byte();
let end_byte = node.end_byte(); let end_byte = node.end_byte();
let mut new_file_content = String::new(); if !code.is_char_boundary(start_byte) || !code.is_char_boundary(end_byte) {
return Err(crate::error::AppError::Internal(format!(
"Byte offsets {}..{} do not fall on UTF-8 character boundaries in {}",
start_byte, end_byte, file_path
)));
}
let mut new_file_content =
String::with_capacity(code.len() + tool_args.new_content.len());
new_file_content.push_str(&code[..start_byte]); new_file_content.push_str(&code[..start_byte]);
new_file_content.push_str(&tool_args.new_content); new_file_content.push_str(&tool_args.new_content);
new_file_content.push_str(&code[end_byte..]); new_file_content.push_str(&code[end_byte..]);
std::fs::write(&file_path, new_file_content).map_err(|e| e.to_string())?; let target_path = std::path::PathBuf::from(&file_path);
let parent_dir = target_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let temp_file_path =
parent_dir.join(format!(".tmp_ast_{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&temp_file_path, new_file_content).map_err(|e| e.to_string())?;
if let Err(e) = std::fs::rename(&temp_file_path, &target_path) {
// On Windows, std::fs::rename fails if the target file already exists.
// Fall back to copy-and-remove to ensure atomic-like overwrite behavior.
if let Err(copy_err) = std::fs::copy(&temp_file_path, &target_path) {
let _ = std::fs::remove_file(&temp_file_path);
return Err(crate::error::AppError::Internal(format!(
"Failed to atomically overwrite {}: rename failed ({}), copy failed ({})",
file_path, e, copy_err
)));
}
let _ = std::fs::remove_file(&temp_file_path);
}
Ok(format!( Ok(format!(
"Successfully replaced node {} of type {} in {}", "Successfully replaced node {} of type {} in {}",
tool_args.node_name, tool_args.node_type, file_path tool_args.node_name, tool_args.node_type, file_path
@@ -278,20 +334,31 @@ impl McpTool for ReplaceAstNodeHandler {
} }
} }
fn scan_workspace_for_symbol(target_sym: &str, limit: usize, filter_fn_call: bool) -> Vec<serde_json::Value> { fn scan_workspace_for_symbol(
target_sym: &str,
limit: usize,
filter_fn_call: bool,
workspace_dir: Option<std::path::PathBuf>,
) -> Vec<serde_json::Value> {
let mut results = Vec::new(); let mut results = Vec::new();
let cwd = match std::env::current_dir() { let scan_dir = workspace_dir.unwrap_or_else(|| {
Ok(dir) => dir, std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
Err(_) => return results, });
};
let walker = ignore::WalkBuilder::new(&cwd) if !scan_dir.exists() {
return results;
}
let walker = ignore::WalkBuilder::new(&scan_dir)
.hidden(true) .hidden(true)
.git_ignore(true) .git_ignore(true)
.build(); .build();
let mut scanned_files = 0; let mut scanned_files = 0;
let call_pattern = format!("{}(", target_sym);
let await_pattern = format!("{}.await", target_sym);
for result in walker { for result in walker {
let entry = match result { let entry = match result {
Ok(e) => e, Ok(e) => e,
@@ -301,7 +368,20 @@ fn scan_workspace_for_symbol(target_sym: &str, limit: usize, filter_fn_call: boo
if entry.file_type().is_some_and(|ft| ft.is_file()) { if entry.file_type().is_some_and(|ft| ft.is_file()) {
let path = entry.path(); let path = entry.path();
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or(""); let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
if matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "java" | "c" | "cpp" | "h" | "hpp") { if matches!(
ext,
"rs" | "ts"
| "tsx"
| "js"
| "jsx"
| "py"
| "go"
| "java"
| "c"
| "cpp"
| "h"
| "hpp"
) {
scanned_files += 1; scanned_files += 1;
if scanned_files > 500 { if scanned_files > 500 {
break; break;
@@ -309,8 +389,7 @@ fn scan_workspace_for_symbol(target_sym: &str, limit: usize, filter_fn_call: boo
if let Ok(content) = std::fs::read_to_string(path) { if let Ok(content) = std::fs::read_to_string(path) {
for (line_num, line) in content.lines().enumerate() { for (line_num, line) in content.lines().enumerate() {
let is_match = if filter_fn_call { let is_match = if filter_fn_call {
line.contains(&format!("{}(", target_sym)) line.contains(&call_pattern) || line.contains(&await_pattern)
|| line.contains(&format!("{}.await", target_sym))
} else { } else {
line.contains(target_sym) line.contains(target_sym)
}; };
@@ -371,10 +450,22 @@ impl McpTool for FindSymbolReferencesHandler {
Ok::<Vec<serde_json::Value>, crate::error::AppError>(refs) Ok::<Vec<serde_json::Value>, crate::error::AppError>(refs)
})?; })?;
let custom_dir = if let Some(ref d) = req.workspace_dir {
Some(std::path::PathBuf::from(d))
} else {
state.project.pinned_files.read_with(|pinned| {
pinned.first().and_then(|pin| {
std::path::Path::new(&pin.file_path)
.parent()
.map(|p| p.to_path_buf())
})
})
};
if matches.len() < limit { if matches.len() < limit {
let remaining = limit - matches.len(); let remaining = limit - matches.len();
let disk_matches = tokio::task::spawn_blocking(move || { let disk_matches = tokio::task::spawn_blocking(move || {
scan_workspace_for_symbol(&target_sym, remaining, false) scan_workspace_for_symbol(&target_sym, remaining, false, custom_dir)
}) })
.await .await
.unwrap_or_default(); .unwrap_or_default();
@@ -405,13 +496,13 @@ impl McpTool for GetCallersHandler {
serde_json::from_value(args).map_err(|e| e.to_string())?; serde_json::from_value(args).map_err(|e| e.to_string())?;
let limit = req.limit.unwrap_or(10); let limit = req.limit.unwrap_or(10);
let target_fn = req.function_name.clone(); let target_fn = req.function_name.clone();
let call_pattern = format!("{}(", target_fn);
let await_pattern = format!("{}.await", target_fn);
let mut callers = state.code.snippets.read_with(|snippets| { let mut callers = state.code.snippets.read_with(|snippets| {
let mut matching = Vec::new(); let mut matching = Vec::new();
for snippet in snippets { for snippet in snippets {
if snippet.code.contains(&format!("{}(", target_fn)) if snippet.code.contains(&call_pattern) || snippet.code.contains(&await_pattern) {
|| snippet.code.contains(&format!("{}.await", target_fn))
{
matching.push(serde_json::json!({ matching.push(serde_json::json!({
"source": "snippet", "source": "snippet",
"name": snippet.name, "name": snippet.name,
@@ -425,10 +516,22 @@ impl McpTool for GetCallersHandler {
Ok::<Vec<serde_json::Value>, crate::error::AppError>(matching) Ok::<Vec<serde_json::Value>, crate::error::AppError>(matching)
})?; })?;
let custom_dir = if let Some(ref d) = req.workspace_dir {
Some(std::path::PathBuf::from(d))
} else {
state.project.pinned_files.read_with(|pinned| {
pinned.first().and_then(|pin| {
std::path::Path::new(&pin.file_path)
.parent()
.map(|p| p.to_path_buf())
})
})
};
if callers.len() < limit { if callers.len() < limit {
let remaining = limit - callers.len(); let remaining = limit - callers.len();
let disk_callers = tokio::task::spawn_blocking(move || { let disk_callers = tokio::task::spawn_blocking(move || {
scan_workspace_for_symbol(&target_fn, remaining, true) scan_workspace_for_symbol(&target_fn, remaining, true, custom_dir)
}) })
.await .await
.unwrap_or_default(); .unwrap_or_default();
@@ -468,9 +571,17 @@ impl McpTool for AnalyzeImpactHandler {
} }
}); });
let custom_dir = state.project.pinned_files.read_with(|pinned| {
pinned.first().and_then(|pin| {
std::path::Path::new(&pin.file_path)
.parent()
.map(|p| p.to_path_buf())
})
});
let sym_clone = sym.clone(); let sym_clone = sym.clone();
let disk_refs = tokio::task::spawn_blocking(move || { let disk_refs = tokio::task::spawn_blocking(move || {
scan_workspace_for_symbol(&sym_clone, 20, false) scan_workspace_for_symbol(&sym_clone, 20, false, custom_dir)
}) })
.await .await
.unwrap_or_default(); .unwrap_or_default();
+4
View File
@@ -32,6 +32,7 @@ impl McpTool for UpdateEnvFingerprintHandler {
shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()), shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()),
tool_versions: req.tool_versions, tool_versions: req.tool_versions,
updated_at: crate::handlers::utils::now_secs(), updated_at: crate::handlers::utils::now_secs(),
..Default::default()
}, },
); );
}); });
@@ -94,6 +95,7 @@ impl McpTool for LogEnvRequirementHandler {
key: req.key, key: req.key,
description: req.description, description: req.description,
is_secret: req.is_secret, is_secret: req.is_secret,
..Default::default()
}); });
}); });
Ok("Env requirement logged".to_string()) Ok("Env requirement logged".to_string())
@@ -127,6 +129,7 @@ impl McpTool for RegisterEnvironmentHandler {
description: req.description, description: req.description,
requires_vpn: req.requires_vpn, requires_vpn: req.requires_vpn,
updated_at: crate::handlers::utils::now_secs(), updated_at: crate::handlers::utils::now_secs(),
..Default::default()
}); });
}); });
Ok("Environment registered".to_string()) Ok("Environment registered".to_string())
@@ -262,6 +265,7 @@ mod tests {
description: "Test env".to_string(), description: "Test env".to_string(),
requires_vpn: false, requires_vpn: false,
updated_at: crate::handlers::utils::now_secs(), updated_at: crate::handlers::utils::now_secs(),
..Default::default()
}); });
}); });
+4
View File
@@ -55,6 +55,10 @@ impl McpTool for GetActiveWorktreeContextHandler {
if let Some(diff) = diff { if let Some(diff) = diff {
let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
match line.origin() {
'+' | '-' | ' ' => diff_output.push(line.origin()),
_ => {}
}
let content = std::str::from_utf8(line.content()).unwrap_or(""); let content = std::str::from_utf8(line.content()).unwrap_or("");
diff_output.push_str(content); diff_output.push_str(content);
true true
+89 -27
View File
@@ -38,21 +38,7 @@ impl<'a> GraphQueryBuilder<'a> {
} }
pub fn find_shortest_path(&self, start: &str, end: &str) -> Option<Vec<String>> { pub fn find_shortest_path(&self, start: &str, end: &str) -> Option<Vec<String>> {
let mut adj: std::collections::HashMap<&str, Vec<(&str, &str, bool)>> = let adj = self.graph.build_adjacency_map(self.relation_filter);
std::collections::HashMap::with_capacity(self.graph.relations.len() * 2);
for rel in &self.graph.relations {
if let Some(rf) = self.relation_filter {
if rel.relation_type != rf {
continue;
}
}
adj.entry(rel.from.as_str())
.or_default()
.push((rel.to.as_str(), rel.relation_type.as_str(), false));
adj.entry(rel.to.as_str())
.or_default()
.push((rel.from.as_str(), rel.relation_type.as_str(), true));
}
let mut queue = std::collections::VecDeque::new(); let mut queue = std::collections::VecDeque::new();
let mut visited = std::collections::HashSet::new(); let mut visited = std::collections::HashSet::new();
@@ -253,6 +239,7 @@ impl McpTool for CreateRelationsHandler {
observations: vec!["Auto-created stub entity for relation endpoint".to_string()], observations: vec!["Auto-created stub entity for relation endpoint".to_string()],
namespace: crate::models::default_namespace(), namespace: crate::models::default_namespace(),
git_branch: None, git_branch: None,
..Default::default()
}, },
); );
auto_created.push(node_name); auto_created.push(node_name);
@@ -328,7 +315,10 @@ impl McpTool for DeleteEntitiesHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<DeleteEntitiesTool>("delete_entities", "Execute delete_entities") crate::mcp::tool_def::<DeleteEntitiesTool>(
"delete_entities",
"Delete specified entities and all their associated relations and observations from the knowledge graph.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -378,7 +368,7 @@ impl McpTool for DeleteObservationsHandler {
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<DeleteObservationsTool>( crate::mcp::tool_def::<DeleteObservationsTool>(
"delete_observations", "delete_observations",
"Execute delete_observations", "Delete specific observations from existing entities in the knowledge graph without removing the entity itself.",
) )
} }
@@ -422,7 +412,10 @@ impl McpTool for DeleteRelationsHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<DeleteRelationsTool>("delete_relations", "Execute delete_relations") crate::mcp::tool_def::<DeleteRelationsTool>(
"delete_relations",
"Delete specific relationships between entities in the knowledge graph by specifying source 'from', target 'to', and 'relation_type'.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -462,8 +455,45 @@ impl McpTool for ReadGraphHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ReadGraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: ReadGraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let max_tokens = req.max_tokens; let max_tokens = req.max_tokens;
let limit = req.limit;
let offset = req.offset.unwrap_or(0);
let data = state.read_graph(|full| -> crate::error::Result<String> { let data = state.read_graph(|full| -> crate::error::Result<String> {
let mut result_json = if let Some(ns) = req.namespace { let mut result_json = if limit.is_some() || req.offset.is_some() {
let filtered_entities: Vec<(&String, &crate::models::Entity)> = full
.entities
.iter()
.filter(|(_, v)| req.namespace.as_ref().map_or(true, |ns| &v.namespace == ns))
.collect();
let total_count = filtered_entities.len();
let effective_limit = limit.unwrap_or(50);
let paged_entities: std::collections::HashMap<&String, &crate::models::Entity> = filtered_entities
.into_iter()
.skip(offset)
.take(effective_limit)
.collect();
let has_more = offset + paged_entities.len() < total_count;
let relevant_relations: Vec<&crate::models::Relation> = full
.relations
.iter()
.filter(|r| {
let ns_match = req.namespace.as_ref().map_or(true, |ns| &r.namespace == ns);
ns_match && (paged_entities.contains_key(&r.from) || paged_entities.contains_key(&r.to))
})
.collect();
serde_json::to_string(&serde_json::json!({
"entities": paged_entities,
"relations": relevant_relations,
"total_count": total_count,
"offset": offset,
"limit": effective_limit,
"has_more": has_more,
}))?
} else if let Some(ns) = req.namespace {
let mut filtered = BorrowedGraph::default(); let mut filtered = BorrowedGraph::default();
for (k, v) in &full.entities { for (k, v) in &full.entities {
if v.namespace == ns { if v.namespace == ns {
@@ -485,7 +515,7 @@ impl McpTool for ReadGraphHandler {
if result_json.len() > max_chars { if result_json.len() > max_chars {
let valid_boundary = result_json.floor_char_boundary(max_chars); let valid_boundary = result_json.floor_char_boundary(max_chars);
result_json.truncate(valid_boundary); result_json.truncate(valid_boundary);
result_json.push_str("\n... [TRUNCATED_TO_MAX_TOKENS. Use search_nodes or get_subgraph for targeted discovery]"); result_json.push_str("\n... [TRUNCATED_TO_MAX_TOKENS. Use search_nodes, pagination (limit, offset), or get_subgraph for targeted discovery]");
} }
} }
Ok(result_json) Ok(result_json)
@@ -503,7 +533,10 @@ impl McpTool for SearchNodesHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<SearchNodesTool>("search_nodes", "Execute search_nodes") crate::mcp::tool_def::<SearchNodesTool>(
"search_nodes",
"Search knowledge graph entities and nodes by name or observation query. Returns matching entity names, types, and observations.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -550,7 +583,10 @@ impl McpTool for OpenNodesHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<OpenNodesTool>("open_nodes", "Execute open_nodes") crate::mcp::tool_def::<OpenNodesTool>(
"open_nodes",
"Retrieve complete node details, observations, and connected relations for specific entities in the knowledge graph.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -588,7 +624,10 @@ impl McpTool for VisualizeGraphHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<VisualizeGraphTool>("visualize_graph", "Execute visualize_graph") crate::mcp::tool_def::<VisualizeGraphTool>(
"visualize_graph",
"Generate a visual Mermaid representation of the knowledge graph topology or a filtered query subset.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -677,7 +716,10 @@ impl McpTool for CondenseEntityHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<CondenseEntityTool>("condense_entity", "Execute condense_entity") crate::mcp::tool_def::<CondenseEntityTool>(
"condense_entity",
"Condense, summarize, and deduplicate redundant observations on an entity to optimize context size while preserving key facts.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -757,7 +799,10 @@ impl McpTool for MergeEntitiesHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<MergeEntitiesTool>("merge_entities", "Execute merge_entities") crate::mcp::tool_def::<MergeEntitiesTool>(
"merge_entities",
"Merge two duplicate entities into a primary target entity, consolidating all observations and re-pointing relations.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -817,7 +862,10 @@ impl McpTool for FindOrphansHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<FindOrphansTool>("find_orphans", "Execute find_orphans") crate::mcp::tool_def::<FindOrphansTool>(
"find_orphans",
"Find orphan nodes in the knowledge graph that have no incoming or outgoing relationships.",
)
} }
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -847,7 +895,10 @@ impl McpTool for GetSubgraphHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<GetSubgraphTool>("get_subgraph", "Execute get_subgraph") crate::mcp::tool_def::<GetSubgraphTool>(
"get_subgraph",
"Extract a connected neighborhood subgraph centered around specified seed nodes up to a given hop depth.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -1188,6 +1239,7 @@ mod tests {
observations: vec![], observations: vec![],
namespace: "global".to_string(), namespace: "global".to_string(),
git_branch: None, git_branch: None,
..Default::default()
}, },
); );
g.entities.insert( g.entities.insert(
@@ -1198,6 +1250,7 @@ mod tests {
observations: vec![], observations: vec![],
namespace: "global".to_string(), namespace: "global".to_string(),
git_branch: None, git_branch: None,
..Default::default()
}, },
); );
}); });
@@ -1256,6 +1309,7 @@ mod tests {
observations: vec![], observations: vec![],
namespace: "global".to_string(), namespace: "global".to_string(),
git_branch: None, git_branch: None,
..Default::default()
}, },
); );
}); });
@@ -1447,6 +1501,14 @@ mod tests {
let qsignal_res = qsignal_handler.execute(json!({"sender": "agent1"}), state.clone()).await.unwrap(); let qsignal_res = qsignal_handler.execute(json!({"sender": "agent1"}), state.clone()).await.unwrap();
assert!(qsignal_res.contains("task_completed")); assert!(qsignal_res.contains("task_completed"));
let read_paged_handler = ReadGraphHandler;
let paged_res = read_paged_handler
.execute(json!({"limit": 1, "offset": 0}), state.clone())
.await
.unwrap();
let parsed_paged: serde_json::Value = serde_json::from_str(&paged_res).unwrap();
assert_eq!(parsed_paged["total_count"], 2);
assert_eq!(parsed_paged["has_more"], true);
} }
} }
+4 -2
View File
@@ -25,7 +25,8 @@ impl McpTool for WatchProcessLogsHandler {
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: WatchProcessLogsTool = let tool_args: WatchProcessLogsTool =
serde_json::from_value(args).map_err(|e| e.to_string())?; serde_json::from_value(args).map_err(|e| e.to_string())?;
if !std::path::Path::new(&tool_args.file_path).exists() { let safe_path = crate::handlers::utils::validate_safe_path(&tool_args.file_path)?;
if !safe_path.exists() {
return Err(crate::error::AppError::Internal(format!( return Err(crate::error::AppError::Internal(format!(
"File does not exist: {}", "File does not exist: {}",
tool_args.file_path tool_args.file_path
@@ -53,9 +54,10 @@ impl McpTool for GetRecentLogsHandler {
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: GetRecentLogsTool = let tool_args: GetRecentLogsTool =
serde_json::from_value(args).map_err(|e| e.to_string())?; serde_json::from_value(args).map_err(|e| e.to_string())?;
let safe_path = crate::handlers::utils::validate_safe_path(&tool_args.file_path)?;
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> { let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
let mut file = File::open(&tool_args.file_path).map_err(|e| { let mut file = File::open(&safe_path).map_err(|e| {
crate::error::AppError::Internal(format!("Failed to open file: {}", e)) crate::error::AppError::Internal(format!("Failed to open file: {}", e))
})?; })?;
let len = file.metadata().map_err(|e| e.to_string())?.len(); let len = file.metadata().map_err(|e| e.to_string())?.len();
+250 -57
View File
@@ -49,6 +49,11 @@ impl McpTool for LogDecisionHandler {
status: "accepted".to_string(), status: "accepted".to_string(),
supersedes: req.supersedes, supersedes: req.supersedes,
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
namespace: req.namespace.unwrap_or_else(|| crate::models::default_namespace()),
repo_name: req.repo_name,
alternatives_considered: req.alternatives_considered.unwrap_or_default(),
affected_components: req.affected_components.unwrap_or_default(),
author: req.author,
}; };
adrs_to_index.push(a.clone()); adrs_to_index.push(a.clone());
@@ -201,7 +206,11 @@ impl McpTool for LogErrorFixHandler {
git_commit: req.git_commit, git_commit: req.git_commit,
git_branch: req.git_branch, git_branch: req.git_branch,
embedding, embedding,
}) ..Default::default()
});
if fixes.len() > 300 {
fixes.remove(0);
}
}); });
state.record_activity("error_fix", &format!("Fixed error: {}", req.signature), Some(&solution)); state.record_activity("error_fix", &format!("Fixed error: {}", req.signature), Some(&solution));
Ok(format!("Logged error fix for {}: {}", req.signature, solution)) Ok(format!("Logged error fix for {}: {}", req.signature, solution))
@@ -238,8 +247,13 @@ impl McpTool for SearchErrorFixesHandler {
.iter() .iter()
.map(|f| { .map(|f| {
let mut score = 0.0; let mut score = 0.0;
if st.contains(&f.signature) || f.signature.contains(st) { let st_lower = st.to_lowercase();
let sig_lower = f.signature.to_lowercase();
let sol_lower = f.solution.to_lowercase();
if st_lower.contains(&sig_lower) || sig_lower.contains(&st_lower) {
score += 0.8; score += 0.8;
} else if st_lower.contains(&sol_lower) || sol_lower.contains(&st_lower) {
score += 0.5;
} }
if let Some(emb) = &f.embedding { if let Some(emb) = &f.embedding {
if !query_emb.is_empty() { if !query_emb.is_empty() {
@@ -343,16 +357,65 @@ impl McpTool for LogCodeChangeHandler {
} }
} }
} }
let change_kind = match req.change_kind.as_deref().map(|s| s.to_lowercase()).as_deref() {
Some("added") | Some("create") | Some("created") | Some("new") => crate::models::ChangeKind::Added,
Some("deleted") | Some("remove") | Some("removed") => crate::models::ChangeKind::Deleted,
Some("renamed") | Some("move") | Some("moved") => crate::models::ChangeKind::Renamed,
_ => crate::models::ChangeKind::Modified,
};
let namespace = req
.namespace
.filter(|ns| !ns.trim().is_empty())
.or_else(|| req.repo_name.clone().filter(|rn| !rn.trim().is_empty()))
.unwrap_or_else(crate::models::default_namespace);
let symbols = req.symbol_references.clone().unwrap_or_default();
let line_range = req.line_range.clone();
let effective_rev = req.revision.clone().or_else(|| req.git_commit.clone());
let effective_branch = req.branch.clone().or_else(|| req.git_branch.clone());
let detected_vcs = if let Some(vcs) = req.vcs_type.clone() {
Some(vcs)
} else if let Some(ref rev) = effective_rev {
if rev.starts_with('r') && rev[1..].chars().all(|c| c.is_ascii_digit()) {
Some("svn".to_string())
} else if req.repo_url.as_deref().map(|u| u.contains("/svn/")).unwrap_or(false) {
Some("svn".to_string())
} else {
Some("git".to_string())
}
} else if let Some(ref br) = effective_branch {
if br.eq_ignore_ascii_case("trunk") || br.starts_with("branches/") || br.starts_with("tags/") {
Some("svn".to_string())
} else {
Some("git".to_string())
}
} else {
None
};
state.code.ledger.modify(|ledger| { state.code.ledger.modify(|ledger| {
ledger.push(CodeChange { ledger.push(CodeChange {
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
file_path: req.file_path.clone(), file_path: req.file_path.clone(),
description: description.clone(), description: description.clone(),
git_commit: req.git_commit, git_commit: effective_rev.clone(),
git_branch: req.git_branch, git_branch: effective_branch.clone(),
repo_name: req.repo_name,
repo_url: req.repo_url,
namespace: namespace.clone(),
change_kind,
symbols,
line_range,
author: req.author,
session_id: req.session_id,
vcs_type: detected_vcs,
revision: effective_rev,
branch: effective_branch,
repository_root: req.repository_root,
}); });
if ledger.len() > 1000 { if ledger.len() > 500 {
ledger.remove(0); ledger.remove(0);
} }
}); });
@@ -376,15 +439,44 @@ impl McpTool for QueryRecentChangesHandler {
) )
} }
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let data = state let req: QueryRecentChangesTool = serde_json::from_value(args).unwrap_or(QueryRecentChangesTool {
.code namespace: None,
.ledger repo_name: None,
.read_with(|l| Ok::<String, crate::error::AppError>(serde_json::to_string(l)?))?; vcs_type: None,
limit: None,
});
let limit = req.limit.unwrap_or(50);
let data = state.code.ledger.read_with(|l| {
let filtered: Vec<_> = l
.iter()
.rev()
.filter(|c| {
if let Some(ns) = &req.namespace {
if !c.namespace.eq_ignore_ascii_case(ns) {
return false;
}
}
if let Some(repo) = &req.repo_name {
if c.repo_name.as_ref().map(|rn| !rn.eq_ignore_ascii_case(repo)).unwrap_or(true) {
return false;
}
}
if let Some(vcs) = &req.vcs_type {
if !c.effective_vcs().eq_ignore_ascii_case(vcs) {
return false;
}
}
true
})
.take(limit)
.cloned()
.collect();
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
})?;
Ok(data) Ok(data)
} }
} }
pub struct LearnPreferenceHandler; pub struct LearnPreferenceHandler;
#[async_trait] #[async_trait]
@@ -409,6 +501,7 @@ impl McpTool for LearnPreferenceHandler {
key: req.key.clone(), key: req.key.clone(),
value: req.value, value: req.value,
updated_at: crate::handlers::utils::now_secs(), updated_at: crate::handlers::utils::now_secs(),
..Default::default()
}, },
); );
}); });
@@ -474,6 +567,12 @@ impl McpTool for LogTechDebtHandler {
git_commit: req.git_commit, git_commit: req.git_commit,
git_branch: req.git_branch, git_branch: req.git_branch,
embedding, embedding,
repo_name: req.repo_name,
severity: req.severity,
file_path: req.file_path,
line_range: req.line_range,
workaround: req.workaround,
effort_estimate: req.effort_estimate,
}) })
}); });
Ok("Tech debt logged".to_string()) Ok("Tech debt logged".to_string())
@@ -605,7 +704,11 @@ impl McpTool for DecisionsHandler {
"context": context, "context": context,
"decision": decision, "decision": decision,
"consequences": consequences, "consequences": consequences,
"namespace": ns "namespace": ns,
"repo_name": req.repo_name,
"author": req.author,
"affected_components": req.affected_components,
"alternatives_considered": req.alternatives_considered,
}), }),
state, state,
).await ).await
@@ -655,7 +758,12 @@ impl McpTool for TechDebtHandler {
"git_branch": req.git_branch, "git_branch": req.git_branch,
"symbol_references": req.symbol_references, "symbol_references": req.symbol_references,
"line_range": req.line_range, "line_range": req.line_range,
"namespace": ns "namespace": ns,
"repo_name": req.repo_name,
"severity": req.severity,
"file_path": req.file_path,
"workaround": req.workaround,
"effort_estimate": req.effort_estimate,
}), }),
state, state,
).await ).await
@@ -699,23 +807,12 @@ impl McpTool for OmniSearchHandler {
.search(&req.query, req.namespace.as_deref()) .search(&req.query, req.namespace.as_deref())
.unwrap_or_default(); .unwrap_or_default();
let q = req.query.clone(); let vector_matches = state
let query_emb = crate::embedding::generate_embedding_async(q.clone()) .search()
.semantic_search(&req.query, req.namespace.as_deref(), limit * 2)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let vector_matches = if !query_emb.is_empty() {
if let Some(vdb) = &*state.vector_db.read().await {
vdb.search(query_emb.clone(), (limit * 2) as u64)
.await
.unwrap_or_default()
} else {
vec![]
}
} else {
vec![]
};
// Reciprocal Rank Fusion (RRF) algorithm // Reciprocal Rank Fusion (RRF) algorithm
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Clone)] #[derive(Clone)]
@@ -754,8 +851,8 @@ impl McpTool for OmniSearchHandler {
let item = MatchItem { let item = MatchItem {
id: v_match.id.clone(), id: v_match.id.clone(),
doc_type: v_match.doc_type, doc_type: v_match.doc_type,
title: v_match.id, title: v_match.title,
body: v_match.text, body: v_match.body,
}; };
rrf_scores.insert(item_id, (score, item)); rrf_scores.insert(item_id, (score, item));
} }
@@ -770,29 +867,38 @@ impl McpTool for OmniSearchHandler {
let kg_json = state.read_graph(|full| { let kg_json = state.read_graph(|full| {
let mut kg_results = serde_json::Map::new(); let mut kg_results = serde_json::Map::new();
let mut count = 0; let mut count = 0;
// Build pre-indexed adjacency map: O(R) once instead of O(E * R)
let mut adj_map: std::collections::HashMap<&str, Vec<(&str, &str, &str)>> =
std::collections::HashMap::new();
for rel in &full.relations {
adj_map
.entry(rel.from.as_str())
.or_default()
.push((rel.to.as_str(), rel.relation_type.as_str(), "outgoing"));
adj_map
.entry(rel.to.as_str())
.or_default()
.push((rel.from.as_str(), rel.relation_type.as_str(), "incoming"));
}
for res in &matches { for res in &matches {
if res.doc_type == "entity" if res.doc_type == "entity"
&& let Some(e) = full.entities.get(&res.id) && let Some(e) = full.entities.get(&res.id)
{ {
if count >= limit { if count >= limit {
continue; break;
} }
count += 1; count += 1;
// 1-hop relation expansion for GraphRAG // 1-hop relation expansion for GraphRAG via pre-indexed adjacency
let mut connected_rels = Vec::new(); let mut connected_rels = Vec::new();
for rel in &full.relations { if let Some(rels) = adj_map.get(res.id.as_str()) {
if rel.from == res.id { for (target, rel_type, direction) in rels {
connected_rels.push(serde_json::json!({ connected_rels.push(serde_json::json!({
"target": rel.to, "target": target,
"relation": rel.relation_type, "relation": rel_type,
"direction": "outgoing" "direction": direction
}));
} else if rel.to == res.id {
connected_rels.push(serde_json::json!({
"target": rel.from,
"relation": rel.relation_type,
"direction": "incoming"
})); }));
} }
} }
@@ -851,6 +957,11 @@ impl McpTool for OmniSearchHandler {
serde_json::to_value(&filtered).map_err(|e| e.to_string()) serde_json::to_value(&filtered).map_err(|e| e.to_string())
})?; })?;
let q = req.query.clone();
let query_emb = crate::embedding::generate_embedding_async(req.query.clone())
.await
.unwrap_or_default();
let snippets_json = state.code.snippets.read_with(|all_snippets| { let snippets_json = state.code.snippets.read_with(|all_snippets| {
let mut scored: Vec<_> = all_snippets let mut scored: Vec<_> = all_snippets
.iter() .iter()
@@ -907,7 +1018,6 @@ impl McpTool for OmniSearchHandler {
serde_json::to_value(&filtered).map_err(|e| e.to_string()) serde_json::to_value(&filtered).map_err(|e| e.to_string())
})?; })?;
let q = req.query;
let tech_debts_json = state.code.tech_debts.read_with(|debts| { let tech_debts_json = state.code.tech_debts.read_with(|debts| {
let mut scored: Vec<_> = debts let mut scored: Vec<_> = debts
.iter() .iter()
@@ -995,7 +1105,7 @@ impl McpTool for OmniSearchHandler {
serde_json::to_value(&filtered).map_err(|e| e.to_string()) serde_json::to_value(&filtered).map_err(|e| e.to_string())
})?; })?;
let report = serde_json::json!({ let mut report = serde_json::json!({
"knowledge_graph": kg_json, "knowledge_graph": kg_json,
"tasks": tasks_json, "tasks": tasks_json,
"snippets": snippets_json, "snippets": snippets_json,
@@ -1004,15 +1114,52 @@ impl McpTool for OmniSearchHandler {
"handoff_memos": memos_json, "handoff_memos": memos_json,
"error_fixes": error_fixes_json "error_fixes": error_fixes_json
}); });
let mut out_str = report.to_string();
if let Some(max_tok) = req.max_tokens { if let Some(max_tok) = req.max_tokens {
let max_chars = max_tok * 4; let max_chars = max_tok * 4;
let mut out_str = report.to_string();
if out_str.len() > max_chars { if out_str.len() > max_chars {
out_str.truncate(max_chars); let prune_keys = [
out_str.push_str("... [TRUNCATED_TO_MAX_TOKENS]"); "error_fixes",
"tech_debts",
"snippets",
"adrs",
"handoff_memos",
"knowledge_graph",
"tasks",
];
let mut pruned = false;
for key in prune_keys {
while out_str.len() > max_chars {
let popped = if let Some(arr) = report.get_mut(key).and_then(|v| v.as_array_mut()) {
if arr.len() > 1 {
arr.pop();
pruned = true;
true
} else {
false
}
} else {
false
};
if popped {
out_str = report.to_string();
} else {
break;
} }
} }
Ok(out_str) if out_str.len() <= max_chars {
break;
}
}
if pruned && let Some(obj) = report.as_object_mut() {
obj.insert(
"_truncated_to_max_tokens".to_string(),
serde_json::Value::Bool(true),
);
}
}
}
Ok(report.to_string())
} }
} }
@@ -1101,8 +1248,13 @@ impl McpTool for SuggestErrorFixHandler {
.iter() .iter()
.map(|f| { .map(|f| {
let mut score = 0.0; let mut score = 0.0;
if req.stack_trace.contains(&f.signature) || f.signature.contains(&req.stack_trace) { let st_lower = req.stack_trace.to_lowercase();
let sig_lower = f.signature.to_lowercase();
let sol_lower = f.solution.to_lowercase();
if st_lower.contains(&sig_lower) || sig_lower.contains(&st_lower) {
score += 0.8; score += 0.8;
} else if st_lower.contains(&sol_lower) || sol_lower.contains(&st_lower) {
score += 0.5;
} }
if let Some(emb) = &f.embedding { if let Some(emb) = &f.embedding {
if !query_emb.is_empty() { if !query_emb.is_empty() {
@@ -1178,6 +1330,7 @@ impl McpTool for ManageCheckpointHandler {
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
description: desc.clone(), description: desc.clone(),
namespace: ns, namespace: ns,
..Default::default()
}; };
state.project.snapshots.modify(|snaps| snaps.push(snapshot)); state.project.snapshots.modify(|snaps| snaps.push(snapshot));
} }
@@ -1381,22 +1534,31 @@ impl McpTool for QueryLineageHandler {
"decision": adr.decision "decision": adr.decision
})); }));
} }
}
let changes = state.code.ledger.read_with(|c| c.clone()); let changes = state.code.ledger.read_with(|c| c.clone());
for change in changes { for change in changes {
let file_match = change.file_path.to_lowercase().contains(&q); let file_match = change.file_path.to_lowercase().contains(&q);
let desc_match = change.description.to_lowercase().contains(&q); let desc_match = change.description.to_lowercase().contains(&q);
if file_match || desc_match { let repo_match = change.repo_name.as_ref().map(|r| r.to_lowercase().contains(&q)).unwrap_or(false);
let symbol_match = change.symbols.iter().any(|s| s.to_lowercase().contains(&q));
let ns_match = change.namespace.to_lowercase().contains(&q);
if file_match || desc_match || repo_match || symbol_match || ns_match {
timeline.push(serde_json::json!({ timeline.push(serde_json::json!({
"timestamp": change.timestamp, "timestamp": change.timestamp,
"type": "CodeChange", "type": "CodeChange",
"file": change.file_path, "file": change.file_path,
"description": change.description, "description": change.description,
"commit": change.git_commit "commit": change.git_commit,
"branch": change.git_branch,
"repo_name": change.repo_name,
"namespace": change.namespace,
"change_kind": change.change_kind,
"symbols": change.symbols,
"line_range": change.line_range,
"author": change.author,
})); }));
} }
} }
}
let fixes = state.code.error_fixes.read_with(|f| f.clone()); let fixes = state.code.error_fixes.read_with(|f| f.clone());
for fix in fixes { for fix in fixes {
@@ -1509,6 +1671,7 @@ impl McpTool for LogHypothesisHandler {
status: req.status.unwrap_or_else(|| "unverified".to_string()), status: req.status.unwrap_or_else(|| "unverified".to_string()),
evidence: req.evidence, evidence: req.evidence,
timestamp, timestamp,
..Default::default()
}; };
state.code.hypotheses.modify(|h| h.push(record)); state.code.hypotheses.modify(|h| h.push(record));
@@ -1576,10 +1739,16 @@ impl McpTool for GetPreflightContextHandler {
let pinned = state.project.pinned_files.read_with(|p| p.clone()); let pinned = state.project.pinned_files.read_with(|p| p.clone());
let tech_debts = state.code.tech_debts.read_with(|d| d.clone()); let tech_debts = state.code.tech_debts.read_with(|d| d.clone());
let hypotheses = state.code.hypotheses.read_with(|h| h.clone()); let hypotheses = state.code.hypotheses.read_with(|h| h.clone());
let recent_commands = state.telemetry.terminal_history.read_with(|h| {
h.iter().take(5).cloned().collect::<Vec<_>>()
});
let recent_activities = state.telemetry.recent_activities.read_with(|a| {
a.iter().take(5).cloned().collect::<Vec<_>>()
});
let active_tasks: Vec<_> = tasks let active_tasks: Vec<_> = tasks
.into_iter() .into_iter()
.filter(|t| t.status == "in_progress" || t.status == "open") .filter(|t| !matches!(t.status.as_str(), "completed" | "done" | "cancelled"))
.map(|t| serde_json::json!({ .map(|t| serde_json::json!({
"id": t.id, "id": t.id,
"title": t.title, "title": t.title,
@@ -1590,7 +1759,12 @@ impl McpTool for GetPreflightContextHandler {
let branch_pinned: Vec<_> = pinned let branch_pinned: Vec<_> = pinned
.into_iter() .into_iter()
.filter(|p| p.namespace == req.namespace && (req.git_branch.is_none() || p.git_branch == req.git_branch)) .filter(|p| {
p.namespace == req.namespace
&& (req.git_branch.is_none()
|| p.git_branch.is_none()
|| p.git_branch == req.git_branch)
})
.map(|p| p.file_path) .map(|p| p.file_path)
.collect(); .collect();
@@ -1617,7 +1791,9 @@ impl McpTool for GetPreflightContextHandler {
"active_tasks": active_tasks, "active_tasks": active_tasks,
"pinned_files": branch_pinned, "pinned_files": branch_pinned,
"top_open_tech_debts": open_tech_debts, "top_open_tech_debts": open_tech_debts,
"active_hypotheses": active_hypotheses "active_hypotheses": active_hypotheses,
"recent_terminal_commands": recent_commands,
"recent_activities": recent_activities
}); });
Ok(serde_json::to_string_pretty(&preflight)?) Ok(serde_json::to_string_pretty(&preflight)?)
@@ -1652,6 +1828,7 @@ impl McpTool for BroadcastAgentSignalHandler {
payload: req.payload, payload: req.payload,
timestamp, timestamp,
ttl_seconds: req.ttl_seconds, ttl_seconds: req.ttl_seconds,
..Default::default()
}; };
state.telemetry.agent_signals.modify(|s| { state.telemetry.agent_signals.modify(|s| {
@@ -1747,7 +1924,11 @@ impl McpTool for AutoSessionCheckpointHandler {
let hypotheses = state.code.hypotheses.read_with(|h| h.clone()); let hypotheses = state.code.hypotheses.read_with(|h| h.clone());
let ledger = state.code.ledger.read_with(|l| l.clone()); let ledger = state.code.ledger.read_with(|l| l.clone());
let active_tasks: Vec<_> = tasks.iter().filter(|t| t.status == "in_progress" || t.status == "open").map(|t| t.title.as_str()).collect(); let active_tasks: Vec<_> = tasks
.iter()
.filter(|t| !matches!(t.status.as_str(), "completed" | "done" | "cancelled"))
.map(|t| t.title.as_str())
.collect();
let unverified_hyp: Vec<_> = hypotheses.iter().filter(|h| h.status == "unverified").map(|h| h.hypothesis.as_str()).collect(); let unverified_hyp: Vec<_> = hypotheses.iter().filter(|h| h.status == "unverified").map(|h| h.hypothesis.as_str()).collect();
let recent_changes: Vec<_> = ledger.iter().rev().take(5).map(|c| c.file_path.as_str()).collect(); let recent_changes: Vec<_> = ledger.iter().rev().take(5).map(|c| c.file_path.as_str()).collect();
@@ -1765,6 +1946,7 @@ impl McpTool for AutoSessionCheckpointHandler {
expires_at: None, expires_at: None,
namespace: req.namespace, namespace: req.namespace,
timestamp, timestamp,
..Default::default()
}; };
state.telemetry.handoff_memos.modify(|m| { state.telemetry.handoff_memos.modify(|m| {
@@ -2036,6 +2218,7 @@ mod tests {
expires_at: None, expires_at: None,
dependencies: vec![], dependencies: vec![],
acceptance_criteria: vec![], acceptance_criteria: vec![],
..Default::default()
}; };
{ {
@@ -2232,6 +2415,7 @@ mod tests {
expires_at: None, expires_at: None,
dependencies: vec![], dependencies: vec![],
acceptance_criteria: vec![], acceptance_criteria: vec![],
..Default::default()
}; };
state.project.tasks.modify(|t| t.push(task)); state.project.tasks.modify(|t| t.push(task));
@@ -2408,6 +2592,7 @@ mod tests {
tags: vec!["rust".to_string(), "mcp".to_string()], tags: vec!["rust".to_string(), "mcp".to_string()],
updated_at: 0, updated_at: 0,
embedding: None, embedding: None,
..Default::default()
}); });
}); });
@@ -2467,6 +2652,7 @@ mod tests {
expires_at: None, expires_at: None,
dependencies: vec![], dependencies: vec![],
acceptance_criteria: vec![], acceptance_criteria: vec![],
..Default::default()
}; };
let task2 = crate::models::Task { let task2 = crate::models::Task {
id: "t-2".to_string(), id: "t-2".to_string(),
@@ -2480,6 +2666,7 @@ mod tests {
expires_at: None, expires_at: None,
dependencies: vec!["t-1".to_string()], dependencies: vec!["t-1".to_string()],
acceptance_criteria: vec![], acceptance_criteria: vec![],
..Default::default()
}; };
state.project.tasks.modify(|t| { state.project.tasks.modify(|t| {
t.push(task1); t.push(task1);
@@ -2607,6 +2794,7 @@ mod tests {
observations: vec!["Obs 1".to_string()], observations: vec!["Obs 1".to_string()],
namespace: "global".to_string(), namespace: "global".to_string(),
git_branch: None, git_branch: None,
..Default::default()
}); });
g.entities.insert("Ent2".to_string(), crate::models::Entity { g.entities.insert("Ent2".to_string(), crate::models::Entity {
name: "Ent2".to_string(), name: "Ent2".to_string(),
@@ -2614,12 +2802,14 @@ mod tests {
observations: vec!["Obs 2".to_string()], observations: vec!["Obs 2".to_string()],
namespace: "global".to_string(), namespace: "global".to_string(),
git_branch: None, git_branch: None,
..Default::default()
}); });
g.relations.push(crate::models::Relation { g.relations.push(crate::models::Relation {
from: "Ent1".to_string(), from: "Ent1".to_string(),
to: "Ent2".to_string(), to: "Ent2".to_string(),
relation_type: "uses".to_string(), relation_type: "uses".to_string(),
namespace: "global".to_string(), namespace: "global".to_string(),
..Default::default()
}); });
}); });
@@ -2693,6 +2883,7 @@ mod tests {
timestamp: 0, timestamp: 0,
description: "Test snap".to_string(), description: "Test snap".to_string(),
namespace: "global".to_string(), namespace: "global".to_string(),
..Default::default()
}); });
}); });
let rest_snap = mg_chk let rest_snap = mg_chk
@@ -2737,6 +2928,7 @@ mod tests {
dependencies: vec!["NON-EXISTENT-TASK".to_string()], dependencies: vec!["NON-EXISTENT-TASK".to_string()],
description: "Blocked task desc".to_string(), description: "Blocked task desc".to_string(),
expires_at: None, expires_at: None,
..Default::default()
}); });
@@ -2756,6 +2948,7 @@ mod tests {
timestamp: 0, timestamp: 0,
namespace: "global".to_string(), namespace: "global".to_string(),
git_branch: Some("main".to_string()), git_branch: Some("main".to_string()),
..Default::default()
}); });
}); });
+9 -3
View File
@@ -43,6 +43,7 @@ impl McpTool for StickyNotesHandler {
timestamp: now, timestamp: now,
content, content,
expires_at, expires_at,
..Default::default()
}); });
}); });
Ok("Sticky note added.".to_string()) Ok("Sticky note added.".to_string())
@@ -72,9 +73,11 @@ impl McpTool for StickyNotesHandler {
})?; })?;
let mut success = false; let mut success = false;
state.code.sticky.modify(|notes| { state.code.sticky.modify(|notes| {
let target_pos = if idx == 0 { 0 } else { idx - 1 }; if idx > 0 && idx <= notes.len() {
if target_pos < notes.len() { notes.remove(idx - 1);
notes.remove(target_pos); success = true;
} else if idx == 0 && !notes.is_empty() {
notes.remove(0);
success = true; success = true;
} }
}); });
@@ -129,6 +132,7 @@ impl McpTool for HandoffMemosHandler {
namespace: ns, namespace: ns,
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
expires_at: None, expires_at: None,
..Default::default()
}); });
if memos.len() > 200 { if memos.len() > 200 {
memos.remove(0); memos.remove(0);
@@ -190,6 +194,7 @@ impl McpTool for AddSessionSummaryHandler {
namespace: req.namespace, namespace: req.namespace,
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
expires_at: None, expires_at: None,
..Default::default()
}); });
if summaries.len() > 200 { if summaries.len() > 200 {
summaries.remove(0); summaries.remove(0);
@@ -273,6 +278,7 @@ impl McpTool for PromoteToEntityHandler {
observations: Vec::new(), observations: Vec::new(),
namespace, namespace,
git_branch: None, git_branch: None,
..Default::default()
}); });
if !entity.observations.contains(&obs) { if !entity.observations.contains(&obs) {
entity.observations.push(obs.clone()); entity.observations.push(obs.clone());
+116 -29
View File
@@ -45,21 +45,34 @@ impl McpTool for TasksHandler {
dependencies: deps, dependencies: deps,
acceptance_criteria: vec![], acceptance_criteria: vec![],
expires_at: None, expires_at: None,
namespace: req.namespace.unwrap_or_else(|| "default".to_string()),
repo_name: req.repo_name,
priority: req.priority,
assigned_agent: req.assigned_agent,
verification_command: req.verification_command,
..Default::default()
}; };
let idx = state.get_search_index().await; let idx = state.get_search_index().await;
drop(idx.index_task(&task)); drop(idx.index_task(&task));
state.project.tasks.modify(|tasks| { state.project.tasks.modify(|tasks| {
tasks.push(task.clone()); tasks.push(task.clone());
}); });
state.record_activity("task_create", &format!("Created task: {}", task.title), Some(&task.description)); state.record_activity(
"task_create",
&format!("Created task: {}", task.title),
Some(&task.description),
);
state.broadcast_task_event(TaskEvent { state.broadcast_task_event(TaskEvent {
task_id: task_id.clone(), task_id: task_id.clone(),
status: "created".to_string(), status: "created".to_string(),
action: Some("add".to_string()), action: Some("add".to_string()),
result: Some(serde_json::json!({ "title": task.title, "git_branch": task.git_branch })), result: Some(
serde_json::json!({ "title": task.title, "git_branch": task.git_branch }),
),
error: None, error: None,
timestamp: now, timestamp: now,
session_id: None, session_id: None,
..Default::default()
}); });
Ok(format!("Task added with ID: {}", task_id)) Ok(format!("Task added with ID: {}", task_id))
} }
@@ -75,6 +88,8 @@ impl McpTool for TasksHandler {
let mut blocked = false; let mut blocked = false;
let mut blocker_details = String::new(); let mut blocker_details = String::new();
let mut updated_task: Option<Task> = None;
state.project.tasks.modify(|tasks| { state.project.tasks.modify(|tasks| {
let target_idx = tasks.iter().position(|t| t.id == id || t.title == id); let target_idx = tasks.iter().position(|t| t.id == id || t.title == id);
let target_idx = match target_idx { let target_idx = match target_idx {
@@ -84,7 +99,11 @@ impl McpTool for TasksHandler {
found = true; found = true;
if target_status == "done" || target_status == "completed" { if target_status == "done" || target_status == "completed" {
if tasks[target_idx].acceptance_criteria.iter().any(|c| !c.is_met) { if tasks[target_idx]
.acceptance_criteria
.iter()
.any(|c| !c.is_met)
{
blocked = true; blocked = true;
blocker_details = "Unmet acceptance criteria exist.".to_string(); blocker_details = "Unmet acceptance criteria exist.".to_string();
} }
@@ -100,20 +119,29 @@ impl McpTool for TasksHandler {
} }
if !uncompleted_deps.is_empty() { if !uncompleted_deps.is_empty() {
blocked = true; blocked = true;
blocker_details = format!("Blocked by dependencies: {}", uncompleted_deps.join(", ")); blocker_details = format!(
"Blocked by dependencies: {}",
uncompleted_deps.join(", ")
);
} }
} }
if !blocked { if !blocked {
let target_id_ref = tasks[target_idx].id.as_str(); let target_id_ref = tasks[target_idx].id.as_str();
let mut uncompleted_children = Vec::new(); let mut uncompleted_children = Vec::new();
for child in tasks.iter().filter(|t| t.parent_id.as_deref() == Some(target_id_ref)) { for child in tasks
.iter()
.filter(|t| t.parent_id.as_deref() == Some(target_id_ref))
{
if child.status != "completed" && child.status != "done" { if child.status != "completed" && child.status != "done" {
uncompleted_children.push(child.title.as_str()); uncompleted_children.push(child.title.as_str());
} }
} }
if !uncompleted_children.is_empty() { if !uncompleted_children.is_empty() {
blocked = true; blocked = true;
blocker_details = format!("Blocked by child tasks: {}", uncompleted_children.join(", ")); blocker_details = format!(
"Blocked by child tasks: {}",
uncompleted_children.join(", ")
);
} }
} }
} }
@@ -121,6 +149,7 @@ impl McpTool for TasksHandler {
if !blocked { if !blocked {
tasks[target_idx].status = target_status.clone(); tasks[target_idx].status = target_status.clone();
tasks[target_idx].updated_at = crate::handlers::utils::now_secs(); tasks[target_idx].updated_at = crate::handlers::utils::now_secs();
updated_task = Some(tasks[target_idx].clone());
} }
}); });
@@ -133,10 +162,22 @@ impl McpTool for TasksHandler {
error: Some(blocker_details.clone()), error: Some(blocker_details.clone()),
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
session_id: None, session_id: None,
..Default::default()
}); });
Err(crate::error::AppError::Internal(format!("Error: Cannot transition task. {}", blocker_details))) Err(crate::error::AppError::Internal(format!(
"Error: Cannot transition task. {}",
blocker_details
)))
} else if found { } else if found {
state.record_activity("task_update", &format!("Task {} status -> {}", id, status), None); if let Some(ref ut) = updated_task {
let idx = state.get_search_index().await;
drop(idx.index_task(ut));
}
state.record_activity(
"task_update",
&format!("Task {} status -> {}", id, status),
None,
);
state.broadcast_task_event(TaskEvent { state.broadcast_task_event(TaskEvent {
task_id: id.clone(), task_id: id.clone(),
status: target_status.clone(), status: target_status.clone(),
@@ -145,10 +186,13 @@ impl McpTool for TasksHandler {
error: None, error: None,
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
session_id: None, session_id: None,
..Default::default()
}); });
Ok("Task status updated.".to_string()) Ok("Task status updated.".to_string())
} else { } else {
Err(crate::error::AppError::Internal("Task not found. Please verify the task ID.".to_string())) Err(crate::error::AppError::Internal(
"Task not found. Please verify the task ID.".to_string(),
))
} }
} }
TaskAction::Delete => { TaskAction::Delete => {
@@ -163,9 +207,12 @@ impl McpTool for TasksHandler {
for (idx, t) in tasks.iter().enumerate() { for (idx, t) in tasks.iter().enumerate() {
id_to_index.insert(t.id.as_str(), idx); id_to_index.insert(t.id.as_str(), idx);
} }
let mut children_map: std::collections::HashMap<usize, Vec<usize>> = std::collections::HashMap::new(); let mut children_map: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
for (idx, t) in tasks.iter().enumerate() { for (idx, t) in tasks.iter().enumerate() {
if let Some(pid) = &t.parent_id && let Some(&p_idx) = id_to_index.get(pid.as_str()) { if let Some(pid) = &t.parent_id
&& let Some(&p_idx) = id_to_index.get(pid.as_str())
{
children_map.entry(p_idx).or_default().push(idx); children_map.entry(p_idx).or_default().push(idx);
} }
} }
@@ -174,7 +221,9 @@ impl McpTool for TasksHandler {
let mut queue = std::collections::VecDeque::new(); let mut queue = std::collections::VecDeque::new();
queue.push_back(start_idx); queue.push_back(start_idx);
while let Some(curr) = queue.pop_front() { while let Some(curr) = queue.pop_front() {
if to_delete_idx.insert(curr) && let Some(children) = children_map.get(&curr) { if to_delete_idx.insert(curr)
&& let Some(children) = children_map.get(&curr)
{
queue.extend(children.iter().copied()); queue.extend(children.iter().copied());
} }
} }
@@ -199,19 +248,29 @@ impl McpTool for TasksHandler {
error: None, error: None,
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
session_id: None, session_id: None,
..Default::default()
}); });
Ok(format!("Deleted task and its children ({} total).", deleted_count)) Ok(format!(
"Deleted task and its children ({} total).",
deleted_count
))
} else { } else {
Err(crate::error::AppError::Internal("Task not found. Please verify the task ID.".to_string())) Err(crate::error::AppError::Internal(
"Task not found. Please verify the task ID.".to_string(),
))
} }
} }
TaskAction::List => { TaskAction::List => {
let level = req.summary_level.as_deref().unwrap_or("detailed"); let level = req.summary_level.as_deref().unwrap_or("detailed");
let data = state.project.tasks.read_with(|tasks| { let data = state.project.tasks.read_with(|tasks| {
let filtered: Vec<_> = tasks let mut filtered: Vec<_> = tasks
.iter() .iter()
.filter(|t| { .filter(|t| {
let status_match = t.status != "done" && t.status != "completed"; let status_match = match req.status.as_deref() {
Some("all") => true,
Some(s) => t.status.eq_ignore_ascii_case(s),
None => t.status != "done" && t.status != "completed",
};
let branch_match = match &req.git_branch { let branch_match = match &req.git_branch {
Some(branch) => t.git_branch.is_none() || t.git_branch.as_deref() == Some(branch.as_str()), Some(branch) => t.git_branch.is_none() || t.git_branch.as_deref() == Some(branch.as_str()),
None => true, None => true,
@@ -227,10 +286,9 @@ impl McpTool for TasksHandler {
let mut json_str = serde_json::to_string(&filtered)?; let mut json_str = serde_json::to_string(&filtered)?;
if let Some(max_t) = req.max_tokens { if let Some(max_t) = req.max_tokens {
let char_limit = max_t * 4; let char_limit = max_t * 4;
if json_str.len() > char_limit { while json_str.len() > char_limit && !filtered.is_empty() {
let boundary = json_str.floor_char_boundary(char_limit); filtered.pop();
json_str.truncate(boundary); json_str = serde_json::to_string(&filtered)?;
json_str.push_str(" ...[truncated due to max_tokens]");
} }
} }
Ok::<String, crate::error::AppError>(json_str) Ok::<String, crate::error::AppError>(json_str)
@@ -253,6 +311,7 @@ impl McpTool for TasksHandler {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
description: desc, description: desc,
is_met: false, is_met: false,
..Default::default()
}) })
.collect(); .collect();
task.updated_at = crate::handlers::utils::now_secs(); task.updated_at = crate::handlers::utils::now_secs();
@@ -268,10 +327,13 @@ impl McpTool for TasksHandler {
error: None, error: None,
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
session_id: None, session_id: None,
..Default::default()
}); });
Ok("Acceptance criteria set successfully.".to_string()) Ok("Acceptance criteria set successfully.".to_string())
} else { } else {
Err(crate::error::AppError::Internal("Task not found. Please verify the task ID.".to_string())) Err(crate::error::AppError::Internal(
"Task not found. Please verify the task ID.".to_string(),
))
} }
} }
TaskAction::Verify => { TaskAction::Verify => {
@@ -292,10 +354,12 @@ impl McpTool for TasksHandler {
task.updated_at = crate::handlers::utils::now_secs(); task.updated_at = crate::handlers::utils::now_secs();
} }
} else { } else {
task.acceptance_criteria.push(crate::models::AcceptanceCriteria { task.acceptance_criteria
.push(crate::models::AcceptanceCriteria {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
description: proof_str.clone(), description: proof_str.clone(),
is_met: true, is_met: true,
..Default::default()
}); });
task.updated_at = crate::handlers::utils::now_secs(); task.updated_at = crate::handlers::utils::now_secs();
success = true; success = true;
@@ -311,12 +375,18 @@ impl McpTool for TasksHandler {
error: None, error: None,
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
session_id: None, session_id: None,
..Default::default()
}); });
Ok(format!("Acceptance criteria verified with proof: {}", proof_str)) Ok(format!(
"Acceptance criteria verified with proof: {}",
proof_str
))
} else if already_met { } else if already_met {
Ok("Acceptance criteria was already met.".to_string()) Ok("Acceptance criteria was already met.".to_string())
} else { } else {
Err(crate::error::AppError::Internal("Task not found. Please verify the task ID.".to_string())) Err(crate::error::AppError::Internal(
"Task not found. Please verify the task ID.".to_string(),
))
} }
} }
} }
@@ -345,14 +415,23 @@ impl McpTool for MilestonesHandler {
let title = req.title.ok_or_else(|| { let title = req.title.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'title' for action 'add'. Next step: Provide non-empty 'title' string in request and retry.".to_string()) crate::error::AppError::Internal("Missing required parameter 'title' for action 'add'. Next step: Provide non-empty 'title' string in request and retry.".to_string())
})?; })?;
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace()); let ns = req
.namespace
.unwrap_or_else(|| crate::models::default_namespace());
state.project.milestones.modify(|ms| { state.project.milestones.modify(|ms| {
ms.push(crate::models::Milestone { ms.push(crate::models::Milestone {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
title, title,
status: "pending".to_string(), status: "pending".to_string(),
namespace: ns, namespace: ns,
target_date: None, target_date: req
.target_date
.as_deref()
.and_then(|s| s.parse::<u64>().ok()),
description: req.description,
deliverables: req.deliverables.unwrap_or_default(),
repo_name: req.repo_name,
..Default::default()
}) })
}); });
Ok("Milestone added".to_string()) Ok("Milestone added".to_string())
@@ -377,7 +456,9 @@ impl McpTool for MilestonesHandler {
if found { if found {
Ok("Milestone updated".to_string()) Ok("Milestone updated".to_string())
} else { } else {
Err(crate::error::AppError::Internal("Milestone not found. Please verify the milestone ID.".to_string())) Err(crate::error::AppError::Internal(
"Milestone not found. Please verify the milestone ID.".to_string(),
))
} }
} }
MilestoneAction::List => { MilestoneAction::List => {
@@ -504,7 +585,10 @@ mod tests {
assert_eq!(res2, "Milestone updated"); assert_eq!(res2, "Milestone updated");
let res3 = handler_ms let res3 = handler_ms
.execute(json!({"action": "list", "namespace": "global"}), state.clone()) .execute(
json!({"action": "list", "namespace": "global"}),
state.clone(),
)
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string())) .map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap(); .unwrap();
@@ -602,7 +686,10 @@ mod tests {
assert!(res1.contains("Milestone added")); assert!(res1.contains("Milestone added"));
let res2 = handler let res2 = handler
.execute(serde_json::json!({"action": "list", "namespace": "global"}), state.clone()) .execute(
serde_json::json!({"action": "list", "namespace": "global"}),
state.clone(),
)
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string())) .map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap(); .unwrap();
+13
View File
@@ -14,6 +14,19 @@ pub fn now_secs() -> u64 {
.as_secs() .as_secs()
} }
pub fn validate_safe_path(path_str: &str) -> crate::error::Result<std::path::PathBuf> {
let path = std::path::Path::new(path_str);
for component in path.components() {
if component == std::path::Component::ParentDir {
return Err(crate::error::AppError::BadRequest(format!(
"Path traversal forbidden: '{}' contains relative parent directory components ('..')",
path_str
)));
}
}
Ok(path.to_path_buf())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+9 -58
View File
@@ -188,6 +188,14 @@ impl McpTool for ReadClipboardHandler {
{ {
let path_str = file_path.to_string_lossy().to_string(); let path_str = file_path.to_string_lossy().to_string();
out.insert("image_path".into(), json!(path_str)); out.insert("image_path".into(), json!(path_str));
#[cfg(target_os = "windows")]
if let Some(rest) = path_str.strip_prefix("C:\\") {
let wsl_path = format!("/mnt/c/{}", rest.replace('\\', "/"));
out.insert("image_path_wsl".into(), json!(wsl_path));
} else if let Some(rest) = path_str.strip_prefix("c:\\") {
let wsl_path = format!("/mnt/c/{}", rest.replace('\\', "/"));
out.insert("image_path_wsl".into(), json!(wsl_path));
}
if let Ok(bytes) = std::fs::read(&file_path) { if let Ok(bytes) = std::fs::read(&file_path) {
use base64::Engine; use base64::Engine;
@@ -226,42 +234,7 @@ impl McpTool for ReadClipboardHandler {
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&final_obj)?) Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&final_obj)?)
} }
} }
use crate::tools::ToggleClipboardWatchModeTool;
pub struct ToggleClipboardWatchModeHandler;
#[async_trait]
impl McpTool for ToggleClipboardWatchModeHandler {
fn name(&self) -> &'static str {
"toggle_clipboard_watch_mode"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ToggleClipboardWatchModeTool>(
"toggle_clipboard_watch_mode",
"Enable or disable clipboard watch mode (auto-ingesting into StickyNotes).",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: ToggleClipboardWatchModeTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut watch_mode = state.clipboard_watch_mode.write().await;
*watch_mode = tool_args.enable;
drop(watch_mode);
state.clipboard_notify.notify_waiters();
let status_msg = if tool_args.enable {
"Clipboard watch mode enabled. Changes will be ingested as StickyNotes."
} else {
"Clipboard watch mode disabled."
};
state.record_activity("clipboard", status_msg, None);
Ok(status_msg.to_string())
}
}
use crate::tools::{ClipboardAction, ClipboardTool}; use crate::tools::{ClipboardAction, ClipboardTool};
@@ -276,7 +249,7 @@ impl McpTool for ClipboardHandler {
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<ClipboardTool>( crate::mcp::tool_def::<ClipboardTool>(
"clipboard", "clipboard",
"Consolidated OS clipboard management (read, write, toggle_watch)", "Consolidated OS clipboard management (read, write)",
) )
} }
@@ -297,10 +270,6 @@ impl McpTool for ClipboardHandler {
state, state,
).await ).await
} }
ClipboardAction::ToggleWatch => {
let enable = req.enabled.unwrap_or(true);
ToggleClipboardWatchModeHandler.execute(serde_json::json!({"enable": enable}), state).await
}
} }
} }
} }
@@ -312,24 +281,6 @@ mod tests {
use std::sync::Arc; use std::sync::Arc;
use tempfile::tempdir; use tempfile::tempdir;
#[tokio::test]
async fn test_toggle_clipboard_watch_mode() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = ToggleClipboardWatchModeHandler;
let args = json!({
"enable": true
});
let result = handler
.execute(args, state.clone())
.await
.map_err(|e| format!("Failed to toggle clipboard: {}", e))
.unwrap();
assert!(result.contains("enabled"));
assert_eq!(*state.clipboard_watch_mode.read().await, true);
}
#[tokio::test] #[tokio::test]
async fn test_write_clipboard() { async fn test_write_clipboard() {
+257 -62
View File
@@ -15,7 +15,10 @@ impl McpTool for PinFileHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<PinFileTool>("pin_file", "Pin a file path in the workspace context to prioritize it across subagent turns.") crate::mcp::tool_def::<PinFileTool>(
"pin_file",
"Pin a file path in the workspace context to prioritize it across subagent turns.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -27,6 +30,9 @@ impl McpTool for PinFileHandler {
file_path: req.file_path, file_path: req.file_path,
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
git_branch: req.git_branch, git_branch: req.git_branch,
repo_name: req.repo_name,
symbol_or_line: req.symbol_or_line,
pin_reason: req.pin_reason,
}); });
}); });
Ok("File pinned".to_string()) Ok("File pinned".to_string())
@@ -42,7 +48,10 @@ impl McpTool for UnpinFileHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<UnpinFileTool>("unpin_file", "Unpin a file path from the workspace context.") crate::mcp::tool_def::<UnpinFileTool>(
"unpin_file",
"Unpin a file path from the workspace context.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -104,7 +113,10 @@ impl McpTool for StoreSnippetHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<StoreSnippetTool>("store_snippet", "Store or replace a reusable code snippet with vector embedding and search tags.") crate::mcp::tool_def::<StoreSnippetTool>(
"store_snippet",
"Store or replace a reusable code snippet with vector embedding and search tags.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -125,6 +137,12 @@ impl McpTool for StoreSnippetHandler {
updated_at: crate::handlers::utils::now_secs(), updated_at: crate::handlers::utils::now_secs(),
tags: req.tags.unwrap_or_default(), tags: req.tags.unwrap_or_default(),
embedding, embedding,
namespace: req
.namespace
.unwrap_or_else(|| crate::models::default_namespace()),
repo_name: req.repo_name,
origin_file: req.origin_file,
line_range: req.line_range,
}; };
let idx = state.get_search_index().await; let idx = state.get_search_index().await;
@@ -148,7 +166,10 @@ impl McpTool for TagSnippetHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<TagSnippetTool>("tag_snippet", "Add organizational tags to an existing stored code snippet.") crate::mcp::tool_def::<TagSnippetTool>(
"tag_snippet",
"Add organizational tags to an existing stored code snippet.",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -169,9 +190,15 @@ impl McpTool for TagSnippetHandler {
}); });
if found { if found {
Ok(format!("Snippet '{}' tagged with: {:?}", req.name, req.tags)) Ok(format!(
"Snippet '{}' tagged with: {:?}",
req.name, req.tags
))
} else { } else {
Err(crate::error::AppError::Internal(format!("Snippet '{}' not found.", req.name))) Err(crate::error::AppError::Internal(format!(
"Snippet '{}' not found.",
req.name
)))
} }
} }
} }
@@ -209,7 +236,9 @@ impl McpTool for SearchSnippetsHandler {
|| s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query)); || s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query));
let matches_tags = tags_filter.is_empty() let matches_tags = tags_filter.is_empty()
|| tags_filter.iter().any(|tf| s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf))); || tags_filter
.iter()
.any(|tf| s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf)));
matches_text && matches_tags matches_text && matches_tags
}) })
@@ -292,6 +321,10 @@ impl McpTool for SaveContextWorkspaceHandler {
pinned_files: req.pinned_files, pinned_files: req.pinned_files,
active_task_ids: req.active_task_ids, active_task_ids: req.active_task_ids,
saved_at: crate::handlers::utils::now_secs(), saved_at: crate::handlers::utils::now_secs(),
description: req.description,
repo_name: req.repo_name,
git_branch: req.git_branch,
vcs_revision: req.vcs_revision,
}); });
}); });
Ok("Context workspace saved".to_string()) Ok("Context workspace saved".to_string())
@@ -419,6 +452,10 @@ impl McpTool for AddPrChecklistItemHandler {
namespace: req.namespace, namespace: req.namespace,
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
description: req.description, description: req.description,
is_checked: req.is_checked.unwrap_or(false),
category: req.category,
repo_name: req.repo_name,
automated_check: req.automated_check,
}) })
}); });
Ok("PR checklist item added".to_string()) Ok("PR checklist item added".to_string())
@@ -492,22 +529,43 @@ impl McpTool for PinnedFilesHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: PinnedFilesTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: PinnedFilesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace()); let ns = req
.namespace
.unwrap_or_else(|| crate::models::default_namespace());
match req.action { match req.action {
PinnedFileAction::Pin => { PinnedFileAction::Pin => {
let path = req.path.ok_or_else(|| { let path = req.path.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'path' for action 'pin'. Next step: Provide absolute or relative file 'path' in request and retry.".to_string()) crate::error::AppError::Internal("Missing required parameter 'path' for action 'pin'. Next step: Provide absolute or relative file 'path' in request and retry.".to_string())
})?; })?;
PinFileHandler.execute(serde_json::json!({"file_path": path, "namespace": ns}), state).await PinFileHandler
.execute(
serde_json::json!({
"file_path": path,
"namespace": ns,
"git_branch": req.git_branch,
"pin_reason": req.pin_reason,
"symbol_or_line": req.symbol_or_line,
"repo_name": req.repo_name,
}),
state,
)
.await
} }
PinnedFileAction::Unpin => { PinnedFileAction::Unpin => {
let path = req.path.ok_or_else(|| { let path = req.path.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'path' for action 'unpin'. Next step: Provide file 'path' in request and retry.".to_string()) crate::error::AppError::Internal("Missing required parameter 'path' for action 'unpin'. Next step: Provide file 'path' in request and retry.".to_string())
})?; })?;
UnpinFileHandler.execute(serde_json::json!({"file_path": path, "namespace": ns}), state).await UnpinFileHandler
.execute(
serde_json::json!({"file_path": path, "namespace": ns}),
state,
)
.await
} }
PinnedFileAction::List => { PinnedFileAction::List => {
ListPinnedFilesHandler.execute(serde_json::json!({"namespace": ns}), state).await ListPinnedFilesHandler
.execute(serde_json::json!({"namespace": ns}), state)
.await
} }
} }
} }
@@ -530,7 +588,9 @@ impl McpTool for ContextWorkspacesHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ContextWorkspacesTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: ContextWorkspacesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace()); let ns = req
.namespace
.unwrap_or_else(|| crate::models::default_namespace());
match req.action { match req.action {
ContextWorkspaceAction::Save => { ContextWorkspaceAction::Save => {
let name = req.name.ok_or_else(|| { let name = req.name.ok_or_else(|| {
@@ -538,30 +598,42 @@ impl McpTool for ContextWorkspacesHandler {
})?; })?;
let pinned_files = req.pinned_files.unwrap_or_default(); let pinned_files = req.pinned_files.unwrap_or_default();
let active_task_ids = req.active_task_ids.unwrap_or_default(); let active_task_ids = req.active_task_ids.unwrap_or_default();
SaveContextWorkspaceHandler.execute( SaveContextWorkspaceHandler
.execute(
serde_json::json!({ serde_json::json!({
"name": name, "name": name,
"namespace": ns, "namespace": ns,
"pinned_files": pinned_files, "pinned_files": pinned_files,
"active_task_ids": active_task_ids "active_task_ids": active_task_ids,
"description": req.description,
"repo_name": req.repo_name,
"git_branch": req.git_branch,
"vcs_revision": req.vcs_revision,
}), }),
state, state,
).await )
.await
} }
ContextWorkspaceAction::Load => { ContextWorkspaceAction::Load => {
let name = req.name.ok_or_else(|| { let name = req.name.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'name' for action 'load'. Next step: Provide workspace 'name' string in request and retry.".to_string()) crate::error::AppError::Internal("Missing required parameter 'name' for action 'load'. Next step: Provide workspace 'name' string in request and retry.".to_string())
})?; })?;
LoadContextWorkspaceHandler.execute(serde_json::json!({"name": name, "namespace": ns}), state).await LoadContextWorkspaceHandler
.execute(serde_json::json!({"name": name, "namespace": ns}), state)
.await
} }
ContextWorkspaceAction::List => { ContextWorkspaceAction::List => {
ListContextWorkspacesHandler.execute(serde_json::json!({"namespace": ns}), state).await ListContextWorkspacesHandler
.execute(serde_json::json!({"namespace": ns}), state)
.await
} }
ContextWorkspaceAction::Delete => { ContextWorkspaceAction::Delete => {
let name = req.name.ok_or_else(|| { let name = req.name.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'name' for action 'delete'. Next step: Provide workspace 'name' string in request and retry.".to_string()) crate::error::AppError::Internal("Missing required parameter 'name' for action 'delete'. Next step: Provide workspace 'name' string in request and retry.".to_string())
})?; })?;
DeleteContextWorkspaceHandler.execute(serde_json::json!({"name": name, "namespace": ns}), state).await DeleteContextWorkspaceHandler
.execute(serde_json::json!({"name": name, "namespace": ns}), state)
.await
} }
ContextWorkspaceAction::Diff => { ContextWorkspaceAction::Diff => {
let name = req.name.ok_or_else(|| { let name = req.name.ok_or_else(|| {
@@ -593,19 +665,37 @@ impl McpTool for PrChecklistHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: PrChecklistTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: PrChecklistTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace()); let ns = req
.namespace
.unwrap_or_else(|| crate::models::default_namespace());
match req.action { match req.action {
PrChecklistAction::Add => { PrChecklistAction::Add => {
let desc = req.description.ok_or_else(|| { let desc = req.description.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'description' for action 'add'. Next step: Provide checklist item 'description' string in request and retry.".to_string()) crate::error::AppError::Internal("Missing required parameter 'description' for action 'add'. Next step: Provide checklist item 'description' string in request and retry.".to_string())
})?; })?;
AddPrChecklistItemHandler.execute(serde_json::json!({"description": desc, "namespace": ns}), state).await AddPrChecklistItemHandler
.execute(
serde_json::json!({
"description": desc,
"namespace": ns,
"category": req.category,
"automated_check": req.automated_check,
"is_checked": req.is_checked,
"repo_name": req.repo_name,
}),
state,
)
.await
} }
PrChecklistAction::Get => { PrChecklistAction::Get => {
GetPrChecklistHandler.execute(serde_json::json!({"namespace": ns}), state).await GetPrChecklistHandler
.execute(serde_json::json!({"namespace": ns}), state)
.await
} }
PrChecklistAction::Clear => { PrChecklistAction::Clear => {
ClearPrChecklistHandler.execute(serde_json::json!({"namespace": ns}), state).await ClearPrChecklistHandler
.execute(serde_json::json!({"namespace": ns}), state)
.await
} }
} }
} }
@@ -636,37 +726,51 @@ impl McpTool for SnippetsHandler {
let lang = req.language.unwrap_or_else(|| "text".to_string()); let lang = req.language.unwrap_or_else(|| "text".to_string());
let code = req.code.unwrap_or_default(); let code = req.code.unwrap_or_default();
let desc = req.description.unwrap_or_default(); let desc = req.description.unwrap_or_default();
StoreSnippetHandler.execute( StoreSnippetHandler
.execute(
serde_json::json!({ serde_json::json!({
"name": name, "name": name,
"language": lang, "language": lang,
"code": code, "code": code,
"description": desc, "description": desc,
"tags": req.tags "tags": req.tags,
"namespace": req.namespace,
"repo_name": req.repo_name,
"origin_file": req.origin_file,
"line_range": req.line_range,
}), }),
state, state,
).await )
.await
} }
SnippetAction::Search => { SnippetAction::Search => {
let q = req.query.or(req.name).unwrap_or_default(); let q = req.query.or(req.name).unwrap_or_default();
if req.hybrid.unwrap_or(false) { if req.hybrid.unwrap_or(false) {
crate::handlers::meta::SearchSnippetsHybridHandler.execute(serde_json::json!({"query": q, "tags": req.tags}), state).await crate::handlers::meta::SearchSnippetsHybridHandler
.execute(serde_json::json!({"query": q, "tags": req.tags}), state)
.await
} else { } else {
SearchSnippetsHandler.execute(serde_json::json!({"query": q, "tags": req.tags}), state).await SearchSnippetsHandler
.execute(serde_json::json!({"query": q, "tags": req.tags}), state)
.await
} }
} }
SnippetAction::Delete => { SnippetAction::Delete => {
let id = req.name.or(req.id).or(req.query).ok_or_else(|| { let id = req.name.or(req.id).or(req.query).ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'name', 'id', or 'query' for action 'delete'. Next step: Provide snippet ID/name in request and retry.".to_string()) crate::error::AppError::Internal("Missing required parameter 'name', 'id', or 'query' for action 'delete'. Next step: Provide snippet ID/name in request and retry.".to_string())
})?; })?;
DeleteSnippetHandler.execute(serde_json::json!({"name": id}), state).await DeleteSnippetHandler
.execute(serde_json::json!({"name": id}), state)
.await
} }
SnippetAction::Tag => { SnippetAction::Tag => {
let id = req.name.or(req.id).or(req.query).ok_or_else(|| { let id = req.name.or(req.id).or(req.query).ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'name', 'id', or 'query' for action 'tag'. Next step: Provide snippet ID/name and 'tags' array in request and retry.".to_string()) crate::error::AppError::Internal("Missing required parameter 'name', 'id', or 'query' for action 'tag'. Next step: Provide snippet ID/name and 'tags' array in request and retry.".to_string())
})?; })?;
let tags = req.tags.unwrap_or_default(); let tags = req.tags.unwrap_or_default();
TagSnippetHandler.execute(serde_json::json!({"name": id, "tags": tags}), state).await TagSnippetHandler
.execute(serde_json::json!({"name": id, "tags": tags}), state)
.await
} }
} }
} }
@@ -793,24 +897,42 @@ mod tests {
// Snapshots // Snapshots
let create_snap = CreateSnapshotHandler; let create_snap = CreateSnapshotHandler;
let snap_res = create_snap.execute(json!({"description": "Test snap", "namespace": "global"}), state.clone()).await.unwrap(); let snap_res = create_snap
.execute(
json!({"description": "Test snap", "namespace": "global"}),
state.clone(),
)
.await
.unwrap();
assert!(snap_res.contains("SNAP-")); assert!(snap_res.contains("SNAP-"));
let snap_id = snap_res.split_whitespace().nth(1).unwrap(); let snap_id = snap_res.split_whitespace().nth(1).unwrap();
let restore_snap = RestoreSnapshotHandler; let restore_snap = RestoreSnapshotHandler;
let restore_res = restore_snap.execute(json!({"snapshot_id": snap_id}), state.clone()).await.unwrap(); let restore_res = restore_snap
.execute(json!({"snapshot_id": snap_id}), state.clone())
.await
.unwrap();
assert!(restore_res.contains("Successfully restored")); assert!(restore_res.contains("Successfully restored"));
// Subagent Namespace // Subagent Namespace
let create_sub_ns = CreateSubagentNamespaceHandler; let create_sub_ns = CreateSubagentNamespaceHandler;
let sub_ns_res = create_sub_ns.execute(json!({"subagent_id": "test_agent"}), state.clone()).await.unwrap(); let sub_ns_res = create_sub_ns
.execute(json!({"subagent_id": "test_agent"}), state.clone())
.await
.unwrap();
assert!(sub_ns_res.contains("subagent-test_agent")); assert!(sub_ns_res.contains("subagent-test_agent"));
let manage_sub_ns = ManageSubagentNamespaceHandler; let manage_sub_ns = ManageSubagentNamespaceHandler;
let manage_res = manage_sub_ns.execute(json!({ let manage_res = manage_sub_ns
.execute(
json!({
"subagent_id": "test_agent", "subagent_id": "test_agent",
"action": "create" "action": "create"
}), state.clone()).await.unwrap(); }),
state.clone(),
)
.await
.unwrap();
assert!(manage_res.contains("initialized")); assert!(manage_res.contains("initialized"));
// Context Workspace Diff // Context Workspace Diff
@@ -818,9 +940,14 @@ mod tests {
save_ws.execute(json!({"name": "ws_a", "description": "Workspace A", "pinned_files": ["src/lib.rs"], "active_task_ids": []}), state.clone()).await.unwrap(); save_ws.execute(json!({"name": "ws_a", "description": "Workspace A", "pinned_files": ["src/lib.rs"], "active_task_ids": []}), state.clone()).await.unwrap();
save_ws.execute(json!({"name": "ws_b", "description": "Workspace B", "pinned_files": ["src/main.rs"], "active_task_ids": []}), state.clone()).await.unwrap(); save_ws.execute(json!({"name": "ws_b", "description": "Workspace B", "pinned_files": ["src/main.rs"], "active_task_ids": []}), state.clone()).await.unwrap();
let diff_ws = DiffContextWorkspacesHandler; let diff_ws = DiffContextWorkspacesHandler;
let diff_res = diff_ws.execute(json!({"workspace_a": "ws_a", "workspace_b": "ws_b"}), state.clone()).await.unwrap(); let diff_res = diff_ws
.execute(
json!({"workspace_a": "ws_a", "workspace_b": "ws_b"}),
state.clone(),
)
.await
.unwrap();
assert!(diff_res.contains("workspace_a")); assert!(diff_res.contains("workspace_a"));
} }
@@ -834,7 +961,13 @@ mod tests {
save_ws.execute(json!({"name": "ws_2", "description": "Workspace 2", "pinned_files": ["src/main.rs"], "active_task_ids": []}), state.clone()).await.unwrap(); save_ws.execute(json!({"name": "ws_2", "description": "Workspace 2", "pinned_files": ["src/main.rs"], "active_task_ids": []}), state.clone()).await.unwrap();
let diff_ws = DiffContextWorkspacesHandler; let diff_ws = DiffContextWorkspacesHandler;
let diff_res = diff_ws.execute(json!({"workspace_a": "ws_1", "workspace_b": "ws_2"}), state.clone()).await.unwrap(); let diff_res = diff_ws
.execute(
json!({"workspace_a": "ws_1", "workspace_b": "ws_2"}),
state.clone(),
)
.await
.unwrap();
assert!(diff_res.contains("ws_1")); assert!(diff_res.contains("ws_1"));
} }
} }
@@ -998,7 +1131,10 @@ impl McpTool for CreateSnapshotHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: CreateSnapshotTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: CreateSnapshotTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let snapshot_id = format!("SNAP-{}", uuid::Uuid::new_v4().to_string()[..8].to_uppercase()); let snapshot_id = format!(
"SNAP-{}",
uuid::Uuid::new_v4().to_string()[..8].to_uppercase()
);
let ns = req.namespace.unwrap_or_else(|| "global".to_string()); let ns = req.namespace.unwrap_or_else(|| "global".to_string());
let snapshot = crate::models::StateSnapshot { let snapshot = crate::models::StateSnapshot {
@@ -1006,14 +1142,22 @@ impl McpTool for CreateSnapshotHandler {
timestamp: crate::handlers::utils::now_secs(), timestamp: crate::handlers::utils::now_secs(),
description: req.description.clone(), description: req.description.clone(),
namespace: ns.clone(), namespace: ns.clone(),
..Default::default()
}; };
state.project.snapshots.modify(|snaps| { state.project.snapshots.modify(|snaps| {
snaps.push(snapshot); snaps.push(snapshot);
}); });
state.record_activity("snapshot", &format!("Created snapshot {}: {}", snapshot_id, req.description), None); state.record_activity(
Ok(format!("Snapshot {} created for namespace '{}'", snapshot_id, ns)) "snapshot",
&format!("Created snapshot {}: {}", snapshot_id, req.description),
None,
);
Ok(format!(
"Snapshot {} created for namespace '{}'",
snapshot_id, ns
))
} }
} }
@@ -1031,15 +1175,26 @@ impl McpTool for RestoreSnapshotHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: RestoreSnapshotTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: RestoreSnapshotTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let found = state.project.snapshots.read_with(|snaps| { let found = state
snaps.iter().any(|s| s.id == req.snapshot_id) .project
}); .snapshots
.read_with(|snaps| snaps.iter().any(|s| s.id == req.snapshot_id));
if found { if found {
state.record_activity("snapshot", &format!("Restored snapshot {}", req.snapshot_id), None); state.record_activity(
Ok(format!("Successfully restored memory state from snapshot {}", req.snapshot_id)) "snapshot",
&format!("Restored snapshot {}", req.snapshot_id),
None,
);
Ok(format!(
"Successfully restored memory state from snapshot {}",
req.snapshot_id
))
} else { } else {
Err(crate::error::AppError::Internal(format!("Snapshot {} not found", req.snapshot_id))) Err(crate::error::AppError::Internal(format!(
"Snapshot {} not found",
req.snapshot_id
)))
} }
} }
} }
@@ -1060,20 +1215,35 @@ impl McpTool for ManageSubagentNamespaceHandler {
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ManageSubagentNamespaceTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: ManageSubagentNamespaceTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let subagent_ns = format!("subagent-{}", req.subagent_id); let subagent_ns = format!("subagent-{}", req.subagent_id);
match req.action { match req.action {
SubagentNamespaceAction::Create => { SubagentNamespaceAction::Create => {
state.record_activity("namespace", &format!("Created subagent namespace {}", subagent_ns), None); state.record_activity(
Ok(format!("Subagent memory namespace '{}' initialized.", subagent_ns)) "namespace",
&format!("Created subagent namespace {}", subagent_ns),
None,
);
Ok(format!(
"Subagent memory namespace '{}' initialized.",
subagent_ns
))
} }
SubagentNamespaceAction::Purge => { SubagentNamespaceAction::Purge => {
state.project.pinned_files.modify(|files| { state.project.pinned_files.modify(|files| {
files.retain(|f| f.namespace != subagent_ns); files.retain(|f| f.namespace != subagent_ns);
}); });
state.record_activity("namespace", &format!("Purged subagent namespace {}", subagent_ns), None); state.record_activity(
Ok(format!("Subagent memory namespace '{}' purged.", subagent_ns)) "namespace",
&format!("Purged subagent namespace {}", subagent_ns),
None,
);
Ok(format!(
"Subagent memory namespace '{}' purged.",
subagent_ns
))
} }
SubagentNamespaceAction::Condense => { SubagentNamespaceAction::Condense => {
let purge = req.purge_after_promotion.unwrap_or(true); let purge = req.purge_after_promotion.unwrap_or(true);
@@ -1091,12 +1261,14 @@ impl McpTool for ManageSubagentNamespaceHandler {
for mut e in sub_entities { for mut e in sub_entities {
promoted_entities += 1; promoted_entities += 1;
e.namespace = "global".to_string(); e.namespace = "global".to_string();
let global_entry = g.entities.entry(e.name.clone()).or_insert_with(|| Entity { let global_entry =
g.entities.entry(e.name.clone()).or_insert_with(|| Entity {
name: e.name.clone(), name: e.name.clone(),
entity_type: e.entity_type.clone(), entity_type: e.entity_type.clone(),
observations: Vec::new(), observations: Vec::new(),
namespace: "global".to_string(), namespace: "global".to_string(),
git_branch: e.git_branch.clone(), git_branch: e.git_branch.clone(),
..Default::default()
}); });
for obs in e.observations { for obs in e.observations {
if !global_entry.observations.contains(&obs) { if !global_entry.observations.contains(&obs) {
@@ -1142,13 +1314,21 @@ impl McpTool for CreateSubagentNamespaceHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<CreateSubagentNamespaceTool>("create_subagent_namespace", "Create subagent namespace") crate::mcp::tool_def::<CreateSubagentNamespaceTool>(
"create_subagent_namespace",
"Create subagent namespace",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: CreateSubagentNamespaceTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: CreateSubagentNamespaceTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let ns = format!("subagent-{}", req.subagent_id); let ns = format!("subagent-{}", req.subagent_id);
state.record_activity("namespace", &format!("Created subagent namespace {}", ns), None); state.record_activity(
"namespace",
&format!("Created subagent namespace {}", ns),
None,
);
Ok(format!("Subagent memory namespace '{}' initialized.", ns)) Ok(format!("Subagent memory namespace '{}' initialized.", ns))
} }
} }
@@ -1162,18 +1342,26 @@ impl McpTool for PurgeSubagentNamespaceHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<PurgeSubagentNamespaceTool>("purge_subagent_namespace", "Purge subagent namespace") crate::mcp::tool_def::<PurgeSubagentNamespaceTool>(
"purge_subagent_namespace",
"Purge subagent namespace",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: PurgeSubagentNamespaceTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: PurgeSubagentNamespaceTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let ns = format!("subagent-{}", req.subagent_id); let ns = format!("subagent-{}", req.subagent_id);
state.project.pinned_files.modify(|files| { state.project.pinned_files.modify(|files| {
files.retain(|f| f.namespace != ns); files.retain(|f| f.namespace != ns);
}); });
state.record_activity("namespace", &format!("Purged subagent namespace {}", ns), None); state.record_activity(
"namespace",
&format!("Purged subagent namespace {}", ns),
None,
);
Ok(format!("Subagent memory namespace '{}' purged.", ns)) Ok(format!("Subagent memory namespace '{}' purged.", ns))
} }
} }
@@ -1219,6 +1407,7 @@ impl McpTool for CondenseSubagentNamespaceHandler {
observations: Vec::new(), observations: Vec::new(),
namespace: "global".to_string(), namespace: "global".to_string(),
git_branch: e.git_branch.clone(), git_branch: e.git_branch.clone(),
..Default::default()
}); });
for obs in e.observations { for obs in e.observations {
if !global_entry.observations.contains(&obs) { if !global_entry.observations.contains(&obs) {
@@ -1262,11 +1451,15 @@ impl McpTool for DiffContextWorkspacesHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<DiffContextWorkspacesTool>("diff_context_workspaces", "Execute diff_context_workspaces") crate::mcp::tool_def::<DiffContextWorkspacesTool>(
"diff_context_workspaces",
"Execute diff_context_workspaces",
)
} }
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: DiffContextWorkspacesTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: DiffContextWorkspacesTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let workspaces = state.project.context_workspaces.read_with(|ws| ws.clone()); let workspaces = state.project.context_workspaces.read_with(|ws| ws.clone());
let ws_a = workspaces.iter().find(|w| w.name == req.workspace_a); let ws_a = workspaces.iter().find(|w| w.name == req.workspace_a);
@@ -1275,7 +1468,10 @@ impl McpTool for DiffContextWorkspacesHandler {
if ws_a.is_none() || ws_b.is_none() { if ws_a.is_none() || ws_b.is_none() {
return Err(crate::error::AppError::Internal(format!( return Err(crate::error::AppError::Internal(format!(
"One or both context workspaces not found: '{}' found={}, '{}' found={}", "One or both context workspaces not found: '{}' found={}, '{}' found={}",
req.workspace_a, ws_a.is_some(), req.workspace_b, ws_b.is_some() req.workspace_a,
ws_a.is_some(),
req.workspace_b,
ws_b.is_some()
))); )));
} }
@@ -1314,4 +1510,3 @@ impl McpTool for DiffContextWorkspacesHandler {
Ok(serde_json::to_string_pretty(&diff)?) Ok(serde_json::to_string_pretty(&diff)?)
} }
} }
+13 -5
View File
@@ -73,7 +73,10 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
// Gold Standard: Batch generate embeddings in chunks of 16 to eliminate sequential HTTP overhead // Gold Standard: Batch generate embeddings in chunks of 16 to eliminate sequential HTTP overhead
for chunk_batch in chunks.chunks(16) { for chunk_batch in chunks.chunks(16) {
let texts: Vec<String> = chunk_batch.iter().map(|(_, code, _)| code.clone()).collect(); let texts: Vec<String> = chunk_batch
.iter()
.map(|(_, code, _)| code.clone())
.collect();
let embeddings = generate_embeddings_async(texts).await.unwrap_or_default(); let embeddings = generate_embeddings_async(texts).await.unwrap_or_default();
let mut new_snippets = Vec::with_capacity(chunk_batch.len()); let mut new_snippets = Vec::with_capacity(chunk_batch.len());
@@ -84,7 +87,8 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
for (i, (name, code, desc)) in chunk_batch.iter().enumerate() { for (i, (name, code, desc)) in chunk_batch.iter().enumerate() {
let embedding = embeddings.get(i).cloned(); let embedding = embeddings.get(i).cloned();
let file_name = file_path.file_name().unwrap_or_default().to_string_lossy(); let file_name =
file_path.file_name().unwrap_or_default().to_string_lossy();
let snippet_name = format!("{}:{}", file_name, name); let snippet_name = format!("{}:{}", file_name, name);
let snippet = Snippet { let snippet = Snippet {
@@ -95,6 +99,7 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
updated_at: now, updated_at: now,
tags: vec![], tags: vec![],
embedding, embedding,
..Default::default()
}; };
new_snippets.push(snippet); new_snippets.push(snippet);
} }
@@ -162,7 +167,10 @@ fn extract_chunks_with_parent(
let mut cursor = node.walk(); let mut cursor = node.walk();
let mut type_name = None; let mut type_name = None;
for child in node.children(&mut cursor) { for child in node.children(&mut cursor) {
if child.kind() == "type_identifier" || child.kind() == "name" || child.kind() == "identifier" { if child.kind() == "type_identifier"
|| child.kind() == "name"
|| child.kind() == "identifier"
{
type_name = child.utf8_text(code.as_bytes()).ok(); type_name = child.utf8_text(code.as_bytes()).ok();
break; break;
} }
@@ -189,7 +197,8 @@ fn extract_chunks_with_parent(
let mut cursor = node.walk(); let mut cursor = node.walk();
for child in node.children(&mut cursor) { for child in node.children(&mut cursor) {
let child_kind = child.kind(); let child_kind = child.kind();
if child_kind == "identifier" || child_kind == "name" || child_kind == "type_identifier" { if child_kind == "identifier" || child_kind == "name" || child_kind == "type_identifier"
{
if let Ok(text) = child.utf8_text(code.as_bytes()) { if let Ok(text) = child.utf8_text(code.as_bytes()) {
name = text; name = text;
} }
@@ -284,4 +293,3 @@ mod tests {
start_background_indexer(state).await; start_background_indexer(state).await;
} }
} }
+58 -17
View File
@@ -24,17 +24,17 @@ To prevent graph fragmentation and ensure seamless LLM context retrieval:
The server consolidates granular single-purpose tools into domain-named smart tools. Always prefer the consolidated tools over legacy aliases: The server consolidates granular single-purpose tools into domain-named smart tools. Always prefer the consolidated tools over legacy aliases:
* **`tasks`**: Complete task lifecycle management. * **`tasks`**: Complete task lifecycle management.
- `action: "add"`: Create a new task (requires `title`, optional `description`, `git_branch`, `parent_id`, `dependencies`). - `action: "add"`: Create a new task (requires `title`, optional `description`, `git_branch`, `repo_name`, `priority: "low" | "medium" | "high" | "urgent"`, `assigned_agent`, `verification_command`, `parent_id`, `dependencies`).
- `action: "update"`: Update task status (requires `id`, `status: "pending" | "completed" | "cancelled"`). - `action: "update"`: Update task status (requires `id`, `status: "pending" | "completed" | "cancelled"`).
- `action: "delete"`: Delete task and child tasks (requires `id`). - `action: "delete"`: Delete task and child tasks (requires `id`).
- `action: "list"`: List active tasks (optional `git_branch`, `summary_level: "compact" | "detailed" | "full"`, `max_tokens`). - `action: "list"`: List active tasks (optional `git_branch`, `summary_level: "compact" | "detailed" | "full"`, `max_tokens`).
- `action: "set_criteria"`: Set acceptance criteria (requires `id`, `criteria: Vec<String>`). - `action: "set_criteria"`: Set acceptance criteria (requires `id`, `criteria: Vec<String>`).
- `action: "verify"`: Verify criteria met (requires `id`). - `action: "verify"`: Verify criteria met (requires `id`, optional `proof`).
* **`milestones`**: Milestone tracking. * **`milestones`**: Milestone tracking.
- `action: "add"`: Create milestone (requires `title`). - `action: "add"`: Create milestone (requires `title`, optional `namespace`, `target_date`, `description`, `deliverables: Vec<String>`, `repo_name`).
- `action: "update"`: Update milestone status (requires `id`, `status`). - `action: "update"`: Update milestone status (requires `id`, `status: "active" | "completed" | "cancelled"`).
- `action: "list"`: List milestones. - `action: "list"`: List milestones (optional `namespace`).
* **`sticky_notes`**: Ephemeral scratchpad notes with TTL. * **`sticky_notes`**: Ephemeral scratchpad notes with TTL.
- `action: "add"`: Add note (requires `content`, optional `ttl_seconds`, `session_only`). - `action: "add"`: Add note (requires `content`, optional `ttl_seconds`, `session_only`).
@@ -43,48 +43,48 @@ The server consolidates granular single-purpose tools into domain-named smart to
- `action: "clear"`: Clear all sticky notes. - `action: "clear"`: Clear all sticky notes.
* **`handoff_memos`**: Session handoff notes for future agents. * **`handoff_memos`**: Session handoff notes for future agents.
- `action: "leave"`: Leave a memo (requires `content`). - `action: "leave"`: Leave a memo (requires `content`, optional `vcs_revision`, `repo_name`, `git_branch`, `blockers: Vec<String>`, `action_items: Vec<String>`, `expires_at`).
- `action: "read"`: Read active handoff memos. - `action: "read"`: Read active handoff memos.
- `action: "clear"`: Clear memos. - `action: "clear"`: Clear memos.
* **`pinned_files`**: Focus file working set. * **`pinned_files`**: Focus file working set.
- `action: "pin"`: Pin file to focus set (requires `path`). - `action: "pin"`: Pin file to focus set (requires `path`, optional `pin_reason`, `symbol_or_line`, `repo_name`, `git_branch`).
- `action: "unpin"`: Unpin file from focus set (requires `path`). - `action: "unpin"`: Unpin file from focus set (requires `path`).
- `action: "list"`: List pinned files. - `action: "list"`: List pinned files (optional `namespace`).
* **`context_workspaces`**: Workspace context state snapshots. * **`context_workspaces`**: Workspace context state snapshots.
- `action: "save"`: Save context workspace (requires `name`). - `action: "save"`: Save context workspace (requires `name`, optional `pinned_files`, `active_task_ids`, `description`, `git_branch`, `vcs_revision`, `repo_name`).
- `action: "load"`: Restore saved context workspace (requires `name`). - `action: "load"`: Restore saved context workspace (requires `name`).
- `action: "list"`: List saved context workspaces. - `action: "list"`: List saved context workspaces.
- `action: "delete"`: Delete saved context workspace (requires `name`). - `action: "delete"`: Delete saved context workspace (requires `name`).
- `action: "diff"`: Compare two saved context workspaces (requires `name`, `other_name`). - `action: "diff"`: Compare two saved context workspaces (requires `name`, `other_name`).
* **`pr_checklist`**: Pre-commit and PR checklist. * **`pr_checklist`**: Pre-commit and PR checklist.
- `action: "add"`: Add checklist item (requires `description`). - `action: "add"`: Add checklist item (requires `description`, optional `category: "Security" | "Testing" | "Formatting" | ...`, `automated_check`, `is_checked`, `repo_name`).
- `action: "get"`: Get PR checklist items. - `action: "get"`: Get PR checklist items.
- `action: "clear"`: Clear PR checklist. - `action: "clear"`: Clear PR checklist.
* **`snippets`**: Reusable code snippet vault. * **`snippets`**: Reusable code snippet vault.
- `action: "store"`: Store snippet (requires `query` as name, optional `language`, `code`, `description`, `tags`). - `action: "store"`: Store snippet (requires `query` as name, optional `language`, `code`, `description`, `tags`, `origin_file`, `line_range`, `repo_name`).
- `action: "search"`: Search snippet vault (optional `query`, `tags`, `hybrid: true`). - `action: "search"`: Search snippet vault (optional `query`, `tags`, `hybrid: true`).
- `action: "delete"`: Delete snippet (requires `id`). - `action: "delete"`: Delete snippet (requires `id`).
- `action: "tag"`: Attach classification tags (requires `id`, `tags: Vec<String>`). - `action: "tag"`: Attach classification tags (requires `id`, `tags: Vec<String>`).
* **`decisions`**: Architectural Decision Records (ADRs). * **`decisions`**: Architectural Decision Records (ADRs).
- `action: "log"`: Log ADR (requires `title`, optional `status`, `context`, `decision`, `consequences`). - `action: "log"`: Log ADR (requires `title`, optional `status: "accepted" | "proposed" | "deprecated" | "superseded"`, `context`, `decision`, `consequence`, `author`, `affected_components: Vec<String>`, `alternatives_considered: Vec<String>`, `supersedes`, `repo_name`).
- `action: "query"`: Query ADRs (optional `query`). - `action: "query"`: Query ADRs (optional `query`).
- `action: "delete"`: Delete ADR (requires `id`). - `action: "delete"`: Delete ADR (requires `id`).
* **`tech_debt`**: Engineering debt backlog. * **`tech_debt`**: Engineering debt backlog.
- `action: "log"`: Log debt item (requires `description`, optional `ideal_solution`, `git_commit`, `git_branch`, `symbol_references`, `line_range`). - `action: "log"`: Log debt item (requires `description`, optional `ideal_solution`, `file_path`, `line_range`, `workaround`, `effort_estimate`, `severity: "low" | "medium" | "high" | "critical"`, `git_commit`, `git_branch`, `symbol_references`, `repo_name`).
- `action: "resolve"`: Resolve debt item (requires `id`). - `action: "resolve"`: Resolve debt item (requires `id`).
- `action: "list"`: List debt items (optional `include_resolved`). - `action: "list"`: List debt items (optional `include_resolved`).
* **`environment`**: Infrastructure and requirements tracking. * **`environment`**: Infrastructure and requirements tracking.
- `action: "update_fingerprint"`: Update tool versions. - `action: "update_fingerprint"`: Update tool versions (requires `tool_versions: Map<String, String>`, optional `repo_name`).
- `action: "read_fingerprint"`: Read tool versions fingerprint. - `action: "read_fingerprint"`: Read tool versions fingerprint.
- `action: "log_requirement"`: Log environment variable requirement (requires `key`). - `action: "log_requirement"`: Log environment variable requirement (requires `key`, `description`, `is_secret`, optional `default_value`, `validation_regex`, `repo_name`).
- `action: "register"`: Register target environment (requires `name`). - `action: "register"`: Register target environment (requires `name`, `url`, optional `description`, `requires_vpn`, `env_type: "dev" | "staging" | "qa" | "prod"`, `healthcheck_endpoint`, `ssh_host`, `repo_name`).
- `action: "get_details"`: Read full environment details. - `action: "get_details"`: Read full environment details.
* **`clipboard`**: OS Clipboard management. * **`clipboard`**: OS Clipboard management.
@@ -94,6 +94,26 @@ The server consolidates granular single-purpose tools into domain-named smart to
--- ---
## 3. VCS & SVN Agnosticism & Multi-Repo Provenance
To support diverse enterprise repositories (Git, Subversion / SVN, Mercurial / Hg, Monorepos):
* **`vcs_type`**: Designates the VCS engine (`"git"`, `"svn"`, `"hg"`, `"perforce"`, or `"none"`).
* **`vcs_revision`**: Agnostic commit hash or SVN revision identifier (e.g., `"r12458"`, `"3e4f7a9"`).
* **`upstream_url`**: Canonical remote repository URL (e.g. `https://svn.corp/repo/trunk`, `git@bitbucket.org:org/repo.git`).
* **`repo_name`**: Logical project or repository identifier allowing multiple repositories to share or partition memory namespaces cleanly without collision.
* **Audit Ledger (`log_code_change`)**: Enriched with `vcs_type`, `vcs_revision`, `upstream_url`, `author`, `diff_summary`, and extensible `metadata: HashMap<String, String>`.
---
## 4. Terminal & Process Telemetry
The server ingests and tracks active terminal commands and sessions:
* **Active Terminals**: Tracks PIDs, shell interpreters (`pwsh`, `bash`, `nu`, `zsh`), current working directories (`cwd`), command exit codes, and timestamps.
* **Terminal History Endpoint**: `/terminal/history` exposes recent shell commands and output streams to dashboard and LLMs to prevent lost shell context.
* **Quality Gate Enforcement**: `GateRecord` captures pre-flight and pre-push validation passes with `gate_type`, `enforcer`, `status`, `validation_log`, and `repo_name`.
---
## 4. High-Signal Tool Responses & Performance Guidelines ## 4. High-Signal Tool Responses & Performance Guidelines
To optimize context usage, response times, and LLM reasoning efficiency: To optimize context usage, response times, and LLM reasoning efficiency:
@@ -157,8 +177,16 @@ To maintain maximum security, speed, and cross-platform reliability:
--- ---
## 10. High-Performance Concurrency & Resilience Guarantees ## 10. High-Performance Concurrency & Resilience Guarantees
* **Explicit Fail-Fast Persistence Safety**: Replaced silent fallback to temporary databases (`/tmp/mcp_store_fallback_*`) with an explicit open retry and fail-fast panic unless `MCP_ALLOW_TMP_FALLBACK=1` is explicitly set, preventing silent data loss.
* **Async Mutex Deadlock Elimination**: Converted shared state and Neovim connection locks (`shutdown_tx`, `NVIM_CONN`, `ACTIVE_SOCKET`, `HEADLESS_PROC`) to `tokio::sync::Mutex` to prevent worker thread pool starvation across `.await` points.
* **Telemetry Session Deduplication & Channel Pruning**: Added `LAST_SESSION` in-memory state deduplication for UDP telemetry writes (eliminating disk I/O thrashing) and distinguished WebSocket `TrySendError::Full` backpressure vs `TrySendError::Closed` client pruning.
* **Graph Adjacency Indexing**: Leverages `KnowledgeGraph::build_adjacency_map` to build $O(1)$ lookup adjacency lists for fast BFS shortest path graph queries.
* **Atomic Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` release write guards immediately after in-memory state mutations, serializing JSON payloads under read locks to allow non-blocking concurrent readers.
* **Async Commit Index Reader Auto-Reload**: `MemoryIndex::commit()` automatically triggers `reader.reload()` upon completing background commits, guaranteeing immediate visibility of newly indexed document terms.
* **Zero-Allocation NDJSON Reader**: `mcp-stdio` reclaims line buffers via `std::mem::take` and in-place trimming to eliminate heap allocations during high-frequency NDJSON message parsing. * **Zero-Allocation NDJSON Reader**: `mcp-stdio` reclaims line buffers via `std::mem::take` and in-place trimming to eliminate heap allocations during high-frequency NDJSON message parsing.
* **Micro-Batched Fastembed Inference**: `generate_embeddings_async` chunks text arrays into 32-item micro-batches inside `spawn_blocking`, eliminating RAM/CPU spikes during batch indexing. * **Dynamic Character Micro-Batched Fastembed Inference**: `generate_embeddings_async` dynamically batches text arrays using a 16,000 character budget ceiling inside `spawn_blocking`, preventing heap spikes during vector indexing while keeping ONNX SIMD execution saturated.
* **Bounded Telemetry Detail Records**: Activity and terminal telemetry buffers enforce a 4,000 character truncation ceiling on log details (`ActivityRecord`, `TerminalHistory`) to prevent unbounded RAM growth under high RPC throughput.
* **Zero-Allocation Stream Formatting**: Graph condensation loops (`condense_graph_worker`) format node/relation subgraphs into stream buffers via `std::fmt::Write`, eliminating intermediate String allocation overhead.
* **Single-Handle OS Clipboard Retries & Image Downsampling**: `WriteClipboardHandler` initializes OS clipboard handles once per operation and downsamples images exceeding $2048 \times 2048$ resolution before writing raw RGBA bytes. * **Single-Handle OS Clipboard Retries & Image Downsampling**: `WriteClipboardHandler` initializes OS clipboard handles once per operation and downsamples images exceeding $2048 \times 2048$ resolution before writing raw RGBA bytes.
* **Zero Transaction Drop Persistence Guarantee**: `Store::modify` automatically spawns an async task to execute `push_async` with channel backpressure if `push` encounters queue saturation, ensuring zero data loss under spike write loads. * **Zero Transaction Drop Persistence Guarantee**: `Store::modify` automatically spawns an async task to execute `push_async` with channel backpressure if `push` encounters queue saturation, ensuring zero data loss under spike write loads.
* **Non-Blocking Read Lock Sticky Notes**: `StickyNotesHandler` (`action = "read"`) queries notes using shared read locks, executing write pruning only when expired items exist. * **Non-Blocking Read Lock Sticky Notes**: `StickyNotesHandler` (`action = "read"`) queries notes using shared read locks, executing write pruning only when expired items exist.
@@ -183,3 +211,16 @@ To maintain maximum security, speed, and cross-platform reliability:
* **Directory Tree Depth Safeguard**: `ReadDirectoryArchitectureHandler` caps directory recursion at depth 10 to prevent stack overflow on deep or cyclic directory structures. * **Directory Tree Depth Safeguard**: `ReadDirectoryArchitectureHandler` caps directory recursion at depth 10 to prevent stack overflow on deep or cyclic directory structures.
* **Deterministic Total-Order Score Ranking**: `OmniSearchHandler` uses `f64::total_cmp` for Reciprocal Rank Fusion (RRF) score sorting, guaranteeing deterministic NaN-safe search result ordering. * **Deterministic Total-Order Score Ranking**: `OmniSearchHandler` uses `f64::total_cmp` for Reciprocal Rank Fusion (RRF) score sorting, guaranteeing deterministic NaN-safe search result ordering.
* **RPC Timeout Memory Hygiene**: `nvim-core` maintains request hygiene by removing pending request entries from static RPC maps upon timeout or channel drop, eliminating orphan memory leaks. * **RPC Timeout Memory Hygiene**: `nvim-core` maintains request hygiene by removing pending request entries from static RPC maps upon timeout or channel drop, eliminating orphan memory leaks.
* **Embedding Input Safeguard**: `generate_embedding_async` returns explicit errors for empty/0-length text inputs instead of returning empty vectors, preventing downstream vector dimension mismatches during cosine similarity calculations.
* **Path Traversal Security Guards**: `validate_safe_path` enforces path canonicalization and rejects relative parent traversal components (`..`) across file and process log handlers (`GetRecentLogsTool`, `WatchProcessLogsTool`).
* **Watcher Map Memory Eviction**: Proactive daemon file watcher in `watcher.rs` caps `last_processed` map size at 1,000 entries and purges entries older than 10 minutes to prevent monotonic memory leakage.
* **Comprehensive Serde Casing Aliases**: All 11 consolidated tool action enums (`TaskAction`, `MilestoneAction`, `PinnedFileAction`, `ContextWorkspaceAction`, `PrChecklistAction`, `SnippetAction`, `DecisionAction`, `TechDebtAction`, `EnvAction`, `ClipboardAction`, `HandoffMemoAction`) include serde alias attributes supporting `snake_case`, `camelCase`, `PascalCase`, and uppercase variants for maximum LLM casing resilience.
* **Two-Phase Graph Condensation**: `condense_graph_worker` uses a 2-phase commit (non-destructive `read_with` -> graph insert -> prune by timestamp/content) to prevent data loss if summarization or graph insertion fails.
* **Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` unblock concurrent readers during JSON serialization by releasing the write lock immediately after mutating memory state.
* **Redb Database Lock Retry Backoff**: `init_db` retries transient Redb lock contention with exponential backoff (3 attempts, 150ms delay) before falling back.
* **Offloaded Background Index Rebuilds**: `MemoryState::rebuild_index` offloads graph snapshot cloning and Tantivy document re-indexing into `tokio::task::spawn_blocking` to avoid stalling async event loops.
* **Broadcast Watch-Based Shutdown Channels**: Background workers utilize `tokio::sync::watch` for broadcast shutdown notifications without consuming cancellation signals.
* **Consolidated Neovim Tool Suite (v2)**: The Neovim server exposes 7 consolidated domain tools (`nvim_buffer`, `nvim_window`, `nvim_view`, `nvim_diagnostics`, `nvim_visual`, `nvim_execute_lua`, `nvim_system`) with comprehensive action dispatching.
* **Fallback Vector Search Parity**: In-memory vector search fallback indexes Knowledge Graph entities, observations, and error fixes when external vector databases are unavailable.
+32 -21
View File
@@ -4,7 +4,6 @@
)] )]
pub mod api; pub mod api;
pub mod clipboard_watcher;
pub mod db; pub mod db;
pub mod embedding; pub mod embedding;
pub mod error; pub mod error;
@@ -18,7 +17,6 @@ pub mod search;
pub mod state; pub mod state;
pub mod store; pub mod store;
pub mod tools; pub mod tools;
pub mod vector_db;
pub mod watcher; pub mod watcher;
use crate::api::rest::GateSetReq; use crate::api::rest::GateSetReq;
@@ -164,6 +162,7 @@ pub async fn condense_graph_worker(state: Arc<MemoryState>) {
observations: vec![condensed_sticky_content], observations: vec![condensed_sticky_content],
namespace: crate::models::default_namespace(), namespace: crate::models::default_namespace(),
git_branch: None, git_branch: None,
..Default::default()
}, },
); );
}); });
@@ -196,6 +195,7 @@ pub async fn condense_graph_worker(state: Arc<MemoryState>) {
observations: vec![condensed_snippet_content], observations: vec![condensed_snippet_content],
namespace: crate::models::default_namespace(), namespace: crate::models::default_namespace(),
git_branch: None, git_branch: None,
..Default::default()
}, },
); );
}); });
@@ -216,7 +216,6 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
tokio::spawn(index_committer_worker(Arc::clone(&state))); tokio::spawn(index_committer_worker(Arc::clone(&state)));
tokio::spawn(ttl_sweeper_worker(Arc::clone(&state))); tokio::spawn(ttl_sweeper_worker(Arc::clone(&state)));
tokio::spawn(condense_graph_worker(Arc::clone(&state))); tokio::spawn(condense_graph_worker(Arc::clone(&state)));
crate::clipboard_watcher::spawn_watcher(Arc::clone(&state));
crate::watcher::spawn_watcher(Arc::clone(&state)); crate::watcher::spawn_watcher(Arc::clone(&state));
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let app_state = Arc::new(AppState { let app_state = Arc::new(AppState {
@@ -315,7 +314,8 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
if let Some(home) = dirs::home_dir() { if let Some(home) = dirs::home_dir() {
let nvim_dir = home.join(".gemini"); let nvim_dir = home.join(".gemini");
let nvim_txt_path = nvim_dir.join("active_nvim.txt"); let nvim_txt_path = nvim_dir.join("active_nvim.txt");
let tmp_path = nvim_dir.join(format!("active_nvim_{}.tmp", std::process::id())); let tmp_path =
nvim_dir.join(format!("active_nvim_{}.tmp", std::process::id()));
if tokio::fs::create_dir_all(&nvim_dir).await.is_ok() if tokio::fs::create_dir_all(&nvim_dir).await.is_ok()
&& tokio::fs::write(&tmp_path, session).await.is_ok() && tokio::fs::write(&tmp_path, session).await.is_ok()
{ {
@@ -561,16 +561,6 @@ pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
rt.block_on(async { rt.block_on(async {
let state = Arc::new(MemoryState::new(&base.to_string_lossy())); let state = Arc::new(MemoryState::new(&base.to_string_lossy()));
match crate::vector_db::VectorDB::new("http://localhost:6334", "mcp_memory").await {
Ok(vdb) => {
tracing::info!("Successfully connected to Qdrant vector database");
*state.vector_db.write().await = Some(vdb);
}
Err(e) => {
tracing::warn!("Failed to initialize Qdrant vector database: {}. Vector search will fallback to manual embedding loop. (Is Qdrant running on localhost:6334?)", e);
}
}
if let Err(e) = run_server(state).await { if let Err(e) = run_server(state).await {
tracing::error!("Server error: {}", e); tracing::error!("Server error: {}", e);
} }
@@ -594,7 +584,13 @@ mod tests {
#[test] #[test]
fn test_cli_parsing_exit_and_target() { fn test_cli_parsing_exit_and_target() {
let cli = Cli::try_parse_from(&["mcp-memory-server", "--exit", "--target", "http://localhost:3000"]).unwrap(); let cli = Cli::try_parse_from(&[
"mcp-memory-server",
"--exit",
"--target",
"http://localhost:3000",
])
.unwrap();
assert!(cli.exit); assert!(cli.exit);
assert_eq!(cli.target.as_deref(), Some("http://localhost:3000")); assert_eq!(cli.target.as_deref(), Some("http://localhost:3000"));
} }
@@ -615,7 +611,17 @@ mod tests {
]) ])
.unwrap(); .unwrap();
if let Some(Commands::Gate { subcmd: GateCommands::Set { action, target, authorize, reason, .. } }) = cli.command { if let Some(Commands::Gate {
subcmd:
GateCommands::Set {
action,
target,
authorize,
reason,
..
},
}) = cli.command
{
assert_eq!(action, "git_push"); assert_eq!(action, "git_push");
assert_eq!(target, "master"); assert_eq!(target, "master");
assert!(authorize); assert!(authorize);
@@ -639,7 +645,16 @@ mod tests {
]) ])
.unwrap(); .unwrap();
if let Some(Commands::Gate { subcmd: GateCommands::Verify { action, target, consume, .. } }) = cli.command { if let Some(Commands::Gate {
subcmd:
GateCommands::Verify {
action,
target,
consume,
..
},
}) = cli.command
{
assert_eq!(action, "deploy"); assert_eq!(action, "deploy");
assert_eq!(target, "prod"); assert_eq!(target, "prod");
assert!(consume); assert!(consume);
@@ -709,7 +724,3 @@ mod tests {
server_handle.abort(); server_handle.abort();
} }
} }
+382 -7
View File
@@ -2,24 +2,109 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ChangeKind {
Added,
Modified,
Deleted,
Renamed,
}
impl Default for ChangeKind {
fn default() -> Self {
ChangeKind::Modified
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CodeChange { pub struct CodeChange {
pub timestamp: u64, pub timestamp: u64,
pub file_path: String, pub file_path: String,
pub description: String, pub description: String,
#[serde(default)]
pub git_commit: Option<String>, pub git_commit: Option<String>,
#[serde(default)]
pub git_branch: Option<String>, pub git_branch: Option<String>,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub repo_url: Option<String>,
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default)]
pub change_kind: ChangeKind,
#[serde(default)]
pub symbols: Vec<String>,
#[serde(default)]
pub line_range: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub vcs_type: Option<String>,
#[serde(default)]
pub revision: Option<String>,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub repository_root: Option<String>,
}
impl CodeChange {
pub fn effective_vcs(&self) -> &str {
if let Some(vcs) = &self.vcs_type {
return vcs.as_str();
}
if let Some(rev) = self.revision.as_deref().or(self.git_commit.as_deref()) {
if rev.starts_with('r') && rev[1..].chars().all(|c| c.is_ascii_digit()) {
return "svn";
}
}
if let Some(br) = self.branch.as_deref().or(self.git_branch.as_deref()) {
if br.eq_ignore_ascii_case("trunk")
|| br.starts_with("branches/")
|| br.starts_with("tags/")
{
return "svn";
}
}
"git"
}
pub fn effective_revision(&self) -> Option<&str> {
self.revision.as_deref().or(self.git_commit.as_deref())
}
pub fn effective_branch(&self) -> Option<&str> {
self.branch.as_deref().or(self.git_branch.as_deref())
}
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StickyNote { pub struct StickyNote {
#[serde(default = "generate_uuid")]
pub id: String,
pub timestamp: u64, pub timestamp: u64,
pub content: String, pub content: String,
#[serde(default)] #[serde(default)]
pub expires_at: Option<u64>, pub expires_at: Option<u64>,
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub author: Option<String>,
} }
pub fn generate_uuid() -> String {
uuid::Uuid::new_v4().to_string()
}
pub fn default_namespace() -> String { pub fn default_namespace() -> String {
"global".to_string() "global".to_string()
} }
pub fn normalize_entity_type(input: &str) -> String { pub fn normalize_entity_type(input: &str) -> String {
let trimmed = input.trim(); let trimmed = input.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -47,7 +132,9 @@ pub fn normalize_entity_type(input: &str) -> String {
"McpTool" | "Mcptool" | "Mcp" => "McpTool".to_string(), "McpTool" | "Mcptool" | "Mcp" => "McpTool".to_string(),
"Databasetable" | "DatabaseTable" => "DatabaseTable".to_string(), "Databasetable" | "DatabaseTable" => "DatabaseTable".to_string(),
"Datastructure" | "DataStore" | "Datastore" => "DataStructure".to_string(), "Datastructure" | "DataStore" | "Datastore" => "DataStructure".to_string(),
"Architecturecomponent" | "ArchitectureComponent" | "Architecture" => "ArchitectureComponent".to_string(), "Architecturecomponent" | "ArchitectureComponent" | "Architecture" => {
"ArchitectureComponent".to_string()
}
"Frontendcomponent" | "FrontendComponent" => "FrontendComponent".to_string(), "Frontendcomponent" | "FrontendComponent" => "FrontendComponent".to_string(),
"Globalrule" | "GlobalRule" => "GlobalRule".to_string(), "Globalrule" | "GlobalRule" => "GlobalRule".to_string(),
"Securitypolicy" | "SecurityPolicy" => "SecurityPolicy".to_string(), "Securitypolicy" | "SecurityPolicy" => "SecurityPolicy".to_string(),
@@ -84,7 +171,10 @@ pub enum TaskStatus {
impl TaskStatus { impl TaskStatus {
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
matches!(self, TaskStatus::Active | TaskStatus::InProgress | TaskStatus::Blocked) matches!(
self,
TaskStatus::Active | TaskStatus::InProgress | TaskStatus::Blocked
)
} }
pub fn is_completed(&self) -> bool { pub fn is_completed(&self) -> bool {
@@ -107,7 +197,7 @@ impl std::fmt::Display for TaskStatus {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct Entity { pub struct Entity {
pub name: String, pub name: String,
#[serde(alias = "entityType", alias = "type", alias = "category")] #[serde(alias = "entityType", alias = "type", alias = "category")]
@@ -118,8 +208,19 @@ pub struct Entity {
pub namespace: String, pub namespace: String,
#[serde(default)] #[serde(default)]
pub git_branch: Option<String>, pub git_branch: Option<String>,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub file_path: Option<String>,
#[serde(default)]
pub created_at: Option<u64>,
#[serde(default)]
pub updated_at: Option<u64>,
#[serde(default)]
pub embedding: Option<Vec<f32>>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct Relation { pub struct Relation {
#[serde(alias = "source", alias = "from_node", alias = "source_node")] #[serde(alias = "source", alias = "from_node", alias = "source_node")]
pub from: String, pub from: String,
@@ -129,7 +230,36 @@ pub struct Relation {
pub relation_type: String, pub relation_type: String,
#[serde(default = "default_namespace")] #[serde(default = "default_namespace")]
pub namespace: String, pub namespace: String,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub weight: Option<f32>,
#[serde(default)]
pub is_bidirectional: bool,
} }
impl PartialEq for Relation {
fn eq(&self, other: &Self) -> bool {
self.from == other.from
&& self.to == other.to
&& self.relation_type == other.relation_type
&& self.namespace == other.namespace
&& self.repo_name == other.repo_name
}
}
impl Eq for Relation {}
impl std::hash::Hash for Relation {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.from.hash(state);
self.to.hash(state);
self.relation_type.hash(state);
self.namespace.hash(state);
self.repo_name.hash(state);
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct KnowledgeGraph { pub struct KnowledgeGraph {
#[serde(default)] #[serde(default)]
@@ -137,11 +267,49 @@ pub struct KnowledgeGraph {
#[serde(default)] #[serde(default)]
pub relations: Vec<Relation>, pub relations: Vec<Relation>,
} }
impl KnowledgeGraph {
/// Builds an adjacency index mapping node names to lists of outgoing/incoming neighbors and relation types.
pub fn build_adjacency_map<'a>(
&'a self,
relation_filter: Option<&'a str>,
) -> HashMap<&'a str, Vec<(&'a str, &'a str, bool)>> {
let mut adj: HashMap<&'a str, Vec<(&'a str, &'a str, bool)>> =
HashMap::with_capacity(self.relations.len() * 2);
for rel in &self.relations {
if let Some(rf) = relation_filter {
if rel.relation_type != rf {
continue;
}
}
adj.entry(rel.from.as_str()).or_default().push((
rel.to.as_str(),
rel.relation_type.as_str(),
false,
));
adj.entry(rel.to.as_str()).or_default().push((
rel.from.as_str(),
rel.relation_type.as_str(),
true,
));
}
adj
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AcceptanceCriteria { pub struct AcceptanceCriteria {
pub id: String, pub id: String,
pub description: String, pub description: String,
pub is_met: bool, pub is_met: bool,
#[serde(default)]
pub verification_command: Option<String>,
#[serde(default)]
pub verification_output: Option<String>,
#[serde(default)]
pub verified_at: Option<u64>,
#[serde(default)]
pub verified_by: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -161,6 +329,16 @@ pub struct Task {
pub dependencies: Vec<String>, pub dependencies: Vec<String>,
#[serde(default)] #[serde(default)]
pub acceptance_criteria: Vec<AcceptanceCriteria>, pub acceptance_criteria: Vec<AcceptanceCriteria>,
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub priority: Option<String>,
#[serde(default)]
pub assigned_agent: Option<String>,
#[serde(default)]
pub verification_command: Option<String>,
} }
impl Task { impl Task {
@@ -172,6 +350,7 @@ impl Task {
&& !s.eq_ignore_ascii_case("canceled") && !s.eq_ignore_ascii_case("canceled")
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Snippet { pub struct Snippet {
pub name: String, pub name: String,
@@ -183,7 +362,16 @@ pub struct Snippet {
pub tags: Vec<String>, pub tags: Vec<String>,
#[serde(default)] #[serde(default)]
pub embedding: Option<Vec<f32>>, pub embedding: Option<Vec<f32>>,
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub origin_file: Option<String>,
#[serde(default)]
pub line_range: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Adr { pub struct Adr {
pub id: String, pub id: String,
@@ -196,17 +384,35 @@ pub struct Adr {
#[serde(default)] #[serde(default)]
pub supersedes: Option<String>, pub supersedes: Option<String>,
pub timestamp: u64, pub timestamp: u64,
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub alternatives_considered: Vec<String>,
#[serde(default)]
pub affected_components: Vec<String>,
#[serde(default)]
pub author: Option<String>,
} }
fn default_adr_status() -> String { fn default_adr_status() -> String {
"accepted".to_string() "accepted".to_string()
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Preference { pub struct Preference {
pub key: String, pub key: String,
pub value: String, pub value: String,
pub updated_at: u64, pub updated_at: u64,
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub rationale: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ErrorFix { pub struct ErrorFix {
pub signature: String, pub signature: String,
@@ -216,14 +422,32 @@ pub struct ErrorFix {
pub git_branch: Option<String>, pub git_branch: Option<String>,
#[serde(default)] #[serde(default)]
pub embedding: Option<Vec<f32>>, pub embedding: Option<Vec<f32>>,
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub error_category: Option<String>,
#[serde(default)]
pub stack_trace: Option<String>,
#[serde(default)]
pub toolchain: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PinnedFile { pub struct PinnedFile {
pub namespace: String, pub namespace: String,
pub file_path: String, pub file_path: String,
pub timestamp: u64, pub timestamp: u64,
pub git_branch: Option<String>, pub git_branch: Option<String>,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub symbol_or_line: Option<String>,
#[serde(default)]
pub pin_reason: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionSummary { pub struct SessionSummary {
pub timestamp: u64, pub timestamp: u64,
@@ -231,7 +455,20 @@ pub struct SessionSummary {
pub namespace: String, pub namespace: String,
#[serde(default)] #[serde(default)]
pub expires_at: Option<u64>, pub expires_at: Option<u64>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub git_branch: Option<String>,
#[serde(default)]
pub git_commit: Option<String>,
#[serde(default)]
pub tasks_completed: Vec<String>,
#[serde(default)]
pub next_steps: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HandoffMemo { pub struct HandoffMemo {
pub id: String, pub id: String,
@@ -241,7 +478,20 @@ pub struct HandoffMemo {
pub expires_at: Option<u64>, pub expires_at: Option<u64>,
pub namespace: String, pub namespace: String,
pub timestamp: u64, pub timestamp: u64,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub git_branch: Option<String>,
#[serde(default)]
pub vcs_revision: Option<String>,
#[serde(default)]
pub blockers: Vec<String>,
#[serde(default)]
pub action_items: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvFingerprint { pub struct EnvFingerprint {
pub namespace: String, pub namespace: String,
@@ -249,14 +499,24 @@ pub struct EnvFingerprint {
pub shell: String, pub shell: String,
pub tool_versions: HashMap<String, String>, pub tool_versions: HashMap<String, String>,
pub updated_at: u64, pub updated_at: u64,
#[serde(default)]
pub repo_name: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvRequirement { pub struct EnvRequirement {
pub namespace: String, pub namespace: String,
pub key: String, pub key: String,
pub description: String, pub description: String,
pub is_secret: bool, pub is_secret: bool,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub default_value: Option<String>,
#[serde(default)]
pub validation_regex: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Milestone { pub struct Milestone {
pub id: String, pub id: String,
@@ -264,7 +524,18 @@ pub struct Milestone {
pub status: String, pub status: String,
pub namespace: String, pub namespace: String,
pub target_date: Option<u64>, pub target_date: Option<u64>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub task_ids: Vec<String>,
#[serde(default)]
pub deliverables: Vec<String>,
#[serde(default)]
pub completed_at: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvironmentDetail { pub struct EnvironmentDetail {
pub namespace: String, pub namespace: String,
@@ -273,13 +544,31 @@ pub struct EnvironmentDetail {
pub description: String, pub description: String,
pub requires_vpn: bool, pub requires_vpn: bool,
pub updated_at: u64, pub updated_at: u64,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub env_type: Option<String>,
#[serde(default)]
pub healthcheck_endpoint: Option<String>,
#[serde(default)]
pub ssh_host: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PrChecklistItem { pub struct PrChecklistItem {
pub namespace: String, pub namespace: String,
pub id: String, pub id: String,
pub description: String, pub description: String,
#[serde(default)]
pub is_checked: bool,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub automated_check: Option<String>,
#[serde(default)]
pub repo_name: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TechDebt { pub struct TechDebt {
pub id: String, pub id: String,
@@ -292,7 +581,20 @@ pub struct TechDebt {
pub git_branch: Option<String>, pub git_branch: Option<String>,
#[serde(default)] #[serde(default)]
pub embedding: Option<Vec<f32>>, pub embedding: Option<Vec<f32>>,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub severity: Option<String>,
#[serde(default)]
pub file_path: Option<String>,
#[serde(default)]
pub line_range: Option<String>,
#[serde(default)]
pub workaround: Option<String>,
#[serde(default)]
pub effort_estimate: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ContextWorkspace { pub struct ContextWorkspace {
pub name: String, pub name: String,
@@ -300,6 +602,14 @@ pub struct ContextWorkspace {
pub pinned_files: Vec<String>, pub pinned_files: Vec<String>,
pub active_task_ids: Vec<String>, pub active_task_ids: Vec<String>,
pub saved_at: u64, pub saved_at: u64,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub git_branch: Option<String>,
#[serde(default)]
pub vcs_revision: Option<String>,
#[serde(default)]
pub description: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -312,15 +622,44 @@ pub struct GateRecord {
pub status: String, pub status: String,
pub reason: Option<String>, pub reason: Option<String>,
pub timestamp: u64, pub timestamp: u64,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub gate_type: Option<String>,
#[serde(default)]
pub enforcer: Option<String>,
#[serde(default)]
pub validation_log: Option<String>,
#[serde(default)]
pub expires_at: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TerminalHistory { pub struct TerminalHistory {
#[serde(default = "generate_uuid")]
pub id: String,
pub command: String, pub command: String,
pub exit_code: i32, pub exit_code: i32,
pub cwd: String, pub cwd: String,
#[serde(default)] #[serde(default)]
pub os: String, pub os: String,
pub timestamp: u64, pub timestamp: u64,
#[serde(default)]
pub error_output: Option<String>,
#[serde(default)]
pub shell: Option<String>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub stdout_summary: Option<String>,
#[serde(default)]
pub status_reason: Option<String>,
#[serde(default)]
pub invoker: Option<String>,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default = "default_namespace")]
pub namespace: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -329,6 +668,16 @@ pub struct ActivityRecord {
pub category: String, // e.g. "code_change", "task_update", "telemetry", "error_fix" pub category: String, // e.g. "code_change", "task_update", "telemetry", "error_fix"
pub summary: String, // Brief human-readable description of what is being worked on pub summary: String, // Brief human-readable description of what is being worked on
pub details: Option<String>, pub details: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub actor: Option<String>,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default)]
pub associated_file: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -337,6 +686,14 @@ pub struct StateSnapshot {
pub timestamp: u64, pub timestamp: u64,
pub description: String, pub description: String,
pub namespace: String, pub namespace: String,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub git_branch: Option<String>,
#[serde(default)]
pub vcs_revision: Option<String>,
#[serde(default)]
pub active_tasks_count: usize,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -347,6 +704,14 @@ pub struct Hypothesis {
pub status: String, pub status: String,
pub evidence: Option<String>, pub evidence: Option<String>,
pub timestamp: u64, pub timestamp: u64,
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub validation_steps: Vec<String>,
#[serde(default)]
pub diagnostician: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -357,6 +722,12 @@ pub struct AgentSignal {
pub payload: String, pub payload: String,
pub timestamp: u64, pub timestamp: u64,
pub ttl_seconds: Option<u64>, pub ttl_seconds: Option<u64>,
#[serde(default)]
pub recipient: Option<String>,
#[serde(default)]
pub correlation_id: Option<String>,
#[serde(default)]
pub priority: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -368,13 +739,18 @@ pub struct TaskEvent {
pub error: Option<String>, pub error: Option<String>,
pub timestamp: u64, pub timestamp: u64,
pub session_id: Option<String>, pub session_id: Option<String>,
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub repo_name: Option<String>,
#[serde(default)]
pub actor: Option<String>,
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::error::AppError;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use crate::error::AppError;
#[test] #[test]
fn test_app_error_bad_request_invalid_json() { fn test_app_error_bad_request_invalid_json() {
@@ -383,4 +759,3 @@ mod tests {
assert_eq!(res.status(), StatusCode::BAD_REQUEST); assert_eq!(res.status(), StatusCode::BAD_REQUEST);
} }
} }
+240 -47
View File
@@ -113,11 +113,13 @@ impl McpResource for TasksActiveResource {
Some("List of currently active tasks") Some("List of currently active tasks")
} }
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
let tasks = state.project.tasks.cache.read().unwrap_or_else(|e| e.into_inner()); let tasks = state
let data: Vec<_> = tasks .project
.iter() .tasks
.filter(|t| t.is_active()) .cache
.collect(); .read()
.unwrap_or_else(|e| e.into_inner());
let data: Vec<_> = tasks.iter().filter(|t| t.is_active()).collect();
Ok(serde_json::to_string_pretty(&data)?) Ok(serde_json::to_string_pretty(&data)?)
} }
} }
@@ -135,7 +137,12 @@ impl McpResource for DecisionsActiveResource {
Some("All accepted Architectural Decision Records (ADRs)") Some("All accepted Architectural Decision Records (ADRs)")
} }
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
let adrs = state.code.adrs.cache.read().unwrap_or_else(|e| e.into_inner()); let adrs = state
.code
.adrs
.cache
.read()
.unwrap_or_else(|e| e.into_inner());
let data: Vec<_> = adrs.iter().filter(|a| a.status == "accepted").collect(); let data: Vec<_> = adrs.iter().filter(|a| a.status == "accepted").collect();
Ok(serde_json::to_string_pretty(&data)?) Ok(serde_json::to_string_pretty(&data)?)
} }
@@ -154,7 +161,12 @@ impl McpResource for TechDebtActiveResource {
Some("All currently unresolved technical debt items") Some("All currently unresolved technical debt items")
} }
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
let debts = state.code.tech_debts.cache.read().unwrap_or_else(|e| e.into_inner()); let debts = state
.code
.tech_debts
.cache
.read()
.unwrap_or_else(|e| e.into_inner());
let data: Vec<_> = debts.iter().filter(|d| !d.is_resolved).collect(); let data: Vec<_> = debts.iter().filter(|d| !d.is_resolved).collect();
Ok(serde_json::to_string_pretty(&data)?) Ok(serde_json::to_string_pretty(&data)?)
} }
@@ -233,14 +245,32 @@ impl McpResource for SessionDeltaResource {
let lookback = 7200; // last 2 hours let lookback = 7200; // last 2 hours
let cutoff = now.saturating_sub(lookback); let cutoff = now.saturating_sub(lookback);
let changes = state.code.ledger.cache.read().unwrap_or_else(|e| e.into_inner()); let changes = state
.code
.ledger
.cache
.read()
.unwrap_or_else(|e| e.into_inner());
let recent_changes: Vec<_> = changes.iter().filter(|c| c.timestamp >= cutoff).collect(); let recent_changes: Vec<_> = changes.iter().filter(|c| c.timestamp >= cutoff).collect();
let tasks = state.project.tasks.cache.read().unwrap_or_else(|e| e.into_inner()); let tasks = state
.project
.tasks
.cache
.read()
.unwrap_or_else(|e| e.into_inner());
let active_tasks: Vec<_> = tasks.iter().filter(|t| t.is_active()).collect(); let active_tasks: Vec<_> = tasks.iter().filter(|t| t.is_active()).collect();
let notes = state.code.sticky.cache.read().unwrap_or_else(|e| e.into_inner()); let notes = state
let active_notes: Vec<_> = notes.iter().filter(|n| n.expires_at.map_or(true, |exp| exp > now)).collect(); .code
.sticky
.cache
.read()
.unwrap_or_else(|e| e.into_inner());
let active_notes: Vec<_> = notes
.iter()
.filter(|n| n.expires_at.map_or(true, |exp| exp > now))
.collect();
let delta = serde_json::json!({ let delta = serde_json::json!({
"recent_changes": recent_changes, "recent_changes": recent_changes,
@@ -260,7 +290,9 @@ impl McpPrompt for ContextWarmupPrompt {
"context_warmup" "context_warmup"
} }
fn description(&self) -> Option<&'static str> { fn description(&self) -> Option<&'static str> {
Some("Consolidated context warmup prompt providing active tasks, pinned files, open tech debt, and sticky notes") Some(
"Consolidated context warmup prompt providing active tasks, pinned files, open tech debt, and sticky notes",
)
} }
async fn get( async fn get(
&self, &self,
@@ -334,7 +366,34 @@ impl MemoryHandler {
Some("Recent terminal execution history and exit codes") Some("Recent terminal execution history and exit codes")
} }
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
let items = state.telemetry.terminal_history.cache.read().unwrap_or_else(|e| e.into_inner()); let items = state
.telemetry
.terminal_history
.cache
.read()
.unwrap_or_else(|e| e.into_inner());
Ok(serde_json::to_string_pretty(&*items)?)
}
}
struct RecentActivitiesResource;
#[async_trait]
impl McpResource for RecentActivitiesResource {
fn uri(&self) -> &'static str {
"memory://activity/recent"
}
fn name(&self) -> &'static str {
"Recent Activity"
}
fn description(&self) -> Option<&'static str> {
Some("Recent IDE and developer activity logs")
}
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
let items = state
.telemetry
.recent_activities
.cache
.read()
.unwrap_or_else(|e| e.into_inner());
Ok(serde_json::to_string_pretty(&*items)?) Ok(serde_json::to_string_pretty(&*items)?)
} }
} }
@@ -351,7 +410,12 @@ impl MemoryHandler {
Some("Currently pinned files in the workspace") Some("Currently pinned files in the workspace")
} }
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
let items = state.project.pinned_files.cache.read().unwrap_or_else(|e| e.into_inner()); let items = state
.project
.pinned_files
.cache
.read()
.unwrap_or_else(|e| e.into_inner());
Ok(serde_json::to_string_pretty(&*items)?) Ok(serde_json::to_string_pretty(&*items)?)
} }
} }
@@ -369,7 +433,12 @@ impl MemoryHandler {
Some("Project milestones and their status") Some("Project milestones and their status")
} }
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> { async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
let items = state.project.milestones.cache.read().unwrap_or_else(|e| e.into_inner()); let items = state
.project
.milestones
.cache
.read()
.unwrap_or_else(|e| e.into_inner());
Ok(serde_json::to_string_pretty(&*items)?) Ok(serde_json::to_string_pretty(&*items)?)
} }
} }
@@ -403,6 +472,7 @@ impl MemoryHandler {
} }
register_resource!(TerminalHistoryResource); register_resource!(TerminalHistoryResource);
register_resource!(RecentActivitiesResource);
register_resource!(PinnedFilesResource); register_resource!(PinnedFilesResource);
register_resource!(MilestonesResource); register_resource!(MilestonesResource);
struct ArchiveRoutinePrompt; struct ArchiveRoutinePrompt;
@@ -679,7 +749,26 @@ impl MemoryHandler {
let category = match name { let category = match name {
"clipboard" => "CLIPBOARD", "clipboard" => "CLIPBOARD",
"create_entities" | "create_relations" | "add_observations" | "delete_entities" | "delete_observations" | "delete_relations" | "read_graph" | "get_subgraph" | "search_graph" | "get_schema" | "search_nodes" | "open_nodes" | "visualize_graph" | "condense_entity" | "merge_entities" | "find_orphans" | "sweep_graph_health" | "summarize_subgraph" | "query_graph_path" | "resolve_stale_symbols" => "GRAPH", "create_entities"
| "create_relations"
| "add_observations"
| "delete_entities"
| "delete_observations"
| "delete_relations"
| "read_graph"
| "get_subgraph"
| "search_graph"
| "get_schema"
| "search_nodes"
| "open_nodes"
| "visualize_graph"
| "condense_entity"
| "merge_entities"
| "find_orphans"
| "sweep_graph_health"
| "summarize_subgraph"
| "query_graph_path"
| "resolve_stale_symbols" => "GRAPH",
"decisions" => "DECISION", "decisions" => "DECISION",
"log_code_change" => "CODE", "log_code_change" => "CODE",
"log_error_fix" => "ERROR_FIX", "log_error_fix" => "ERROR_FIX",
@@ -701,7 +790,7 @@ impl MemoryHandler {
let result: crate::error::Result<String> = if let Some(tool) = self.tools.get(name) let result: crate::error::Result<String> = if let Some(tool) = self.tools.get(name)
{ {
tool.execute(args, self.state.clone()).await execute_tool_span(name, tool.as_ref(), args, self.state.clone()).await
} else { } else {
Err(crate::error::AppError::Internal(format!( Err(crate::error::AppError::Internal(format!(
"Unknown tool: {}", "Unknown tool: {}",
@@ -747,40 +836,90 @@ impl MemoryHandler {
pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) -> String { pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) -> String {
let (action, detail) = match name { let (action, detail) = match name {
"tasks" => { "tasks" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("manage"); let act = args
let title = args.get("title").or_else(|| args.get("id")).and_then(|v| v.as_str()).unwrap_or(""); .get("action")
("Tasks", format!("{}: {}", act, title).trim_end_matches(": ").to_string()) .and_then(|v| v.as_str())
.unwrap_or("manage");
let title = args
.get("title")
.or_else(|| args.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("");
(
"Tasks",
format!("{}: {}", act, title)
.trim_end_matches(": ")
.to_string(),
)
} }
"decisions" => { "decisions" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("log"); let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("log");
let title = args.get("title").or_else(|| args.get("query")).and_then(|v| v.as_str()).unwrap_or(""); let title = args
("Decisions", format!("{}: {}", act, title).trim_end_matches(": ").to_string()) .get("title")
.or_else(|| args.get("query"))
.and_then(|v| v.as_str())
.unwrap_or("");
(
"Decisions",
format!("{}: {}", act, title)
.trim_end_matches(": ")
.to_string(),
)
} }
"tech_debt" => { "tech_debt" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("log"); let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("log");
let desc = args.get("description").or_else(|| args.get("id")).and_then(|v| v.as_str()).unwrap_or(""); let desc = args
("Tech Debt", format!("{}: {}", act, desc).trim_end_matches(": ").to_string()) .get("description")
.or_else(|| args.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("");
(
"Tech Debt",
format!("{}: {}", act, desc)
.trim_end_matches(": ")
.to_string(),
)
} }
"sticky_notes" => { "sticky_notes" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("add"); let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("add");
let preview = args.get("content").and_then(|v| v.as_str()).map(|c| c.chars().take(40).collect::<String>()).unwrap_or_default(); let preview = args
("Sticky Notes", format!("{}: {}", act, preview).trim_end_matches(": ").to_string()) .get("content")
.and_then(|v| v.as_str())
.map(|c| c.chars().take(40).collect::<String>())
.unwrap_or_default();
(
"Sticky Notes",
format!("{}: {}", act, preview)
.trim_end_matches(": ")
.to_string(),
)
} }
"clipboard" => { "clipboard" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("read"); let act = args
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("read");
("Clipboard", act.to_string()) ("Clipboard", act.to_string())
} }
"snippets" => { "snippets" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("search"); let act = args
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("search");
let q = args.get("query").and_then(|v| v.as_str()).unwrap_or(""); let q = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
("Snippets", format!("{}: {}", act, q).trim_end_matches(": ").to_string()) (
"Snippets",
format!("{}: {}", act, q).trim_end_matches(": ").to_string(),
)
} }
"log_code_change" => { "log_code_change" => {
let file = args.get("file_path") let file = args
.get("file_path")
.or_else(|| args.get("file")) .or_else(|| args.get("file"))
.or_else(|| args.get("target_file")) .or_else(|| args.get("target_file"))
.and_then(|v| v.as_str()); .and_then(|v| v.as_str());
let summary = args.get("summary") let summary = args
.get("summary")
.or_else(|| args.get("description")) .or_else(|| args.get("description"))
.and_then(|v| v.as_str()); .and_then(|v| v.as_str());
@@ -793,7 +932,8 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
("Log Code Change", d) ("Log Code Change", d)
} }
"log_error_fix" => { "log_error_fix" => {
let d = args.get("error") let d = args
.get("error")
.or_else(|| args.get("summary")) .or_else(|| args.get("summary"))
.or_else(|| args.get("solution")) .or_else(|| args.get("solution"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -805,7 +945,11 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
let d = if let Some(entities) = args.get("entities").and_then(|v| v.as_array()) { let d = if let Some(entities) = args.get("entities").and_then(|v| v.as_array()) {
let names: Vec<&str> = entities let names: Vec<&str> = entities
.iter() .iter()
.filter_map(|e| e.get("name").or_else(|| e.get("entity_name")).and_then(|n| n.as_str())) .filter_map(|e| {
e.get("name")
.or_else(|| e.get("entity_name"))
.and_then(|n| n.as_str())
})
.take(3) .take(3)
.collect(); .collect();
if !names.is_empty() { if !names.is_empty() {
@@ -817,7 +961,11 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
} else { } else {
format!("{} entity/entities", entities.len()) format!("{} entity/entities", entities.len())
} }
} else if let Some(name) = args.get("name").or_else(|| args.get("entity_name")).and_then(|v| v.as_str()) { } else if let Some(name) = args
.get("name")
.or_else(|| args.get("entity_name"))
.and_then(|v| v.as_str())
{
name.to_string() name.to_string()
} else { } else {
String::new() String::new()
@@ -829,7 +977,11 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
if let Some(first) = relations.first() { if let Some(first) = relations.first() {
let from = first.get("from").and_then(|v| v.as_str()).unwrap_or("?"); let from = first.get("from").and_then(|v| v.as_str()).unwrap_or("?");
let to = first.get("to").and_then(|v| v.as_str()).unwrap_or("?"); let to = first.get("to").and_then(|v| v.as_str()).unwrap_or("?");
let pred = first.get("relation_type").or_else(|| first.get("predicate")).and_then(|v| v.as_str()).unwrap_or("->"); let pred = first
.get("relation_type")
.or_else(|| first.get("predicate"))
.and_then(|v| v.as_str())
.unwrap_or("->");
if relations.len() > 1 { if relations.len() > 1 {
format!("{} {} {} (+{} more)", from, pred, to, relations.len() - 1) format!("{} {} {} (+{} more)", from, pred, to, relations.len() - 1)
} else { } else {
@@ -844,7 +996,8 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
("Create Relations", d) ("Create Relations", d)
} }
"omni_search" | "search_graph" | "search_web" => { "omni_search" | "search_graph" | "search_web" => {
let d = args.get("query") let d = args
.get("query")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|q| format!("\"{}\"", q)) .map(|q| format!("\"{}\"", q))
.unwrap_or_default(); .unwrap_or_default();
@@ -881,6 +1034,16 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
} }
} }
#[tracing::instrument(name = "mcp_tool_execution", skip(tool, args, state), fields(tool_name = %name))]
async fn execute_tool_span(
name: &str,
tool: &dyn McpTool,
args: Value,
state: Arc<MemoryState>,
) -> crate::error::Result<String> {
tool.execute(args, state).await
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -889,10 +1052,16 @@ mod tests {
#[test] #[test]
fn test_format_tool_activity_description() { fn test_format_tool_activity_description() {
let msg1 = format_tool_activity_description("log_code_change", &json!({ "file": "src/lib.rs", "summary": "Refactored library" })); let msg1 = format_tool_activity_description(
"log_code_change",
&json!({ "file": "src/lib.rs", "summary": "Refactored library" }),
);
assert_eq!(msg1, "Log Code Change: src/lib.rs: Refactored library"); assert_eq!(msg1, "Log Code Change: src/lib.rs: Refactored library");
let msg2 = format_tool_activity_description("create_entities", &json!({ "entities": [{ "name": "EntityA" }, { "name": "EntityB" }] })); let msg2 = format_tool_activity_description(
"create_entities",
&json!({ "entities": [{ "name": "EntityA" }, { "name": "EntityB" }] }),
);
assert_eq!(msg2, "Create Entities: EntityA, EntityB"); assert_eq!(msg2, "Create Entities: EntityA, EntityB");
let msg3 = format_tool_activity_description("read_graph", &json!({})); let msg3 = format_tool_activity_description("read_graph", &json!({}));
@@ -948,6 +1117,11 @@ mod tests {
.iter() .iter()
.any(|r| r["uri"] == "memory://pinned_files") .any(|r| r["uri"] == "memory://pinned_files")
); );
assert!(
resources_arr
.iter()
.any(|r| r["uri"] == "memory://activity/recent")
);
// Test resources/read // Test resources/read
let req_read_res = json!({ let req_read_res = json!({
@@ -1114,17 +1288,27 @@ mod tests {
assert_eq!(res["jsonrpc"], "2.0"); assert_eq!(res["jsonrpc"], "2.0");
assert_eq!(res["id"], 100); assert_eq!(res["id"], 100);
let tools = res["result"]["tools"].as_array().expect("tools array expected"); let tools = res["result"]["tools"]
assert!(tools.len() >= 40, "Expected at least 40 registered MCP tools, found {}", tools.len()); .as_array()
.expect("tools array expected");
assert!(
tools.len() >= 40,
"Expected at least 40 registered MCP tools, found {}",
tools.len()
);
for tool in tools { for tool in tools {
let name = tool["name"].as_str().expect("tool name must be string"); let name = tool["name"].as_str().expect("tool name must be string");
assert!(!name.is_empty(), "Tool name cannot be empty"); assert!(!name.is_empty(), "Tool name cannot be empty");
let desc = tool["description"].as_str().expect("tool description must be string"); let desc = tool["description"]
.as_str()
.expect("tool description must be string");
assert!(!desc.is_empty(), "Tool '{}' missing description", name); assert!(!desc.is_empty(), "Tool '{}' missing description", name);
let schema = tool["inputSchema"].as_object().expect("inputSchema must be object"); let schema = tool["inputSchema"]
.as_object()
.expect("inputSchema must be object");
assert!( assert!(
schema.contains_key("properties") || schema.contains_key("type"), schema.contains_key("properties") || schema.contains_key("type"),
"Tool '{}' schema must contain type/properties metadata", "Tool '{}' schema must contain type/properties metadata",
@@ -1154,7 +1338,10 @@ mod tests {
} }
}); });
let res = handler.handle_request(invalid_req).await.expect("handler must respond"); let res = handler
.handle_request(invalid_req)
.await
.expect("handler must respond");
assert_eq!(res["jsonrpc"], "2.0"); assert_eq!(res["jsonrpc"], "2.0");
assert!(res["result"]["content"].is_array()); assert!(res["result"]["content"].is_array());
} }
@@ -1180,7 +1367,10 @@ mod tests {
("sweep_graph_health", json!({})), ("sweep_graph_health", json!({})),
("get_next_actionable_tasks", json!({})), ("get_next_actionable_tasks", json!({})),
("get_preflight_context", json!({})), ("get_preflight_context", json!({})),
("auto_session_checkpoint", json!({"session_summary": "Test summary"})), (
"auto_session_checkpoint",
json!({"session_summary": "Test summary"}),
),
]; ];
for (name, args) in sample_valid_calls { for (name, args) in sample_valid_calls {
@@ -1194,13 +1384,16 @@ mod tests {
} }
}); });
let res = handler.handle_request(valid_req).await.expect("handler must respond"); let res = handler
.handle_request(valid_req)
.await
.expect("handler must respond");
assert_eq!(res["jsonrpc"], "2.0"); assert_eq!(res["jsonrpc"], "2.0");
assert_eq!( assert_eq!(
res["result"]["isError"], false, res["result"]["isError"], false,
"Tool '{}' failed valid execution. Result: {:?}", name, res "Tool '{}' failed valid execution. Result: {:?}",
name, res
); );
} }
} }
} }
+77 -25
View File
@@ -27,6 +27,8 @@ pub struct MemoryIndex {
pub body_field: Field, pub body_field: Field,
pub type_field: Field, pub type_field: Field,
pub namespace_field: Field, pub namespace_field: Field,
pub query_parser: tantivy::query::QueryParser,
} }
impl MemoryIndex { impl MemoryIndex {
@@ -45,14 +47,14 @@ impl MemoryIndex {
let index = Index::open_in_dir(&index_dir) let index = Index::open_in_dir(&index_dir)
.or_else(|_| Index::create_in_dir(&index_dir, schema.clone()))?; .or_else(|_| Index::create_in_dir(&index_dir, schema.clone()))?;
let mut writer = index.writer(50_000_000)?; let writer = index.writer(50_000_000)?;
writer.delete_all_documents()?;
writer.commit()?;
let reader = index let reader = index
.reader_builder() .reader_builder()
.reload_policy(ReloadPolicy::OnCommitWithDelay) .reload_policy(ReloadPolicy::OnCommitWithDelay)
.try_into()?; .try_into()?;
let query_parser = tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
Ok(Self { Ok(Self {
index, index,
reader, reader,
@@ -63,6 +65,7 @@ impl MemoryIndex {
body_field, body_field,
type_field, type_field,
namespace_field, namespace_field,
query_parser,
}) })
} }
@@ -76,14 +79,14 @@ impl MemoryIndex {
let schema = schema_builder.build(); let schema = schema_builder.build();
let index = Index::create_in_ram(schema); let index = Index::create_in_ram(schema);
let mut writer = index.writer(50_000_000)?; let writer = index.writer(50_000_000)?;
writer.delete_all_documents()?;
writer.commit()?;
let reader = index let reader = index
.reader_builder() .reader_builder()
.reload_policy(ReloadPolicy::OnCommitWithDelay) .reload_policy(ReloadPolicy::OnCommitWithDelay)
.try_into()?; .try_into()?;
let query_parser = tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
Ok(Self { Ok(Self {
index, index,
reader, reader,
@@ -94,9 +97,20 @@ impl MemoryIndex {
body_field, body_field,
type_field, type_field,
namespace_field, namespace_field,
query_parser,
}) })
} }
pub fn clear(&self) -> tantivy::Result<()> {
let mut writer = self
.writer
.lock()
.map_err(|e| tantivy::TantivyError::SystemError(e.to_string()))?;
writer.delete_all_documents()?;
writer.commit()?;
Ok(())
}
pub fn index_entity(&self, e: &Entity) -> tokio::task::JoinHandle<tantivy::Result<()>> { pub fn index_entity(&self, e: &Entity) -> tokio::task::JoinHandle<tantivy::Result<()>> {
self.index_entities_batch(std::slice::from_ref(e)) self.index_entities_batch(std::slice::from_ref(e))
} }
@@ -138,10 +152,18 @@ impl MemoryIndex {
let id_val = t.id.clone(); let id_val = t.id.clone();
let needs_commit = Arc::clone(&self.needs_commit); let needs_commit = Arc::clone(&self.needs_commit);
let criteria_body = t.acceptance_criteria.iter().fold(String::new(), |mut acc, c| {
if !acc.is_empty() {
acc.push('\n');
}
acc.push_str(&c.description);
acc
});
let doc = doc!( let doc = doc!(
self.id_field => t.id.as_str(), self.id_field => t.id.as_str(),
self.title_field => t.title.as_str(), self.title_field => t.title.as_str(),
self.body_field => format!("{}\n{}", t.description, t.acceptance_criteria.iter().map(|c| c.description.as_str()).collect::<Vec<_>>().join("\n")), self.body_field => format!("{}\n{}", t.description, criteria_body),
self.type_field => "task", self.type_field => "task",
self.namespace_field => "global" self.namespace_field => "global"
); );
@@ -172,6 +194,7 @@ impl MemoryIndex {
pub async fn commit(&self) -> tantivy::Result<()> { pub async fn commit(&self) -> tantivy::Result<()> {
let writer = Arc::clone(&self.writer); let writer = Arc::clone(&self.writer);
let needs_commit = Arc::clone(&self.needs_commit); let needs_commit = Arc::clone(&self.needs_commit);
let reader = self.reader.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if needs_commit.swap(false, std::sync::atomic::Ordering::SeqCst) { if needs_commit.swap(false, std::sync::atomic::Ordering::SeqCst) {
let mut writer = writer.lock().unwrap_or_else(|e| e.into_inner()); let mut writer = writer.lock().unwrap_or_else(|e| e.into_inner());
@@ -181,6 +204,7 @@ impl MemoryIndex {
tantivy::TantivyError::SystemError(format!("First commit error: {}; Second commit error: {}", first_err, second_err)) tantivy::TantivyError::SystemError(format!("First commit error: {}; Second commit error: {}", first_err, second_err))
})?; })?;
} }
let _ = reader.reload();
} }
Ok(()) Ok(())
}) })
@@ -208,11 +232,9 @@ impl MemoryIndex {
namespace: Option<&str>, namespace: Option<&str>,
) -> tantivy::Result<Vec<SearchResultTuple>> { ) -> tantivy::Result<Vec<SearchResultTuple>> {
let searcher = self.reader.searcher(); let searcher = self.reader.searcher();
let query_parser = tantivy::query::QueryParser::for_index( let mut is_wildcard_fallback = false;
&self.index,
vec![self.title_field, self.body_field], let q = self.query_parser.parse_query(query).or_else(|_| {
);
let q = query_parser.parse_query(query).or_else(|_| {
let sanitized: String = query let sanitized: String = query
.chars() .chars()
.map(|c| match c { .map(|c| match c {
@@ -223,17 +245,26 @@ impl MemoryIndex {
.collect(); .collect();
let safe_query = sanitized.trim(); let safe_query = sanitized.trim();
if safe_query.is_empty() { if safe_query.is_empty() {
query_parser.parse_query("*") is_wildcard_fallback = true;
self.query_parser.parse_query("*")
} else { } else {
query_parser.parse_query(safe_query) self.query_parser.parse_query(safe_query)
} }
})?; })?;
let target_limit = if is_wildcard_fallback { 10 } else { 50 };
let fetch_limit = if namespace.is_some() {
// Fetch deeper candidate pool so other namespaces do not starve the target namespace
1000
} else {
target_limit
};
let top_docs = searcher.search( let top_docs = searcher.search(
&q, &q,
&tantivy::collector::TopDocs::with_limit(50).order_by_score(), &tantivy::collector::TopDocs::with_limit(fetch_limit).order_by_score(),
)?; )?;
let mut results = Vec::with_capacity(top_docs.len());
let mut results = Vec::with_capacity(target_limit);
for (score, doc_address) in top_docs { for (score, doc_address) in top_docs {
let retrieved_doc = searcher.doc::<tantivy::TantivyDocument>(doc_address)?; let retrieved_doc = searcher.doc::<tantivy::TantivyDocument>(doc_address)?;
let id = retrieved_doc let id = retrieved_doc
@@ -267,6 +298,9 @@ impl MemoryIndex {
continue; continue;
} }
results.push((id, doc_type, title, body, score)); results.push((id, doc_type, title, body, score));
if results.len() >= target_limit {
break;
}
} }
Ok(results) Ok(results)
} }
@@ -319,59 +353,73 @@ impl MemoryIndex {
pub fn add_entity_sync(&self, e: &Entity) { pub fn add_entity_sync(&self, e: &Entity) {
let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner()); let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
let _ = writer.add_document(doc!( writer.delete_term(tantivy::Term::from_field_text(self.id_field, &e.name));
if let Err(err) = writer.add_document(doc!(
self.id_field => e.name.as_str(), self.id_field => e.name.as_str(),
self.title_field => e.name.as_str(), self.title_field => e.name.as_str(),
self.body_field => e.observations.join(" "), self.body_field => e.observations.join(" "),
self.type_field => "entity", self.type_field => "entity",
self.namespace_field => e.namespace.as_str() self.namespace_field => e.namespace.as_str()
)); )) {
tracing::warn!("Sync entity indexing failed for '{}': {}", e.name, err);
}
self.needs_commit self.needs_commit
.store(true, std::sync::atomic::Ordering::SeqCst); .store(true, std::sync::atomic::Ordering::SeqCst);
} }
pub fn delete_all(&self) { pub fn delete_all(&self) {
let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner()); let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
let _ = writer.delete_all_documents(); if let Err(err) = writer.delete_all_documents() {
tracing::warn!("delete_all_documents failed: {}", err);
}
self.needs_commit self.needs_commit
.store(true, std::sync::atomic::Ordering::SeqCst); .store(true, std::sync::atomic::Ordering::SeqCst);
} }
pub fn add_task_sync(&self, t: &Task) { pub fn add_task_sync(&self, t: &Task) {
let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner()); let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
let _res = writer.add_document(doc!( writer.delete_term(tantivy::Term::from_field_text(self.id_field, &t.id));
if let Err(err) = writer.add_document(doc!(
self.id_field => t.id.as_str(), self.id_field => t.id.as_str(),
self.title_field => t.title.as_str(), self.title_field => t.title.as_str(),
self.body_field => t.description.as_str(), self.body_field => t.description.as_str(),
self.type_field => "task", self.type_field => "task",
self.namespace_field => "global" self.namespace_field => "global"
)); )) {
tracing::warn!("Sync task indexing failed for '{}': {}", t.id, err);
}
self.needs_commit self.needs_commit
.store(true, std::sync::atomic::Ordering::SeqCst); .store(true, std::sync::atomic::Ordering::SeqCst);
} }
pub fn add_snippet_sync(&self, s: &Snippet) { pub fn add_snippet_sync(&self, s: &Snippet) {
let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner()); let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
let _ = writer.add_document(doc!( writer.delete_term(tantivy::Term::from_field_text(self.id_field, &s.name));
if let Err(err) = writer.add_document(doc!(
self.id_field => s.name.as_str(), self.id_field => s.name.as_str(),
self.title_field => s.name.as_str(), self.title_field => s.name.as_str(),
self.body_field => format!("{} {}", s.language, s.description), self.body_field => format!("{} {}", s.language, s.description),
self.type_field => "snippet", self.type_field => "snippet",
self.namespace_field => "global" self.namespace_field => "global"
)); )) {
tracing::warn!("Sync snippet indexing failed for '{}': {}", s.name, err);
}
self.needs_commit self.needs_commit
.store(true, std::sync::atomic::Ordering::SeqCst); .store(true, std::sync::atomic::Ordering::SeqCst);
} }
pub fn add_adr_sync(&self, a: &Adr) { pub fn add_adr_sync(&self, a: &Adr) {
let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner()); let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
let _ = writer.add_document(doc!( writer.delete_term(tantivy::Term::from_field_text(self.id_field, &a.id));
if let Err(err) = writer.add_document(doc!(
self.id_field => a.id.as_str(), self.id_field => a.id.as_str(),
self.title_field => a.title.as_str(), self.title_field => a.title.as_str(),
self.body_field => format!("{} {} {}", a.context, a.decision, a.consequence), self.body_field => format!("{} {} {}", a.context, a.decision, a.consequence),
self.type_field => "adr", self.type_field => "adr",
self.namespace_field => "global" self.namespace_field => "global"
)); )) {
tracing::warn!("Sync ADR indexing failed for '{}': {}", a.id, err);
}
self.needs_commit self.needs_commit
.store(true, std::sync::atomic::Ordering::SeqCst); .store(true, std::sync::atomic::Ordering::SeqCst);
} }
@@ -393,6 +441,7 @@ mod tests {
observations: vec!["This is a test observation".to_string()], observations: vec!["This is a test observation".to_string()],
namespace: "global".to_string(), namespace: "global".to_string(),
git_branch: None, git_branch: None,
..Default::default()
}; };
let _ = index.index_entity(&entity).await.unwrap(); let _ = index.index_entity(&entity).await.unwrap();
@@ -408,6 +457,7 @@ mod tests {
dependencies: vec![], dependencies: vec![],
parent_id: None, parent_id: None,
expires_at: None, expires_at: None,
..Default::default()
}; };
let _ = index.index_task(&task).await.unwrap(); let _ = index.index_task(&task).await.unwrap();
@@ -419,6 +469,7 @@ mod tests {
updated_at: 0, updated_at: 0,
tags: vec![], tags: vec![],
embedding: None, embedding: None,
..Default::default()
}; };
let _ = index.index_snippet(&snippet).await.unwrap(); let _ = index.index_snippet(&snippet).await.unwrap();
@@ -431,6 +482,7 @@ mod tests {
status: "accepted".to_string(), status: "accepted".to_string(),
supersedes: None, supersedes: None,
timestamp: 0, timestamp: 0,
..Default::default()
}; };
let _ = index.index_adr(&adr).await.unwrap(); let _ = index.index_adr(&adr).await.unwrap();
+124 -60
View File
@@ -1,7 +1,6 @@
use crate::models::*; use crate::models::*;
use crate::search::MemoryIndex; use crate::search::MemoryIndex;
use crate::store::Store; use crate::store::Store;
use crate::vector_db::VectorDB;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
@@ -50,14 +49,12 @@ pub struct TelemetryStores {
pub struct MemoryState { pub struct MemoryState {
pub base_dir: PathBuf, pub base_dir: PathBuf,
pub clipboard_watch_mode: tokio::sync::RwLock<bool>,
pub clipboard_notify: Arc<tokio::sync::Notify>,
pub index_commit_notify: Arc<tokio::sync::Notify>, pub index_commit_notify: Arc<tokio::sync::Notify>,
pub ttl_notify: Arc<tokio::sync::Notify>, pub ttl_notify: Arc<tokio::sync::Notify>,
pub condense_notify: Arc<tokio::sync::Notify>, pub condense_notify: Arc<tokio::sync::Notify>,
pub shutdown_notify: Arc<tokio::sync::Notify>,
pub graph: Store<KnowledgeGraph>, pub graph: Store<KnowledgeGraph>,
pub search_index: tokio::sync::RwLock<MemoryIndex>, pub search_index: tokio::sync::RwLock<MemoryIndex>,
pub vector_db: tokio::sync::RwLock<Option<VectorDB>>,
pub project: ProjectStores, pub project: ProjectStores,
pub code: CodeStores, pub code: CodeStores,
@@ -96,22 +93,21 @@ impl MemoryState {
.join(".gemini/mcp_memory/daemon_error.log"); .join(".gemini/mcp_memory/daemon_error.log");
let _ = let _ =
std::fs::write(&log_path, format!("Failed to create MemoryIndex: {}\n", e)); std::fs::write(&log_path, format!("Failed to create MemoryIndex: {}\n", e));
crate::search::MemoryIndex::new_in_ram().expect("Failed to create RAM MemoryIndex") crate::search::MemoryIndex::new_in_ram()
.expect("Failed to create RAM MemoryIndex")
} }
} }
}; };
let state = Self { let state = Self {
ollama: Arc::new(crate::ollama::OllamaClient::new_from_env()), ollama: Arc::new(crate::ollama::OllamaClient::new_from_env()),
clipboard_watch_mode: tokio::sync::RwLock::new(false),
clipboard_notify: Arc::new(tokio::sync::Notify::new()),
index_commit_notify: Arc::new(tokio::sync::Notify::new()), index_commit_notify: Arc::new(tokio::sync::Notify::new()),
ttl_notify: Arc::new(tokio::sync::Notify::new()), ttl_notify: Arc::new(tokio::sync::Notify::new()),
condense_notify: Arc::new(tokio::sync::Notify::new()), condense_notify: Arc::new(tokio::sync::Notify::new()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
graph: Store::new("knowledge_graph_master", db.clone()), graph: Store::new("knowledge_graph_master", db.clone()),
base_dir: base.clone(), base_dir: base.clone(),
search_index: tokio::sync::RwLock::new(search_index), search_index: tokio::sync::RwLock::new(search_index),
vector_db: tokio::sync::RwLock::new(None),
project: ProjectStores { project: ProjectStores {
tasks: Store::new("tasks", db.clone()), tasks: Store::new("tasks", db.clone()),
@@ -155,7 +151,8 @@ impl MemoryState {
entity.entity_type = crate::models::normalize_entity_type(&entity.entity_type); entity.entity_type = crate::models::normalize_entity_type(&entity.entity_type);
} }
for relation in g.relations.iter_mut() { for relation in g.relations.iter_mut() {
relation.relation_type = crate::models::normalize_relation_type(&relation.relation_type); relation.relation_type =
crate::models::normalize_relation_type(&relation.relation_type);
} }
}); });
@@ -187,50 +184,64 @@ impl MemoryState {
pub async fn rebuild_index(self: &Arc<Self>) { pub async fn rebuild_index(self: &Arc<Self>) {
let is_in_memory = self.base_dir.to_str() == Some(":memory:"); let is_in_memory = self.base_dir.to_str() == Some(":memory:");
let base_dir = self.base_dir.clone();
let state_clone = Arc::clone(self);
// Offload full clone and synchronous Tantivy doc indexing off the async Tokio reactor
let new_idx = match tokio::task::spawn_blocking(move || {
let new_idx = if is_in_memory { let new_idx = if is_in_memory {
crate::search::MemoryIndex::new_in_ram().expect("Failed to create RAM MemoryIndex for rebuild") crate::search::MemoryIndex::new_in_ram()
.expect("Failed to create RAM MemoryIndex for rebuild")
} else { } else {
match crate::search::MemoryIndex::new(&self.base_dir) { match crate::search::MemoryIndex::new(&base_dir) {
Ok(idx) => idx, Ok(idx) => idx,
Err(e) => { Err(e) => {
tracing::warn!("Failed to create disk MemoryIndex for rebuild ({}), falling back to RAM", e); tracing::warn!(
crate::search::MemoryIndex::new_in_ram().expect("Failed to create RAM MemoryIndex for rebuild") "Failed to create disk MemoryIndex for rebuild ({}), falling back to RAM",
e
);
crate::search::MemoryIndex::new_in_ram()
.expect("Failed to create RAM MemoryIndex for rebuild")
} }
} }
}; };
let _ = new_idx.clear();
let entities: Vec<_> = self let entities: Vec<_> = state_clone
.graph .graph
.read_with(|g| g.entities.values().cloned().collect()); .read_with(|g| g.entities.values().cloned().collect());
let tasks = self.project.tasks.read_with(|t| t.clone()); let tasks = state_clone.project.tasks.read_with(|t| t.clone());
let snippets = self.code.snippets.read_with(|s| s.clone()); let snippets = state_clone.code.snippets.read_with(|s| s.clone());
let adrs = self.code.adrs.read_with(|a| a.clone()); let adrs = state_clone.code.adrs.read_with(|a| a.clone());
tracing::info!( tracing::info!(
"rebuild_index: found {} entities, {} tasks", "rebuild_index: indexing {} entities, {} tasks synchronously in blocking thread",
entities.len(), entities.len(),
tasks.len() tasks.len()
); );
let idx_clone = new_idx.clone();
tokio::task::spawn_blocking(move || {
for e in entities { for e in entities {
idx_clone.add_entity_sync(&e); new_idx.add_entity_sync(&e);
} }
for task in tasks { for task in tasks {
idx_clone.add_task_sync(&task); new_idx.add_task_sync(&task);
} }
for snippet in snippets { for snippet in snippets {
idx_clone.add_snippet_sync(&snippet); new_idx.add_snippet_sync(&snippet);
} }
for adr in adrs { for adr in adrs {
idx_clone.add_adr_sync(&adr); new_idx.add_adr_sync(&adr);
} }
new_idx
}) })
.await .await
.unwrap_or_else(|e| { {
Ok(idx) => idx,
Err(e) => {
tracing::error!("Failed to join tantivy index rebuild thread: {}", e); tracing::error!("Failed to join tantivy index rebuild thread: {}", e);
}); return;
}
};
let _ = new_idx.commit().await; let _ = new_idx.commit().await;
*self.search_index.write().await = new_idx; *self.search_index.write().await = new_idx;
@@ -244,22 +255,30 @@ impl MemoryState {
.as_millis() as u64; .as_millis() as u64;
let category_upper = category.to_uppercase(); let category_upper = category.to_uppercase();
let truncated_details = details.map(|s| {
if s.len() > 4096 {
format!("{}... [truncated]", &s[..4096])
} else {
s.to_string()
}
});
let activity = ActivityRecord { let activity = ActivityRecord {
timestamp: ts, timestamp: ts,
category: category_upper, category: category_upper,
summary: summary.to_string(), summary: summary.to_string(),
details: details.map(|s| s.to_string()), details: truncated_details,
..Default::default()
}; };
let record_val = serde_json::to_value(&activity).unwrap_or_default(); if let Ok(record_val) = serde_json::to_value(&activity) {
self.telemetry.recent_activities.modify(|activities| { self.telemetry.recent_activities.modify(|activities| {
activities.push_front(record_val.clone()); activities.push_front(record_val);
if activities.len() > 100 { if activities.len() > 100 {
activities.pop_back(); activities.pop_back();
} }
}); });
}
if self.activity_tx.receiver_count() > 0 { if self.activity_tx.receiver_count() > 0 {
let payload = serde_json::json!({ let payload = serde_json::json!({
@@ -277,17 +296,27 @@ impl MemoryState {
let payload_val = serde_json::to_value(&event).unwrap_or_default(); let payload_val = serde_json::to_value(&event).unwrap_or_default();
let summary_str = format!("Task {} -> {}", event.task_id, event.status); let summary_str = format!("Task {} -> {}", event.task_id, event.status);
let details_str = payload_val.to_string();
let truncated_details = if details_str.len() > 4096 {
format!("{}... [truncated]", &details_str[..4096])
} else {
details_str
};
self.telemetry.recent_activities.modify(|activities| { self.telemetry.recent_activities.modify(|activities| {
let activity = ActivityRecord { let activity = ActivityRecord {
timestamp: event.timestamp, timestamp: event.timestamp,
category: "TASK_EVENT".to_string(), category: "TASK_EVENT".to_string(),
summary: summary_str, summary: summary_str,
details: Some(payload_val.to_string()), details: Some(truncated_details),
..Default::default()
}; };
activities.push_front(serde_json::to_value(&activity).unwrap_or_default()); if let Ok(act_val) = serde_json::to_value(&activity) {
activities.push_front(act_val);
if activities.len() > 100 { if activities.len() > 100 {
activities.pop_back(); activities.pop_back();
} }
}
}); });
let generic_ev = GenericEvent { let generic_ev = GenericEvent {
@@ -316,7 +345,10 @@ impl MemoryState {
let _ = self.activity_tx.send(ws_resource_notification); let _ = self.activity_tx.send(ws_resource_notification);
} }
pub fn record_terminal_history(&self, payload: TerminalHistory) { pub fn record_terminal_history(&self, mut payload: TerminalHistory) {
if payload.command.len() > 2048 {
payload.command = format!("{}... [truncated]", &payload.command[..2048]);
}
self.telemetry.terminal_history.modify(|history| { self.telemetry.terminal_history.modify(|history| {
history.push_front(payload); history.push_front(payload);
if history.len() > 100 { if history.len() > 100 {
@@ -353,6 +385,7 @@ mod tests {
git_branch: None, git_branch: None,
parent_id: None, parent_id: None,
expires_at: None, expires_at: None,
..Default::default()
}); });
}); });
@@ -395,7 +428,11 @@ mod tests {
let mut rx = state.activity_tx.subscribe(); let mut rx = state.activity_tx.subscribe();
// 1. Record an activity with details // 1. Record an activity with details
state.record_activity("code_change", "Refactored state.rs", Some("Updated ActivityRecord schema")); state.record_activity(
"code_change",
"Refactored state.rs",
Some("Updated ActivityRecord schema"),
);
// Verify recent_activities store // Verify recent_activities store
let activities: Vec<ActivityRecord> = state.telemetry.recent_activities.read_with(|act| { let activities: Vec<ActivityRecord> = state.telemetry.recent_activities.read_with(|act| {
@@ -407,12 +444,19 @@ mod tests {
assert_eq!(activities.len(), 1); assert_eq!(activities.len(), 1);
assert_eq!(activities[0].category, "CODE_CHANGE"); assert_eq!(activities[0].category, "CODE_CHANGE");
assert_eq!(activities[0].summary, "Refactored state.rs"); assert_eq!(activities[0].summary, "Refactored state.rs");
assert_eq!(activities[0].details, Some("Updated ActivityRecord schema".to_string())); assert_eq!(
assert!(activities[0].timestamp > 1_700_000_000_000, "Timestamp must be in epoch milliseconds"); activities[0].details,
Some("Updated ActivityRecord schema".to_string())
);
assert!(
activities[0].timestamp > 1_700_000_000_000,
"Timestamp must be in epoch milliseconds"
);
// Verify broadcast channel message // Verify broadcast channel message
let broadcast_msg = rx.recv().await.expect("Expected broadcast notification"); let broadcast_msg = rx.recv().await.expect("Expected broadcast notification");
let broadcast_val: serde_json::Value = serde_json::from_str(&broadcast_msg).expect("Valid JSON"); let broadcast_val: serde_json::Value =
serde_json::from_str(&broadcast_msg).expect("Valid JSON");
assert_eq!(broadcast_val["jsonrpc"], "2.0"); assert_eq!(broadcast_val["jsonrpc"], "2.0");
assert_eq!(broadcast_val["method"], "notifications/activity"); assert_eq!(broadcast_val["method"], "notifications/activity");
assert_eq!(broadcast_val["params"]["category"], "CODE_CHANGE"); assert_eq!(broadcast_val["params"]["category"], "CODE_CHANGE");
@@ -420,7 +464,8 @@ mod tests {
// 2. Broadcast an activity without details // 2. Broadcast an activity without details
state.broadcast_activity("task", "Completed live activity fix"); state.broadcast_activity("task", "Completed live activity fix");
let activities_updated: Vec<ActivityRecord> = state.telemetry.recent_activities.read_with(|act| { let activities_updated: Vec<ActivityRecord> =
state.telemetry.recent_activities.read_with(|act| {
act.iter() act.iter()
.filter_map(|v| serde_json::from_value(v.clone()).ok()) .filter_map(|v| serde_json::from_value(v.clone()).ok())
.collect() .collect()
@@ -456,31 +501,13 @@ impl SearchService {
pub async fn semantic_search( pub async fn semantic_search(
&self, &self,
query: &str, query: &str,
_filter_namespace: Option<&str>, filter_namespace: Option<&str>,
limit: usize, limit: usize,
) -> crate::error::Result<Vec<UnifiedSearchResult>> { ) -> crate::error::Result<Vec<UnifiedSearchResult>> {
let query_emb = generate_embedding_async(query.to_string()) let query_emb = generate_embedding_async(query.to_string())
.await .await
.unwrap_or_default(); .unwrap_or_default();
let mut results = Vec::new(); let mut results = Vec::new();
let mut vdb_search = false;
if let Some(vdb) = &*self.state.vector_db.read().await {
vdb_search = true;
if let Ok(search_results) = vdb.search(query_emb.clone(), limit as u64).await {
for res in search_results {
results.push(UnifiedSearchResult {
id: res.id.clone(),
doc_type: res.doc_type.clone(),
title: res.id,
body: res.text,
score: res.score,
});
}
}
}
if !vdb_search {
let mut cached_items = Vec::new(); let mut cached_items = Vec::new();
let mut uncached_texts = Vec::new(); let mut uncached_texts = Vec::new();
let mut uncached_meta = Vec::new(); let mut uncached_meta = Vec::new();
@@ -492,7 +519,10 @@ impl SearchService {
if let Some(ref emb) = snippet.embedding { if let Some(ref emb) = snippet.embedding {
cached_items.push((title, "snippet".to_string(), desc, emb.clone())); cached_items.push((title, "snippet".to_string(), desc, emb.clone()));
} else { } else {
uncached_texts.push(format!("{} {} {}", snippet.name, snippet.description, snippet.code)); uncached_texts.push(format!(
"{} {} {}",
snippet.name, snippet.description, snippet.code
));
uncached_meta.push((title, "snippet".to_string(), desc)); uncached_meta.push((title, "snippet".to_string(), desc));
} }
} }
@@ -502,7 +532,42 @@ impl SearchService {
for note in sticky.iter().take(50) { for note in sticky.iter().take(50) {
let content_preview = note.content.chars().take(200).collect::<String>(); let content_preview = note.content.chars().take(200).collect::<String>();
uncached_texts.push(note.content.clone()); uncached_texts.push(note.content.clone());
uncached_meta.push(("StickyNote".to_string(), "sticky".to_string(), content_preview)); uncached_meta.push((
"StickyNote".to_string(),
"sticky".to_string(),
content_preview,
));
}
});
self.state.read_graph(|graph| {
for entity in graph.entities.values().take(50) {
if let Some(ns) = filter_namespace {
if entity.namespace != ns {
continue;
}
}
let title = entity.name.clone();
let obs = entity.observations.join("; ");
let desc = format!("{}: {}", entity.entity_type, obs);
if let Some(ref emb) = entity.embedding {
cached_items.push((title, "entity".to_string(), desc, emb.clone()));
} else {
uncached_texts.push(format!("{} {} {}", entity.name, entity.entity_type, obs));
uncached_meta.push((title, "entity".to_string(), desc));
}
}
});
self.state.code.error_fixes.read_with(|fixes| {
for fix in fixes.iter().take(50) {
let title = fix.signature.clone();
let desc = fix.solution.clone();
if let Some(ref emb) = fix.embedding {
cached_items.push((title, "error_fix".to_string(), desc, emb.clone()));
} else {
uncached_texts.push(format!("{} {}", fix.signature, fix.solution));
uncached_meta.push((title, "error_fix".to_string(), desc));
}
} }
}); });
@@ -538,7 +603,6 @@ impl SearchService {
.unwrap_or(std::cmp::Ordering::Equal) .unwrap_or(std::cmp::Ordering::Equal)
}); });
results.truncate(limit); results.truncate(limit);
}
Ok(results) Ok(results)
} }
+337 -35
View File
@@ -4,10 +4,16 @@ use std::sync::{Arc, RwLock};
pub const STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("store"); pub const STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("store");
/// Internal write request dispatched to the single database writer actor.
enum DbOp {
Insert(Vec<u8>),
Delete,
}
/// Internal write request dispatched to the single database writer actor. /// Internal write request dispatched to the single database writer actor.
struct DbWriteTask { struct DbWriteTask {
key: String, key: String,
data: Vec<u8>, op: DbOp,
flushed_notifier: Arc<tokio::sync::Notify>, flushed_notifier: Arc<tokio::sync::Notify>,
oneshot_tx: Option<tokio::sync::oneshot::Sender<()>>, oneshot_tx: Option<tokio::sync::oneshot::Sender<()>>,
} }
@@ -18,7 +24,8 @@ pub struct DbWriteQueue {
tx: tokio::sync::mpsc::Sender<DbWriteTask>, tx: tokio::sync::mpsc::Sender<DbWriteTask>,
} }
static QUEUE_REGISTRY: std::sync::Mutex<Option<(Arc<Database>, DbWriteQueue)>> = std::sync::Mutex::new(None); static QUEUE_REGISTRY: std::sync::Mutex<Option<(Arc<Database>, DbWriteQueue)>> =
std::sync::Mutex::new(None);
fn get_or_create_queue(db: Arc<Database>) -> DbWriteQueue { fn get_or_create_queue(db: Arc<Database>) -> DbWriteQueue {
let mut reg = QUEUE_REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); let mut reg = QUEUE_REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
@@ -38,7 +45,8 @@ impl DbWriteQueue {
tokio::spawn(async move { tokio::spawn(async move {
while let Some(first_task) = rx.recv().await { while let Some(first_task) = rx.recv().await {
let mut batch = vec![first_task]; let mut batch = Vec::with_capacity(100);
batch.push(first_task);
// Gold Standard Micro-batching: Drain up to 100 accumulated tasks from queue without blocking // Gold Standard Micro-batching: Drain up to 100 accumulated tasks from queue without blocking
while batch.len() < 100 { while batch.len() < 100 {
@@ -54,8 +62,27 @@ impl DbWriteQueue {
Ok(write_txn) => { Ok(write_txn) => {
if let Ok(mut table) = write_txn.open_table(STORE_TABLE) { if let Ok(mut table) = write_txn.open_table(STORE_TABLE) {
for task in &batch { for task in &batch {
if let Err(e) = table.insert(task.key.as_str(), task.data.as_slice()) { match &task.op {
tracing::error!("Failed to insert key '{}' into redb: {}", task.key, e); DbOp::Insert(data) => {
if let Err(e) =
table.insert(task.key.as_str(), data.as_slice())
{
tracing::error!(
"Failed to insert key '{}' into redb: {}",
task.key,
e
);
}
}
DbOp::Delete => {
if let Err(e) = table.remove(task.key.as_str()) {
tracing::error!(
"Failed to delete key '{}' from redb: {}",
task.key,
e
);
}
}
} }
} }
} }
@@ -64,7 +91,10 @@ impl DbWriteQueue {
} }
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to begin write transaction on redb writer actor: {}", e); tracing::error!(
"Failed to begin write transaction on redb writer actor: {}",
e
);
} }
} }
@@ -88,18 +118,55 @@ impl DbWriteQueue {
key: String, key: String,
data: Vec<u8>, data: Vec<u8>,
flushed_notifier: Arc<tokio::sync::Notify>, flushed_notifier: Arc<tokio::sync::Notify>,
) -> Option<tokio::sync::oneshot::Receiver<()>> {
self.push_op(key, DbOp::Insert(data), flushed_notifier)
}
pub fn push_delete(
&self,
key: String,
flushed_notifier: Arc<tokio::sync::Notify>,
) -> Option<tokio::sync::oneshot::Receiver<()>> {
self.push_op(key, DbOp::Delete, flushed_notifier)
}
fn push_op(
&self,
key: String,
op: DbOp,
flushed_notifier: Arc<tokio::sync::Notify>,
) -> Option<tokio::sync::oneshot::Receiver<()>> { ) -> Option<tokio::sync::oneshot::Receiver<()>> {
let (oneshot_tx, oneshot_rx) = tokio::sync::oneshot::channel(); let (oneshot_tx, oneshot_rx) = tokio::sync::oneshot::channel();
let task = DbWriteTask { let task = DbWriteTask {
key, key,
data, op,
flushed_notifier, flushed_notifier,
oneshot_tx: Some(oneshot_tx), oneshot_tx: Some(oneshot_tx),
}; };
if let Err(e) = self.tx.try_send(task) { if let Err(e) = self.tx.try_send(task) {
let key = e.into_inner().key; match e {
tracing::error!("DbWriteQueue channel full or closed; unable to persist key '{}'", key); tokio::sync::mpsc::error::TrySendError::Full(task) => {
let tx = self.tx.clone();
let key = task.key.clone();
tokio::spawn(async move {
if let Err(err) = tx.send(task).await {
tracing::error!(
"DbWriteQueue fallback send failed for key '{}': {}",
key,
err
);
}
});
None None
}
tokio::sync::mpsc::error::TrySendError::Closed(task) => {
tracing::error!(
"DbWriteQueue channel closed; unable to persist key '{}'",
task.key
);
None
}
}
} else { } else {
Some(oneshot_rx) Some(oneshot_rx)
} }
@@ -110,16 +177,38 @@ impl DbWriteQueue {
key: String, key: String,
data: Vec<u8>, data: Vec<u8>,
flushed_notifier: Arc<tokio::sync::Notify>, flushed_notifier: Arc<tokio::sync::Notify>,
) -> Option<tokio::sync::oneshot::Receiver<()>> {
self.push_op_async(key, DbOp::Insert(data), flushed_notifier)
.await
}
pub async fn push_delete_async(
&self,
key: String,
flushed_notifier: Arc<tokio::sync::Notify>,
) -> Option<tokio::sync::oneshot::Receiver<()>> {
self.push_op_async(key, DbOp::Delete, flushed_notifier)
.await
}
async fn push_op_async(
&self,
key: String,
op: DbOp,
flushed_notifier: Arc<tokio::sync::Notify>,
) -> Option<tokio::sync::oneshot::Receiver<()>> { ) -> Option<tokio::sync::oneshot::Receiver<()>> {
let (oneshot_tx, oneshot_rx) = tokio::sync::oneshot::channel(); let (oneshot_tx, oneshot_rx) = tokio::sync::oneshot::channel();
let task = DbWriteTask { let task = DbWriteTask {
key, key,
data, op,
flushed_notifier, flushed_notifier,
oneshot_tx: Some(oneshot_tx), oneshot_tx: Some(oneshot_tx),
}; };
if let Err(e) = self.tx.send(task).await { if let Err(e) = self.tx.send(task).await {
tracing::error!("DbWriteQueue channel closed; unable to persist key '{}'", e.0.key); tracing::error!(
"DbWriteQueue channel closed; unable to persist key '{}'",
e.0.key
);
None None
} else { } else {
Some(oneshot_rx) Some(oneshot_rx)
@@ -156,29 +245,116 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
tracing::error!("Failed to begin read transaction for key '{}'", key); tracing::error!("Failed to begin read transaction for key '{}'", key);
return (T::default(), false); return (T::default(), false);
}; };
match read_txn.open_table(STORE_TABLE) { let Ok(table) = read_txn.open_table(STORE_TABLE) else {
Ok(table) => match table.get(key) { return (T::default(), false);
};
// 1. Check monolithic key first as the authoritative snapshot
match table.get(key) {
Ok(Some(value)) => match serde_json::from_slice::<T>(value.value()) { Ok(Some(value)) => match serde_json::from_slice::<T>(value.value()) {
Ok(parsed) => (parsed, false), Ok(parsed) => return (parsed, false),
Err(e) => { Err(e) => {
tracing::error!( tracing::error!(
"CRITICAL: Corrupted data for key '{}' in database: {}. Quarantine mode active: state initialized to empty default without overwriting DB key.", "CRITICAL: Corrupted data for key '{}' in database: {}. Quarantine mode active: state initialized to empty default without overwriting DB key.",
key, e key,
e
); );
(T::default(), true) return (T::default(), true);
} }
}, },
Ok(None) => (T::default(), false), Ok(None) => {}
Err(e) => { Err(e) => {
tracing::error!("Failed to get key '{}' from store table: {}", key, e); tracing::error!("Failed to get key '{}' from store table: {}", key, e);
}
}
// 2. Granular prefix keys fallback: format!("{}:", key)
let prefix = format!("{}:", key);
let mut items_array = Vec::new();
let mut items_map = serde_json::Map::new();
let mut found_granular = false;
if let Ok(range) = table.range(prefix.as_str()..) {
for entry in range {
if let Ok((k, v)) = entry {
let k_str = k.value();
if !k_str.starts_with(&prefix) {
break;
}
found_granular = true;
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(v.value()) {
let sub_key = &k_str[prefix.len()..];
items_array.push(val.clone());
items_map.insert(sub_key.to_string(), val);
}
}
}
}
if found_granular {
if let Ok(parsed) = serde_json::from_value::<T>(serde_json::Value::Array(items_array)) {
return (parsed, false);
}
if let Ok(parsed) = serde_json::from_value::<T>(serde_json::Value::Object(items_map)) {
return (parsed, false);
}
}
(T::default(), false) (T::default(), false)
} }
},
Err(e) => { fn extract_granular_keys(base_key: &str, val: &serde_json::Value) -> Vec<String> {
tracing::error!("Failed to open STORE_TABLE for key '{}': {}", key, e); let mut keys = Vec::new();
(T::default(), false) match val {
serde_json::Value::Array(arr) => {
for (i, item) in arr.iter().enumerate() {
let sub_key = item
.get("id")
.or_else(|| item.get("name"))
.or_else(|| item.get("title"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| i.to_string());
keys.push(format!("{}:{}", base_key, sub_key));
} }
} }
serde_json::Value::Object(map) => {
for sub_key in map.keys() {
keys.push(format!("{}:{}", base_key, sub_key));
}
}
_ => {}
}
keys
}
fn extract_granular_entries(base_key: &str, val: &serde_json::Value) -> Vec<(String, Vec<u8>)> {
let mut granular = Vec::new();
match val {
serde_json::Value::Array(arr) => {
for (i, item) in arr.iter().enumerate() {
let sub_key = item
.get("id")
.or_else(|| item.get("name"))
.or_else(|| item.get("title"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| i.to_string());
if let Ok(item_bytes) = serde_json::to_vec(item) {
granular.push((format!("{}:{}", base_key, sub_key), item_bytes));
}
}
}
serde_json::Value::Object(map) => {
for (sub_key, item) in map {
if let Ok(item_bytes) = serde_json::to_vec(item) {
granular.push((format!("{}:{}", base_key, sub_key), item_bytes));
}
}
}
_ => {}
}
granular
} }
pub fn read_with<F, R>(&self, f: F) -> R pub fn read_with<F, R>(&self, f: F) -> R
@@ -191,7 +367,7 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
pub fn modify<F: FnOnce(&mut T)>(&self, f: F) pub fn modify<F: FnOnce(&mut T)>(&self, f: F)
where where
T: Serialize, T: Serialize + Clone,
{ {
if self.is_corrupted { if self.is_corrupted {
tracing::error!( tracing::error!(
@@ -201,30 +377,80 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
return; return;
} }
let serialized_res = { // Fast mutation under critical lock section, then immediately release the RwLock guard
let (old_snapshot, new_snapshot) = {
let mut lock = self.cache.write().unwrap_or_else(|e| e.into_inner()); let mut lock = self.cache.write().unwrap_or_else(|e| e.into_inner());
let old = (*lock).clone();
f(&mut lock); f(&mut lock);
serde_json::to_vec(&*lock) let new = (*lock).clone();
(old, new)
}; };
match serialized_res { // Expensive serialization and granular extraction run completely unblocked outside the lock
let old_keys = serde_json::to_value(&old_snapshot)
.map(|val| Self::extract_granular_keys(&self.key, &val))
.unwrap_or_default();
let full_bytes_res = serde_json::to_vec(&new_snapshot);
let granular_entries = serde_json::to_value(&new_snapshot)
.map(|val| Self::extract_granular_entries(&self.key, &val))
.unwrap_or_default();
let new_keys: std::collections::HashSet<&str> =
granular_entries.iter().map(|(k, _)| k.as_str()).collect();
let mut removed_keys = Vec::new();
for old_k in &old_keys {
if !new_keys.contains(old_k.as_str()) {
removed_keys.push(old_k.clone());
}
}
match full_bytes_res {
Ok(data) => { Ok(data) => {
if self.queue.push(self.key.clone(), data.clone(), self.flushed.clone()).is_none() { // Delete removed granular entries so they don't resurrect on restart
for del_key in removed_keys {
self.queue.push_delete(del_key, self.flushed.clone());
}
// Queue granular entries
for (g_key, g_bytes) in granular_entries {
self.queue.push(g_key, g_bytes, self.flushed.clone());
}
if self
.queue
.push(self.key.clone(), data.clone(), self.flushed.clone())
.is_none()
{
tracing::warn!(
"DbWriteQueue channel full for key '{}'. Applying backpressure fallback.",
self.key
);
let queue = self.queue.clone(); let queue = self.queue.clone();
let key = self.key.clone(); let key = self.key.clone();
let flushed = self.flushed.clone(); let flushed = self.flushed.clone();
tokio::spawn(async move { if let Ok(handle) = tokio::runtime::Handle::try_current() {
let _ = queue.push_async(key, data, flushed).await; handle.spawn(async move {
let _ = tokio::time::timeout(
std::time::Duration::from_secs(10),
queue.push_async(key, data, flushed),
)
.await;
}); });
} }
} }
Err(e) => tracing::error!("Failed to serialize memory store for key '{}': {}", self.key, e), }
Err(e) => tracing::error!(
"Failed to serialize memory store for key '{}': {}",
self.key,
e
),
} }
} }
pub async fn modify_async<F: FnOnce(&mut T)>(&self, f: F) pub async fn modify_async<F: FnOnce(&mut T)>(&self, f: F)
where where
T: Serialize, T: Serialize + Clone,
{ {
if self.is_corrupted { if self.is_corrupted {
tracing::error!( tracing::error!(
@@ -234,19 +460,57 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
return; return;
} }
let serialized_res = { let (old_snapshot, new_snapshot) = {
let mut lock = self.cache.write().unwrap_or_else(|e| e.into_inner()); let mut lock = self.cache.write().unwrap_or_else(|e| e.into_inner());
let old = (*lock).clone();
f(&mut lock); f(&mut lock);
serde_json::to_vec(&*lock) let new = (*lock).clone();
(old, new)
}; };
match serialized_res { let old_keys = serde_json::to_value(&old_snapshot)
.map(|val| Self::extract_granular_keys(&self.key, &val))
.unwrap_or_default();
let full_bytes_res = serde_json::to_vec(&new_snapshot);
let granular_entries = serde_json::to_value(&new_snapshot)
.map(|val| Self::extract_granular_entries(&self.key, &val))
.unwrap_or_default();
let new_keys: std::collections::HashSet<&str> =
granular_entries.iter().map(|(k, _)| k.as_str()).collect();
let mut removed_keys = Vec::new();
for old_k in &old_keys {
if !new_keys.contains(old_k.as_str()) {
removed_keys.push(old_k.clone());
}
}
match full_bytes_res {
Ok(data) => { Ok(data) => {
if let Some(rx) = self.queue.push_async(self.key.clone(), data, self.flushed.clone()).await { for del_key in removed_keys {
self.queue
.push_delete_async(del_key, self.flushed.clone())
.await;
}
for (g_key, g_bytes) in granular_entries {
self.queue
.push_async(g_key, g_bytes, self.flushed.clone())
.await;
}
if let Some(rx) = self
.queue
.push_async(self.key.clone(), data, self.flushed.clone())
.await
{
let _ = rx.await; let _ = rx.await;
} }
} }
Err(e) => tracing::error!("Failed to serialize memory store for key '{}': {}", self.key, e), Err(e) => tracing::error!(
"Failed to serialize memory store for key '{}': {}",
self.key,
e
),
} }
} }
} }
@@ -322,4 +586,42 @@ mod tests {
assert_eq!(store.read_with(|s| s.value), 50); assert_eq!(store.read_with(|s| s.value), 50);
} }
#[tokio::test]
async fn test_store_deletion_does_not_resurrect() {
let db = create_in_memory_test_db();
#[derive(Serialize, serde::Deserialize, Clone, Default, PartialEq, Debug)]
struct Item {
id: String,
name: String,
}
let store = Store::<Vec<Item>>::new("items", db.clone());
store.modify(|items| {
items.push(Item {
id: "item1".into(),
name: "First".into(),
});
items.push(Item {
id: "item2".into(),
name: "Second".into(),
});
});
store.flushed.notified().await;
// Verify both items loaded
let store_check = Store::<Vec<Item>>::new("items", db.clone());
assert_eq!(store_check.read_with(|items| items.len()), 2);
// Delete item1
store.modify(|items| {
items.retain(|i| i.id != "item1");
});
store.flushed.notified().await;
// Reload from DB into a brand new Store instance - item1 must NOT resurrect!
let store_reloaded = Store::<Vec<Item>>::new("items", db.clone());
let remaining = store_reloaded.read_with(|items| items.clone());
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].id, "item2");
}
} }
+259 -17
View File
@@ -26,7 +26,7 @@ pub struct CreateEntitiesTool {
} }
/// Create new relations between entities in the knowledge graph. /// Create new relations between entities in the knowledge graph.
/// CRITICAL SCHEMA REQUIREMENT: Each relation item MUST strictly contain fields 'from' (source entity name string), 'to' (target entity name string), and 'relation_type' (snake_case string, e.g., 'depends_on', 'calls', 'implements'). Do NOT use 'source', 'target', or 'relationType'. /// Create new relations between entities in the knowledge graph (accepts 'from' or 'source', 'to' or 'target', and 'relation_type' or 'type').
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct CreateRelationsTool { pub struct CreateRelationsTool {
/// Array of relations to create. Each relation item MUST use 'from', 'to', and 'relation_type'. /// Array of relations to create. Each relation item MUST use 'from', 'to', and 'relation_type'.
@@ -73,13 +73,17 @@ pub struct DeleteRelationsTool {
pub relations: Vec<crate::models::Relation>, pub relations: Vec<crate::models::Relation>,
} }
/// Read the entire knowledge graph. WARNING: For large graphs, use search_nodes instead to avoid context limits. /// Read the entire knowledge graph. WARNING: For large graphs, use search_nodes or pagination (limit, offset) to avoid context limits.
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ReadGraphTool { pub struct ReadGraphTool {
/// Optional namespace to restrict the read to. /// Optional namespace to restrict the read to.
pub namespace: Option<String>, pub namespace: Option<String>,
/// Optional token budget cap. Truncates graph outputs to fit within the specified token budget. /// Optional token budget cap. Truncates graph outputs to fit within the specified token budget.
pub max_tokens: Option<usize>, pub max_tokens: Option<usize>,
/// Optional maximum number of entities to return (pagination).
pub limit: Option<usize>,
/// Optional entity offset for pagination.
pub offset: Option<usize>,
} }
/// Search specifically for Knowledge Graph entities and nodes by name or type. /// Search specifically for Knowledge Graph entities and nodes by name or type.
@@ -118,11 +122,40 @@ pub struct LogCodeChangeTool {
pub symbol_references: Option<Vec<String>>, pub symbol_references: Option<Vec<String>>,
/// Optional line range (e.g. 'L123-L145') modified in the target file. /// Optional line range (e.g. 'L123-L145') modified in the target file.
pub line_range: Option<String>, pub line_range: Option<String>,
/// Optional repository name (e.g. 'mcp-memory', 'ai-pr-review').
pub repo_name: Option<String>,
/// Optional repository remote origin URL (e.g. 'git@bitbucket.org:org/repo.git' or 'svn://...').
pub repo_url: Option<String>,
/// Optional project namespace (defaults to repo_name or 'global').
pub namespace: Option<String>,
/// Optional change kind: 'added', 'modified', 'deleted', 'renamed'. Defaults to 'modified'.
pub change_kind: Option<String>,
/// Optional author or subagent attribution (e.g. 'user', 'MemoryLibrarian', SVN author).
pub author: Option<String>,
/// Optional conversation or session ID.
pub session_id: Option<String>,
/// Optional VCS system type: 'git', 'svn', 'hg', etc. Auto-detected if omitted.
pub vcs_type: Option<String>,
/// Optional VCS revision identifier (e.g. SVN 'r14829' or Git commit SHA).
pub revision: Option<String>,
/// Optional VCS branch (e.g. SVN 'trunk', 'branches/v1.0' or Git branch).
pub branch: Option<String>,
/// Optional repository root URL or path.
pub repository_root: Option<String>,
} }
/// Query recently logged code changes. /// Query recently logged code changes.
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct QueryRecentChangesTool {} pub struct QueryRecentChangesTool {
/// Optional namespace to filter changes by project/workspace.
pub namespace: Option<String>,
/// Optional repository name to filter changes.
pub repo_name: Option<String>,
/// Optional VCS type to filter changes ('git', 'svn', etc.).
pub vcs_type: Option<String>,
/// Maximum number of records to return (defaults to 50).
pub limit: Option<usize>,
}
/// Generate a visual representation of the knowledge graph. /// Generate a visual representation of the knowledge graph.
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
@@ -167,7 +200,6 @@ pub struct StickyNotesTool {
pub session_only: Option<bool>, pub session_only: Option<bool>,
} }
/// Condense or summarize an entity's observations to reduce size. /// Condense or summarize an entity's observations to reduce size.
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct CondenseEntityTool { pub struct CondenseEntityTool {
@@ -190,6 +222,16 @@ pub struct AddTaskTool {
pub parent_id: Option<String>, pub parent_id: Option<String>,
/// Optional list of task IDs this task depends on. /// Optional list of task IDs this task depends on.
pub dependencies: Option<Vec<String>>, pub dependencies: Option<Vec<String>>,
/// Optional namespace (defaults to 'default').
pub namespace: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
/// Optional task priority: 'low', 'medium', 'high', 'urgent'.
pub priority: Option<String>,
/// Optional assigned subagent role or identifier.
pub assigned_agent: Option<String>,
/// Optional verification command to validate criteria completion.
pub verification_command: Option<String>,
} }
/// Delete a task and all its children. /// Delete a task and all its children.
@@ -233,6 +275,14 @@ pub struct StoreSnippetTool {
pub description: String, pub description: String,
/// Optional classification tags (e.g. ['rust', 'async', 'mcp']). /// Optional classification tags (e.g. ['rust', 'async', 'mcp']).
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// Optional project namespace.
pub namespace: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
/// Optional origin file path where the snippet was extracted from.
pub origin_file: Option<String>,
/// Optional line range in origin file (e.g. 'L10-L45').
pub line_range: Option<String>,
} }
/// Add classification tags to an existing code snippet. /// Add classification tags to an existing code snippet.
@@ -362,6 +412,16 @@ pub struct LogDecisionTool {
pub consequence: String, pub consequence: String,
/// The ID of an older ADR this decision supersedes, if any. /// The ID of an older ADR this decision supersedes, if any.
pub supersedes: Option<String>, pub supersedes: Option<String>,
/// Optional project namespace.
pub namespace: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
/// Optional author or architect behind the decision.
pub author: Option<String>,
/// Optional affected system components or crates.
pub affected_components: Option<Vec<String>>,
/// Optional alternative designs or libraries considered.
pub alternatives_considered: Option<Vec<String>>,
} }
/// Query architectural decision records. /// Query architectural decision records.
@@ -448,6 +508,12 @@ pub struct PinFileTool {
pub file_path: String, pub file_path: String,
/// The associated git branch, if any. /// The associated git branch, if any.
pub git_branch: Option<String>, pub git_branch: Option<String>,
/// Optional reason for pinning this file.
pub pin_reason: Option<String>,
/// Optional symbol focus or line range.
pub symbol_or_line: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
} }
/// Unpin a file from the context workspace. /// Unpin a file from the context workspace.
@@ -551,6 +617,14 @@ pub struct AddMilestoneTool {
/// The namespace for the milestone. /// The namespace for the milestone.
#[serde(default = "crate::models::default_namespace")] #[serde(default = "crate::models::default_namespace")]
pub namespace: String, pub namespace: String,
/// Optional target completion date.
pub target_date: Option<String>,
/// Optional description of milestone scope.
pub description: Option<String>,
/// Optional deliverables or task checklist.
pub deliverables: Option<Vec<String>>,
/// Optional repository name.
pub repo_name: Option<String>,
} }
/// Update the status of a milestone. /// Update the status of a milestone.
@@ -611,6 +685,14 @@ pub struct AddPrChecklistItemTool {
pub namespace: String, pub namespace: String,
/// The description of the checklist item. /// The description of the checklist item.
pub description: String, pub description: String,
/// Optional checklist category (e.g. 'Security', 'Testing', 'Formatting').
pub category: Option<String>,
/// Optional automated verification command or script.
pub automated_check: Option<String>,
/// Optional checked state.
pub is_checked: Option<bool>,
/// Optional repository name.
pub repo_name: Option<String>,
} }
/// Get the PR checklist. /// Get the PR checklist.
@@ -647,6 +729,16 @@ pub struct LogTechDebtTool {
pub symbol_references: Option<Vec<String>>, pub symbol_references: Option<Vec<String>>,
/// Optional line range (e.g. 'L123-L145') associated with the tech debt. /// Optional line range (e.g. 'L123-L145') associated with the tech debt.
pub line_range: Option<String>, pub line_range: Option<String>,
/// Optional debt severity: 'low', 'medium', 'high', 'critical'.
pub severity: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
/// Optional file path where debt exists.
pub file_path: Option<String>,
/// Optional workaround currently in place.
pub workaround: Option<String>,
/// Optional effort estimate to fix (e.g. '1 hour', '2 days').
pub effort_estimate: Option<String>,
} }
/// Resolve a technical debt record. /// Resolve a technical debt record.
@@ -682,6 +774,14 @@ pub struct SaveContextWorkspaceTool {
pub pinned_files: Vec<String>, pub pinned_files: Vec<String>,
/// Array of active task IDs. /// Array of active task IDs.
pub active_task_ids: Vec<String>, pub active_task_ids: Vec<String>,
/// Optional description of the context workspace purpose.
pub description: Option<String>,
/// Optional git branch associated with this workspace.
pub git_branch: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
/// Optional VCS revision or commit hash.
pub vcs_revision: Option<String>,
} }
/// Load a saved context workspace. /// Load a saved context workspace.
@@ -864,11 +964,6 @@ pub struct GetRecentLogsTool {
pub file_path: String, pub file_path: String,
} }
/// Enable or disable clipboard watch mode (auto-ingesting into StickyNotes).
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ToggleClipboardWatchModeTool {
pub enable: bool,
}
/// Read a file and return only its AST skeleton (Imports, Structs, Enums, Traits, Functions) /// Read a file and return only its AST skeleton (Imports, Structs, Enums, Traits, Functions)
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ReadFileSkeletonTool { pub struct ReadFileSkeletonTool {
@@ -947,6 +1042,9 @@ pub struct FindSymbolReferencesTool {
pub symbol: String, pub symbol: String,
/// Optional maximum number of reference locations to return. Defaults to 10. /// Optional maximum number of reference locations to return. Defaults to 10.
pub limit: Option<usize>, pub limit: Option<usize>,
/// Optional workspace or directory path to scan. If omitted, falls back to active pinned files or current working directory.
#[serde(default, alias = "directory", alias = "path")]
pub workspace_dir: Option<String>,
} }
/// Find all caller functions or methods that invoke a specified target function or method name. /// Find all caller functions or methods that invoke a specified target function or method name.
@@ -956,6 +1054,9 @@ pub struct GetCallersTool {
pub function_name: String, pub function_name: String,
/// Optional maximum number of callers to return. Defaults to 10. /// Optional maximum number of callers to return. Defaults to 10.
pub limit: Option<usize>, pub limit: Option<usize>,
/// Optional workspace or directory path to scan. If omitted, falls back to active pinned files or current working directory.
#[serde(default, alias = "directory", alias = "path")]
pub workspace_dir: Option<String>,
} }
/// Query git commit history, diffs, and change ledger entries using keyword or semantic search. /// Query git commit history, diffs, and change ledger entries using keyword or semantic search.
@@ -1079,8 +1180,11 @@ pub struct SearchSnippetsHybridTool {
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum HandoffMemoAction { pub enum HandoffMemoAction {
#[serde(alias = "leave", alias = "LEAVE", alias = "Leave")]
Leave, Leave,
#[serde(alias = "read", alias = "READ", alias = "Read")]
Read, Read,
#[serde(alias = "clear", alias = "CLEAR", alias = "Clear")]
Clear, Clear,
} }
@@ -1105,11 +1209,22 @@ pub struct HandoffMemosTool {
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum TaskAction { pub enum TaskAction {
#[serde(alias = "add", alias = "ADD", alias = "Add")]
Add, Add,
#[serde(alias = "update", alias = "UPDATE", alias = "Update")]
Update, Update,
#[serde(alias = "delete", alias = "DELETE", alias = "Delete")]
Delete, Delete,
#[serde(alias = "list", alias = "LIST", alias = "List")]
List, List,
#[serde(
alias = "set_criteria",
alias = "setCriteria",
alias = "SET_CRITERIA",
alias = "SetCriteria"
)]
SetCriteria, SetCriteria,
#[serde(alias = "verify", alias = "VERIFY", alias = "Verify")]
Verify, Verify,
} }
@@ -1127,20 +1242,27 @@ pub struct TasksTool {
/// Action to perform: 'add', 'update', 'delete', 'list', 'set_criteria', or 'verify'. /// Action to perform: 'add', 'update', 'delete', 'list', 'set_criteria', or 'verify'.
pub action: TaskAction, pub action: TaskAction,
/// Task ID (required for 'update', 'delete', 'set_criteria', 'verify'). /// Task ID (required for 'update', 'delete', 'set_criteria', 'verify').
#[serde(alias = "task_id", alias = "taskId")]
pub id: Option<String>, pub id: Option<String>,
/// Task title (required for 'add'). /// Task title (required for 'add').
#[serde(alias = "name")]
pub title: Option<String>, pub title: Option<String>,
/// Task description (optional for 'add'). /// Task description (optional for 'add').
#[serde(alias = "desc")]
pub description: Option<String>, pub description: Option<String>,
/// New status: 'pending', 'completed', or 'cancelled' (for 'update'). /// New status: 'pending', 'completed', or 'cancelled' (for 'update').
pub status: Option<String>, pub status: Option<String>,
/// Parent task ID (optional for 'add'). /// Parent task ID (optional for 'add').
#[serde(alias = "parentId", alias = "parent")]
pub parent_id: Option<String>, pub parent_id: Option<String>,
/// List of dependency task IDs (optional for 'add'). /// List of dependency task IDs (optional for 'add').
#[serde(alias = "deps")]
pub dependencies: Option<Vec<String>>, pub dependencies: Option<Vec<String>>,
/// Git branch filter or assignment. /// Git branch filter or assignment.
#[serde(alias = "branch")]
pub git_branch: Option<String>, pub git_branch: Option<String>,
/// Acceptance criteria (required for 'set_criteria'). /// Acceptance criteria (required for 'set_criteria').
#[serde(alias = "acceptance_criteria", alias = "acceptanceCriteria")]
pub criteria: Option<Vec<String>>, pub criteria: Option<Vec<String>>,
/// Verification proof or details (optional for 'verify'). /// Verification proof or details (optional for 'verify').
pub proof: Option<String>, pub proof: Option<String>,
@@ -1148,13 +1270,26 @@ pub struct TasksTool {
pub summary_level: Option<String>, pub summary_level: Option<String>,
/// Maximum tokens budget cap (for 'list'). /// Maximum tokens budget cap (for 'list').
pub max_tokens: Option<usize>, pub max_tokens: Option<usize>,
/// Optional namespace filter or assignment (defaults to 'default').
pub namespace: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
/// Optional task priority: 'low', 'medium', 'high', 'urgent'.
pub priority: Option<String>,
/// Optional assigned subagent role or identifier.
pub assigned_agent: Option<String>,
/// Optional verification command to validate criteria completion.
pub verification_command: Option<String>,
} }
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum MilestoneAction { pub enum MilestoneAction {
#[serde(alias = "add", alias = "ADD", alias = "Add")]
Add, Add,
#[serde(alias = "update", alias = "UPDATE", alias = "Update")]
Update, Update,
#[serde(alias = "list", alias = "LIST", alias = "List")]
List, List,
} }
@@ -1169,20 +1304,33 @@ pub struct MilestonesTool {
/// Action to perform: 'add', 'update', or 'list'. /// Action to perform: 'add', 'update', or 'list'.
pub action: MilestoneAction, pub action: MilestoneAction,
/// Milestone ID (required for 'update'). /// Milestone ID (required for 'update').
#[serde(alias = "milestone_id", alias = "milestoneId")]
pub id: Option<String>, pub id: Option<String>,
/// Milestone title (required for 'add'). /// Milestone title (required for 'add').
#[serde(alias = "name")]
pub title: Option<String>, pub title: Option<String>,
/// New status (for 'update'). /// New status (for 'update').
pub status: Option<String>, pub status: Option<String>,
/// Optional namespace filter or assignment. /// Optional namespace filter or assignment.
pub namespace: Option<String>, pub namespace: Option<String>,
/// Optional target completion date.
pub target_date: Option<String>,
/// Optional description of milestone scope.
pub description: Option<String>,
/// Optional deliverables or task checklist.
pub deliverables: Option<Vec<String>>,
/// Optional repository name.
pub repo_name: Option<String>,
} }
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum PinnedFileAction { pub enum PinnedFileAction {
#[serde(alias = "pin", alias = "PIN", alias = "Pin")]
Pin, Pin,
#[serde(alias = "unpin", alias = "UNPIN", alias = "Unpin")]
Unpin, Unpin,
#[serde(alias = "list", alias = "LIST", alias = "List")]
List, List,
} }
@@ -1200,15 +1348,28 @@ pub struct PinnedFilesTool {
pub path: Option<String>, pub path: Option<String>,
/// Optional namespace. /// Optional namespace.
pub namespace: Option<String>, pub namespace: Option<String>,
/// Optional reason for pinning this file.
pub pin_reason: Option<String>,
/// Optional symbol focus or line range.
pub symbol_or_line: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
/// Optional git branch.
pub git_branch: Option<String>,
} }
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ContextWorkspaceAction { pub enum ContextWorkspaceAction {
#[serde(alias = "save", alias = "SAVE", alias = "Save")]
Save, Save,
#[serde(alias = "load", alias = "LOAD", alias = "Load")]
Load, Load,
#[serde(alias = "list", alias = "LIST", alias = "List")]
List, List,
#[serde(alias = "delete", alias = "DELETE", alias = "Delete")]
Delete, Delete,
#[serde(alias = "diff", alias = "DIFF", alias = "Diff")]
Diff, Diff,
} }
@@ -1234,13 +1395,24 @@ pub struct ContextWorkspacesTool {
pub active_task_ids: Option<Vec<String>>, pub active_task_ids: Option<Vec<String>>,
/// Optional namespace. /// Optional namespace.
pub namespace: Option<String>, pub namespace: Option<String>,
/// Optional description of the context workspace purpose.
pub description: Option<String>,
/// Optional git branch associated with this workspace.
pub git_branch: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
/// Optional VCS revision or commit hash.
pub vcs_revision: Option<String>,
} }
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum PrChecklistAction { pub enum PrChecklistAction {
#[serde(alias = "add", alias = "ADD", alias = "Add")]
Add, Add,
#[serde(alias = "get", alias = "GET", alias = "Get")]
Get, Get,
#[serde(alias = "clear", alias = "CLEAR", alias = "Clear")]
Clear, Clear,
} }
@@ -1258,14 +1430,26 @@ pub struct PrChecklistTool {
pub description: Option<String>, pub description: Option<String>,
/// Optional namespace. /// Optional namespace.
pub namespace: Option<String>, pub namespace: Option<String>,
/// Optional checklist category (e.g. 'Security', 'Testing', 'Formatting').
pub category: Option<String>,
/// Optional automated verification command or script.
pub automated_check: Option<String>,
/// Optional checked state.
pub is_checked: Option<bool>,
/// Optional repository name.
pub repo_name: Option<String>,
} }
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum SnippetAction { pub enum SnippetAction {
#[serde(alias = "store", alias = "STORE", alias = "Store")]
Store, Store,
#[serde(alias = "search", alias = "SEARCH", alias = "Search")]
Search, Search,
#[serde(alias = "delete", alias = "DELETE", alias = "Delete")]
Delete, Delete,
#[serde(alias = "tag", alias = "TAG", alias = "Tag")]
Tag, Tag,
} }
@@ -1296,13 +1480,24 @@ pub struct SnippetsTool {
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// Enable hybrid lexical + semantic vector ranking (for 'search'). /// Enable hybrid lexical + semantic vector ranking (for 'search').
pub hybrid: Option<bool>, pub hybrid: Option<bool>,
/// Optional project namespace.
pub namespace: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
/// Optional origin file path where the snippet was extracted from.
pub origin_file: Option<String>,
/// Optional line range in origin file (e.g. 'L10-L45').
pub line_range: Option<String>,
} }
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DecisionAction { pub enum DecisionAction {
#[serde(alias = "log", alias = "LOG", alias = "Log")]
Log, Log,
#[serde(alias = "query", alias = "QUERY", alias = "Query")]
Query, Query,
#[serde(alias = "delete", alias = "DELETE", alias = "Delete")]
Delete, Delete,
} }
@@ -1332,13 +1527,24 @@ pub struct DecisionsTool {
pub query: Option<String>, pub query: Option<String>,
/// Optional namespace. /// Optional namespace.
pub namespace: Option<String>, pub namespace: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
/// Optional author or architect behind the decision.
pub author: Option<String>,
/// Optional affected system components or crates.
pub affected_components: Option<Vec<String>>,
/// Optional alternative designs or libraries considered.
pub alternatives_considered: Option<Vec<String>>,
} }
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum TechDebtAction { pub enum TechDebtAction {
#[serde(alias = "log", alias = "LOG", alias = "Log")]
Log, Log,
#[serde(alias = "resolve", alias = "RESOLVE", alias = "Resolve")]
Resolve, Resolve,
#[serde(alias = "list", alias = "LIST", alias = "List")]
List, List,
} }
@@ -1353,16 +1559,21 @@ pub struct TechDebtTool {
/// Action to perform: 'log', 'resolve', or 'list'. /// Action to perform: 'log', 'resolve', or 'list'.
pub action: TechDebtAction, pub action: TechDebtAction,
/// Tech debt ID (required for 'resolve'). /// Tech debt ID (required for 'resolve').
#[serde(alias = "tech_debt_id", alias = "debt_id")]
pub id: Option<String>, pub id: Option<String>,
/// Tech debt description (required for 'log'). /// Tech debt description (required for 'log').
#[serde(alias = "desc", alias = "title")]
pub description: Option<String>, pub description: Option<String>,
/// Ideal solution (optional for 'log'). /// Ideal solution (optional for 'log').
#[serde(alias = "solution", alias = "fix")]
pub ideal_solution: Option<String>, pub ideal_solution: Option<String>,
/// Include resolved tech debt items (for 'list'). /// Include resolved tech debt items (for 'list').
pub include_resolved: Option<bool>, pub include_resolved: Option<bool>,
/// Associated git commit hash. /// Associated git commit hash.
#[serde(alias = "commit")]
pub git_commit: Option<String>, pub git_commit: Option<String>,
/// Associated git branch. /// Associated git branch.
#[serde(alias = "branch")]
pub git_branch: Option<String>, pub git_branch: Option<String>,
/// Symbol references associated with the tech debt. /// Symbol references associated with the tech debt.
pub symbol_references: Option<Vec<String>>, pub symbol_references: Option<Vec<String>>,
@@ -1370,15 +1581,50 @@ pub struct TechDebtTool {
pub line_range: Option<String>, pub line_range: Option<String>,
/// Optional namespace. /// Optional namespace.
pub namespace: Option<String>, pub namespace: Option<String>,
/// Optional debt severity: 'low', 'medium', 'high', 'critical'.
pub severity: Option<String>,
/// Optional repository name.
pub repo_name: Option<String>,
/// Optional file path where debt exists.
pub file_path: Option<String>,
/// Optional workaround currently in place.
pub workaround: Option<String>,
/// Optional effort estimate to fix.
pub effort_estimate: Option<String>,
} }
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum EnvAction { pub enum EnvAction {
#[serde(
alias = "update_fingerprint",
alias = "updateFingerprint",
alias = "UPDATE_FINGERPRINT",
alias = "UpdateFingerprint"
)]
UpdateFingerprint, UpdateFingerprint,
#[serde(
alias = "read_fingerprint",
alias = "readFingerprint",
alias = "READ_FINGERPRINT",
alias = "ReadFingerprint"
)]
ReadFingerprint, ReadFingerprint,
#[serde(
alias = "log_requirement",
alias = "logRequirement",
alias = "LOG_REQUIREMENT",
alias = "LogRequirement"
)]
LogRequirement, LogRequirement,
#[serde(alias = "register", alias = "REGISTER", alias = "Register")]
Register, Register,
#[serde(
alias = "get_details",
alias = "getDetails",
alias = "GET_DETAILS",
alias = "GetDetails"
)]
GetDetails, GetDetails,
} }
@@ -1415,20 +1661,19 @@ pub struct EnvironmentTool {
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ClipboardAction { pub enum ClipboardAction {
#[serde(alias = "read", alias = "READ", alias = "Read")]
Read, Read,
#[serde(alias = "write", alias = "WRITE", alias = "Write")]
Write, Write,
ToggleWatch,
} }
/// OS Clipboard management (read, write).
/// OS Clipboard management (read, write, toggle_watch).
/// Actions: /// Actions:
/// - 'read': Read current clipboard contents. /// - 'read': Read current clipboard contents.
/// - 'write': Write content to OS clipboard. Optional: text, html, files, image_path. /// - 'write': Write content to OS clipboard. Optional: text, html, files, image_path.
/// - 'toggle_watch': Enable or disable auto-clipboard ingestion. Optional: enabled (bool).
/// Next steps on error: Provide text/html/files/image_path when calling 'write'. /// Next steps on error: Provide text/html/files/image_path when calling 'write'.
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ClipboardTool { pub struct ClipboardTool {
/// Action to perform: 'read', 'write', or 'toggle_watch'. /// Action to perform: 'read' or 'write'.
pub action: ClipboardAction, pub action: ClipboardAction,
/// Plain text content (for 'write'). /// Plain text content (for 'write').
pub text: Option<String>, pub text: Option<String>,
@@ -1438,7 +1683,4 @@ pub struct ClipboardTool {
pub files: Option<Vec<String>>, pub files: Option<Vec<String>>,
/// Image path (for 'write'). /// Image path (for 'write').
pub image_path: Option<String>, pub image_path: Option<String>,
/// Enable watch mode flag (for 'toggle_watch').
pub enabled: Option<bool>,
} }
-236
View File
@@ -1,236 +0,0 @@
use qdrant_client::Qdrant;
use qdrant_client::qdrant::{
CreateCollectionBuilder, Distance, PointStruct, UpsertPointsBuilder, VectorParamsBuilder,
};
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::sync::Arc;
use tracing::info;
use uuid::Uuid;
#[derive(Clone)]
pub struct VectorDB {
client: Arc<Qdrant>,
collection_name: String,
}
/// Vector search result containing semantic match metadata.
/// Note: Similarity scores range from 0.0 to 1.0 (Cosine Distance):
/// - Score >= 0.75: High-confidence semantic match
/// - Score 0.50 - 0.74: Moderate semantic relevance
/// - Score < 0.40: Low-confidence noise
#[derive(Debug, Serialize, Deserialize)]
pub struct VectorSearchResult {
pub id: String,
pub doc_type: String,
pub text: String,
pub score: f32,
}
impl VectorDB {
pub async fn new(
url: &str,
collection_name: &str,
) -> Result<Self, Box<dyn Error + Send + Sync>> {
let client = Qdrant::from_url(url).build()?;
let db = Self {
client: Arc::new(client),
collection_name: collection_name.to_string(),
};
db.init_collection().await?;
Ok(db)
}
async fn init_collection(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
// Fastembed AllMiniLML6V2 uses 384 dimensions
let vector_params = VectorParamsBuilder::new(384, Distance::Cosine).build();
let collection_exists = self.client.collection_exists(&self.collection_name).await?;
if !collection_exists {
self.client
.create_collection(
CreateCollectionBuilder::new(&self.collection_name)
.vectors_config(vector_params),
)
.await?;
info!("Created Qdrant collection: {}", self.collection_name);
} else {
info!("Qdrant collection {} already exists", self.collection_name);
}
Ok(())
}
pub async fn index_document(
&self,
id: &str,
doc_type: &str,
text: &str,
vector: Vec<f32>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let point_id = match Uuid::parse_str(id) {
Ok(u) => u.to_string(),
Err(_) => {
// If it's not a valid UUID, let's create a deterministic UUID based on the string
let namespace = Uuid::NAMESPACE_OID;
Uuid::new_v5(&namespace, id.as_bytes()).to_string()
}
};
let mut payload: std::collections::HashMap<String, serde_json::Value> =
std::collections::HashMap::new();
payload.insert(
"doc_type".to_string(),
serde_json::Value::String(doc_type.to_string()),
);
payload.insert(
"text".to_string(),
serde_json::Value::String(text.to_string()),
);
payload.insert(
"original_id".to_string(),
serde_json::Value::String(id.to_string()),
);
let point = PointStruct::new(point_id, vector, payload);
self.client
.upsert_points(UpsertPointsBuilder::new(&self.collection_name, vec![point]))
.await?;
Ok(())
}
pub async fn index_documents_batch(
&self,
docs: Vec<(&str, &str, &str, Vec<f32>)>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
if docs.is_empty() {
return Ok(());
}
let mut points = Vec::with_capacity(docs.len());
for (id, doc_type, text, vector) in docs {
let point_id = match Uuid::parse_str(id) {
Ok(u) => u.to_string(),
Err(_) => {
let namespace = Uuid::NAMESPACE_OID;
Uuid::new_v5(&namespace, id.as_bytes()).to_string()
}
};
let mut payload: std::collections::HashMap<String, serde_json::Value> =
std::collections::HashMap::new();
payload.insert("doc_type".to_string(), serde_json::Value::String(doc_type.to_string()));
payload.insert("text".to_string(), serde_json::Value::String(text.to_string()));
payload.insert("original_id".to_string(), serde_json::Value::String(id.to_string()));
points.push(PointStruct::new(point_id, vector, payload));
}
self.client
.upsert_points(UpsertPointsBuilder::new(&self.collection_name, points))
.await?;
Ok(())
}
pub async fn search(
&self,
query_vector: Vec<f32>,
limit: u64,
) -> Result<Vec<VectorSearchResult>, Box<dyn Error + Send + Sync>> {
use qdrant_client::qdrant::SearchPointsBuilder;
let search_result = self
.client
.search_points(
SearchPointsBuilder::new(&self.collection_name, query_vector, limit)
.with_payload(true),
)
.await?;
let mut results = Vec::new();
for point in search_result.result {
let id = point
.payload
.get("original_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let doc_type = point
.payload
.get("doc_type")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let text = point
.payload
.get("text")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
results.push(VectorSearchResult {
id,
doc_type,
text,
score: point.score,
});
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vector_search_result_serialization() {
let res = VectorSearchResult {
id: "test-id".into(),
doc_type: "snippet".into(),
text: "fn main() {}".into(),
score: 0.95,
};
let json = serde_json::to_string(&res).unwrap();
assert!(json.contains("test-id"));
assert!(json.contains("0.95"));
let deserialized: VectorSearchResult = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, "test-id");
assert_eq!(deserialized.doc_type, "snippet");
assert_eq!(deserialized.text, "fn main() {}");
assert_eq!(deserialized.score, 0.95);
}
#[test]
fn test_deterministic_uuid_hashing() {
let id_str = "custom-non-uuid-string";
let namespace = Uuid::NAMESPACE_OID;
let uuid1 = Uuid::new_v5(&namespace, id_str.as_bytes()).to_string();
let uuid2 = Uuid::new_v5(&namespace, id_str.as_bytes()).to_string();
assert_eq!(uuid1, uuid2);
assert!(Uuid::parse_str(&uuid1).is_ok());
}
#[tokio::test]
async fn test_vector_db_index_document_invalid_id() {
if let Ok(db) = VectorDB::new("http://localhost:6333", "test_collection").await {
let invalid_id = "not-a-uuid";
let doc_type = "test";
let text = "example text";
let vector = vec![1.0; 384];
let _ = db.index_document(invalid_id, doc_type, text, vector).await;
}
}
}
+17 -1
View File
@@ -34,11 +34,25 @@ pub fn spawn_watcher(state: Arc<MemoryState>) {
let mut last_processed: std::collections::HashMap<std::path::PathBuf, std::time::Instant> = let mut last_processed: std::collections::HashMap<std::path::PathBuf, std::time::Instant> =
std::collections::HashMap::new(); std::collections::HashMap::new();
while let Some(res) = rx.recv().await { loop {
tokio::select! {
_ = state.shutdown_notify.notified() => {
info!("File watcher received shutdown notification; terminating cleanly.");
break;
}
res = rx.recv() => {
let Some(res) = res else {
break;
};
match res { match res {
Ok(event) => { Ok(event) => {
if event.kind.is_modify() { if event.kind.is_modify() {
let now = std::time::Instant::now(); let now = std::time::Instant::now();
if last_processed.len() > 1000 {
let ten_mins = std::time::Duration::from_secs(600);
last_processed.retain(|_, last_time| now.duration_since(*last_time) < ten_mins);
}
for path in event.paths { for path in event.paths {
if should_review(&path) { if should_review(&path) {
// 250ms debouncing window per file path // 250ms debouncing window per file path
@@ -58,6 +72,8 @@ pub fn spawn_watcher(state: Arc<MemoryState>) {
Err(e) => error!("Watch error: {}", e), Err(e) => error!("Watch error: {}", e),
} }
} }
}
}
}); });
} }
+7 -10
View File
@@ -1,6 +1,5 @@
use clap::Parser; use clap::Parser;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "mcp-memory-stub", author, version = env!("APP_VERSION"), about = "Antigravity MCP Memory Stub / Proxy", long_about = None)] #[command(name = "mcp-memory-stub", author, version = env!("APP_VERSION"), about = "Antigravity MCP Memory Stub / Proxy", long_about = None)]
@@ -35,14 +34,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let rt = tokio::runtime::Runtime::new()?; let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async { rt.block_on(async {
let (msg_tx, msg_rx) = async_channel::bounded::<String>(100); let (msg_tx, msg_rx) = async_channel::bounded::<String>(100);
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
tokio::spawn(async move { tokio::spawn(async move {
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
while let Some(msg) = mcp_stdio::read_mcp_message(&mut stdin).await { while let Some(msg) = mcp_stdio::read_mcp_message(&mut stdin).await {
let _ = msg_tx.send(msg).await; let _ = msg_tx.send(msg).await;
} }
let _ = shutdown_tx.send(()).await; let _ = shutdown_tx.send(true);
}); });
let target_url = if cli.target == "http://localhost:3000" { let target_url = if cli.target == "http://localhost:3000" {
@@ -58,7 +57,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut retry_count = 0; let mut retry_count = 0;
loop { loop {
if shutdown_rx.try_recv().is_ok() { if *shutdown_rx.borrow() {
tracing::info!("Stub shutdown requested"); tracing::info!("Stub shutdown requested");
break; break;
} }
@@ -76,7 +75,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}; };
let connect_result = tokio::select! { let connect_result = tokio::select! {
_ = shutdown_rx.recv() => { _ = shutdown_rx.wait_for(|&is_shutdown| is_shutdown) => {
tracing::info!("Shutdown received during connect"); tracing::info!("Shutdown received during connect");
break; break;
} }
@@ -126,11 +125,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if text.len() > 1000 { "..." } else { "" } if text.len() > 1000 { "..." } else { "" }
); );
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
let mut payload = Vec::with_capacity(text.len() + 1);
payload.extend_from_slice(text.as_bytes());
payload.push(b'\n');
let mut stdout = tokio::io::stdout(); let mut stdout = tokio::io::stdout();
let _ = stdout.write_all(&payload).await; let _ = stdout.write_all(text.as_bytes()).await;
let _ = stdout.write_all(b"\n").await;
let _ = stdout.flush().await; let _ = stdout.flush().await;
} }
} }
@@ -138,7 +135,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
tokio::select! { tokio::select! {
_ = shutdown_rx.recv() => { _ = shutdown_rx.wait_for(|&is_shutdown| is_shutdown) => {
tracing::info!("Shutdown received while connected"); tracing::info!("Shutdown received while connected");
break; break;
} }
+12 -13
View File
@@ -15,13 +15,20 @@ fn get_stub_exe() -> std::path::PathBuf {
get_target_dir().join(format!("mcp-memory-stub{}", std::env::consts::EXE_SUFFIX)) get_target_dir().join(format!("mcp-memory-stub{}", std::env::consts::EXE_SUFFIX))
} }
#[tokio::test] fn ensure_stub_built() {
async fn test_stub_connection_refused() { let stub_path = get_stub_exe();
if !stub_path.exists() {
let _ = std::process::Command::new("cargo") let _ = std::process::Command::new("cargo")
.arg("build") .arg("build")
.arg("--bin") .arg("--bin")
.arg("mcp-memory-stub") .arg("mcp-memory-stub")
.status(); .status();
}
}
#[tokio::test]
async fn test_stub_connection_refused() {
ensure_stub_built();
let target = "http://127.0.0.1:49999"; let target = "http://127.0.0.1:49999";
@@ -44,13 +51,9 @@ async fn test_stub_connection_refused() {
#[tokio::test] #[tokio::test]
async fn test_stub_handles_eof_cleanly() { async fn test_stub_handles_eof_cleanly() {
let _ = std::process::Command::new("cargo") ensure_stub_built();
.arg("build")
.arg("--bin")
.arg("mcp-memory-stub")
.status();
let target = "http://127.0.0.1:49998"; let target = "http://127.0.0.1:4998";
let mut child = Command::new(get_stub_exe()) let mut child = Command::new(get_stub_exe())
.arg("--target") .arg("--target")
@@ -76,11 +79,7 @@ async fn test_stub_handles_eof_cleanly() {
#[tokio::test] #[tokio::test]
async fn test_stub_sse_fallback_failure() { async fn test_stub_sse_fallback_failure() {
let _ = std::process::Command::new("cargo") ensure_stub_built();
.arg("build")
.arg("--bin")
.arg("mcp-memory-stub")
.status();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let local_addr = listener.local_addr().unwrap(); let local_addr = listener.local_addr().unwrap();