Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c326166311 | ||
|
|
5311167898 | ||
|
|
7fed3a2e77 | ||
|
|
bce9b82b66 | ||
|
|
03f8786d24 | ||
|
|
bd20482cea | ||
|
|
1ed75ca156 | ||
|
|
3765e6b05d | ||
|
|
fecd8bf33f | ||
|
|
b4431a4dcd | ||
|
|
d6acd885dd | ||
|
|
cb0c55775a | ||
|
|
410b0c42ca | ||
|
|
003b3cb2bf | ||
|
|
5485b40d14 | ||
|
|
9d9e959744 | ||
|
|
8952bd5399 |
No files matched your search
@@ -1,65 +0,0 @@
|
||||
# Integrating MCP Memory: A Strategy Guide for LLMs and Agents
|
||||
|
||||
This guide documents the approach and rationale for integrating the `mcp-memory` server with agentic LLMs (like Antigravity). Because `mcp-memory` is a central hub for context, tasks, and environment state, it is critical that LLMs interact with it efficiently without exhausting their primary context window or causing workflow ambiguity.
|
||||
|
||||
## 1. The Core Philosophy: "The Central Brain"
|
||||
The `mcp-memory` server is the persistence layer for the AI development lifecycle. It holds:
|
||||
* **The Knowledge Graph:** Code changes, bug fixes, architecture decisions, and tech debt.
|
||||
* **Project State:** Milestones, tasks, acceptance criteria, and PR checklists.
|
||||
* **Environment State:** Handoff memos, standup reports, and environment fingerprints.
|
||||
* **Live UI Integrations:** Neovim buffer manipulation and user-action webhooks.
|
||||
|
||||
**Rationale:** The LLM's context window is ephemeral and expensive. By pushing state to the `mcp-memory` server (via a local database and Tantivy index), the LLM can selectively retrieve only what it needs, when it needs it.
|
||||
|
||||
## 2. Global Rules vs. Subagents
|
||||
To maximize efficiency, we split interactions into two categories: **Synchronous Rules** (executed by the primary conversational agent) and **Asynchronous Subagents** (delegated background tasks).
|
||||
|
||||
### A. Synchronous Rules (The Primary Loop)
|
||||
The main LLM interacting with the user should be constrained by global system rules to ensure basic context synchronization. These actions must happen synchronously so the main agent never loses the plot.
|
||||
|
||||
* **Context Initialization (`tasks` list, `get_preflight_context`):** Executed when a session starts. This gives the LLM immediate awareness of the current workflow.
|
||||
* **Context Switching (`manage_checkpoint`):** Executed when moving between branches or large features. This prevents context bleed between disparate tasks.
|
||||
* **End-of-Day Handoff (`add_session_summary`, `generate_standup_report`):** Triggered when the user logs off, seamlessly serializing the mental state of the LLM for tomorrow.
|
||||
|
||||
### B. Subagent Orchestration (The Background Team)
|
||||
Heavy or verbose interactions with the MCP server are delegated to specialized background subagents. This keeps the primary chat fast and focused on the code, while the "team" handles project management.
|
||||
|
||||
#### 1. `MemoryLibrarian` (The Graph Curator)
|
||||
* **Role:** Analyzes git diffs and chat history to structure the Knowledge Graph.
|
||||
* **Tools:** `log_code_change`, `log_error_fix`, `create_entities`, `log_tech_debt`.
|
||||
* **Rationale:** Parsing diffs and determining entity relationships is token-heavy. Delegating this prevents the main agent from wasting reasoning cycles on database normalization.
|
||||
|
||||
#### 2. `ScrumMaster` (The Project Manager)
|
||||
* **Role:** Manages the task lifecycle and acceptance criteria.
|
||||
* **Tools:** `add_task`, `update_task_status`, `add_milestone`, `verify_acceptance_criteria`.
|
||||
* **Rationale:** The main agent shouldn't have to repeatedly query "are we done yet?" The `ScrumMaster` runs alongside the session, validating criteria in the background and updating the board autonomously.
|
||||
|
||||
#### 3. `DevOpsSRE` (The Environment Manager)
|
||||
* **Role:** Monitors dependencies and manages session transitions.
|
||||
* **Tools:** `update_env_fingerprint`, `leave_handoff_memo`.
|
||||
* **Rationale:** Prevents "it works on my machine" failures by passively updating fingerprints when build files (e.g., `Cargo.toml`) change.
|
||||
|
||||
## 3. Graceful Degradation & Server Resilience
|
||||
The `mcp-memory` server is a distinct background process (typically port 3000). The LLM ecosystem must handle server downtime gracefully:
|
||||
|
||||
1. **Event Webhooks:** If the server goes down, waiting webhook tasks (e.g., waiting for a user to save a file in Neovim) will drop. These *do not* self-heal. The LLM must recognize the dropped connection and prompt the user to retry the action.
|
||||
2. **Persistent Storage:** Data (tasks, graph, ledger) is persisted to `mcp_store.redb`. When the server comes back online, no data is lost. The LLM can immediately resume querying.
|
||||
3. **Subagent Fast-Failing:** If the `MemoryLibrarian` attempts to log a change while the server is offline, it will instantly fail. It is designed to abandon the background task and notify the primary agent. To recover, the primary agent can manually re-invoke the Librarian once the connection is restored, instructing it to analyze recent commits to backfill the graph.
|
||||
|
||||
## Conclusion
|
||||
By treating `mcp-memory` as the durable brain, and enforcing a strict division of labor between the primary agent loop and background subagents, we achieve a highly autonomous, highly resilient AI pair-programming environment that scales across long-running projects and multiple terminal sessions.
|
||||
|
||||
## 4. MCP Feature Differentiation (Cognitive Boundaries)
|
||||
The MCP protocol exposes three primary primitives. To prevent LLM confusion and API hallucination, the LLM must strictly adhere to the following interaction boundaries:
|
||||
|
||||
### A. Tools (For Stateful Mutation)
|
||||
* **When to use:** Use tools *exclusively* for mutating state (e.g., dd_task, log_code_change) or for highly targeted semantic searches (e.g., search_nodes, query_graph_path).
|
||||
* **LLM Awareness:** The LLM must not use tools to repeatedly poll for state changes. Tools represent active, expensive computing steps.
|
||||
|
||||
### B. Resources (For Passive Awareness)
|
||||
* **When to use:** Use URIs (e.g., memory://tasks/active, memory://session/delta) to read holistic project state.
|
||||
* **LLM Awareness:** The client integration should map these URIs to the LLM's context window. Instead of the LLM invoking a list_active_tasks tool (which costs a round-trip), the LLM should simply read the memory://tasks/active resource content if it needs to know what to do next. Resources are for passive, zero-cost reading.
|
||||
|
||||
### C. Prompts (For Macro-Workflows)
|
||||
* **When to use:** Use server-defined prompts to execute complex, multi-step routines that require bundled context.
|
||||
* **LLM Awareness:** Instead of the user or main agent trying to manually figure out the correct sequence of tools to end a session, the LLM should trigger the handoff_routine prompt. The server will respond with a strictly formatted message array that perfectly primes the LLM on exactly what to do next. Prompts act as "macro-instructions" to prevent the LLM from wandering off-script during complex transitions.
|
||||
@@ -1,77 +0,0 @@
|
||||
# Architecture Design: MCP Resources & Prompts
|
||||
|
||||
## 1. Current Architecture (Tools)
|
||||
Currently, the `mcp-memory` server handles MCP tools using an elegant trait-based approach in `router.rs`:
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait McpTool: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn schema(&self) -> Value;
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String>;
|
||||
}
|
||||
```
|
||||
Tools are registered into a `HashMap<String, Box<dyn McpTool>>` within the `MemoryHandler`. This prevents the main JSON-RPC match block from becoming a monolithic switch statement.
|
||||
|
||||
## 2. The Problem
|
||||
Currently, the `resources/list`, `resources/read`, `prompts/list`, and `prompts/get` endpoints are hardcoded directly inside the `MemoryHandler::handle_request` match block in `router.rs`.
|
||||
As we expand our usage of Resources (to expose the database state dynamically) and Prompts (to bundle complex workflows), continuing to hardcode them in `router.rs` will result in massive code duplication and tearup.
|
||||
|
||||
## 3. The Proposed Solution (Trait Extensibility)
|
||||
We will replicate the success of the `McpTool` trait by introducing `McpResource` and `McpPrompt` traits.
|
||||
|
||||
### A. MCP Resources
|
||||
**Trait Definition (`router.rs` or `resources.rs`):**
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait McpResource: Send + Sync {
|
||||
/// The exact URI the client requests (e.g. "memory://tasks/active")
|
||||
fn uri(&self) -> &'static str;
|
||||
|
||||
/// Human-readable name for the client UI
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Description for the client UI
|
||||
fn description(&self) -> Option<&'static str> { None }
|
||||
|
||||
/// Mime type of the content (usually "application/json" or "text/markdown")
|
||||
fn mime_type(&self) -> Option<&'static str> { Some("application/json") }
|
||||
|
||||
/// Retrieve the resource content
|
||||
async fn read(&self, state: Arc<MemoryState>) -> Result<String, String>;
|
||||
}
|
||||
```
|
||||
**Implementation:**
|
||||
* Add `pub resources: std::collections::HashMap<String, Box<dyn McpResource>>` to `MemoryHandler`.
|
||||
* In `handle_request("resources/list")`, iterate over `self.resources.values()` and build the JSON payload.
|
||||
* In `handle_request("resources/read")`, lookup the requested URI in `self.resources` and call `.read(state).await`.
|
||||
* Move the existing `memory://graph/entities` logic into its own handler struct.
|
||||
|
||||
### B. MCP Prompts
|
||||
**Trait Definition (`router.rs` or `prompts.rs`):**
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait McpPrompt: Send + Sync {
|
||||
/// The unique name of the prompt (e.g. "analyze_tech_debt")
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Description for the client UI
|
||||
fn description(&self) -> Option<&'static str> { None }
|
||||
|
||||
/// Schema or array defining arguments (can default to empty)
|
||||
fn arguments(&self) -> serde_json::Value { serde_json::json!([]) }
|
||||
|
||||
/// Execute the prompt and return the `messages` array payload
|
||||
async fn get(&self, args: Value, state: Arc<MemoryState>) -> Result<serde_json::Value, String>;
|
||||
}
|
||||
```
|
||||
**Implementation:**
|
||||
* Add `pub prompts: std::collections::HashMap<String, Box<dyn McpPrompt>>` to `MemoryHandler`.
|
||||
* In `handle_request("prompts/list")`, map over `self.prompts.values()`.
|
||||
* In `handle_request("prompts/get")`, call `.get(args, state).await`.
|
||||
|
||||
## 4. Execution Plan
|
||||
1. **Refactor `router.rs` (No functional changes yet):** Define the `McpResource` and `McpPrompt` traits. Update the `MemoryHandler` struct to hold these HashMaps. Migrate the existing hardcoded stubs (`memory://graph/entities` and `analyze_tech_debt`) into structs implementing these traits.
|
||||
2. **Expand Resources (Phase 1):** Add new handlers for `memory://tasks/active`, `memory://session/delta`, etc.
|
||||
3. **Expand Prompts (Phase 2):** Add new handlers for `handoff_routine`, etc.
|
||||
|
||||
This design guarantees we do not needlessly tear up code—we merely extend the existing robust `McpTool` pattern to the rest of the protocol.
|
||||
Generated
+20
@@ -2654,6 +2654,7 @@ dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tantivy",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
@@ -4342,6 +4343,19 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
@@ -5438,6 +5452,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# Effective Discourse: LLM Prompting Guide for MCP Memory
|
||||
|
||||
To get the most out of the Antigravity MCP Memory server and its advanced developer tools, use specific phrases that clearly state your intent. This guides the LLM to use the most efficient tools, reducing token consumption, speeding up time-to-resolve (T2R), and avoiding brute-force file reading.
|
||||
|
||||
---
|
||||
|
||||
## 1. Global Semantic Code Search & Navigation
|
||||
Instead of having the LLM use brute-force text searches or `grep` to find abstract logic, instruct it to use local vector embeddings.
|
||||
* **Don't say:** "Grep the codebase for database connection strings."
|
||||
* **Do say:** "Perform a semantic code search for how database connections are established."
|
||||
* **Tool Triggered:** `semantic_code_search`
|
||||
|
||||
---
|
||||
|
||||
## 2. Subgraph Expansion & Neighborhood Exploration
|
||||
When inspecting complex system interactions or module dependencies around a target component.
|
||||
* **Don't say:** "Tell me everything connected to the DatabaseTable node."
|
||||
* **Do say:** "Get the subgraph expansion around 'DatabaseTable' up to 2 hops."
|
||||
* **Tool Triggered:** `get_subgraph`
|
||||
|
||||
---
|
||||
|
||||
## 3. Automated Error Fix Suggestions
|
||||
When encountering build failures, runtime crashes, or stack traces.
|
||||
* **Don't say:** "Here is a stack trace, let's debug from scratch: [paste stack trace]"
|
||||
* **Do say:** "Suggest an error fix for this stack trace before we start debugging."
|
||||
* **Tool Triggered:** `suggest_error_fix`
|
||||
|
||||
---
|
||||
|
||||
## 4. Memory State Checkpointing & Safety Rollbacks
|
||||
Before initiating risky refactors or running experimental multi-step subagents.
|
||||
* **Don't say:** "Hope this refactor doesn't mess up our task board or memory graph."
|
||||
* **Do say:** "Checkpoint the memory state under 'pre-refactor' before we begin."
|
||||
* **Tool Triggered:** `checkpoint_state` / `restore_state`
|
||||
|
||||
---
|
||||
|
||||
## 5. Codebase Exploration & Token Efficiency
|
||||
When entering a new file, avoid having the LLM read the entire contents blindly.
|
||||
* **Don't say:** "Read server.rs and tell me what it does." *(Consumes massive tokens)*
|
||||
* **Do say:** "Extract the AST skeleton of server.rs to understand its structure first."
|
||||
* **Tool Triggered:** `read_file_skeleton`
|
||||
|
||||
---
|
||||
|
||||
## 6. Debugging & Log Parsing
|
||||
Stop copy-pasting giant walls of logs into the chat interface.
|
||||
* **Don't say:** "Here is the error: [paste 500 lines of logs]"
|
||||
* **Do say:** "The daemon crashed. Fetch the recent logs from daemon.log." or "Watch the process logs for server.log."
|
||||
* **Tool Triggered:** `process_logs` (action: "get", action: "watch")
|
||||
|
||||
---
|
||||
|
||||
## 7. Git & Context Handoff
|
||||
When you've been working independently and need to loop the LLM back in on your current state.
|
||||
* **Don't say:** "I changed some files, here are the diffs..."
|
||||
* **Do say:** "Get the active git worktree context to review my uncommitted changes before we continue."
|
||||
* **Tool Triggered:** `get_active_worktree_context`
|
||||
|
||||
---
|
||||
|
||||
## 8. Inter-Agent Signal Bus & Coordination
|
||||
When multiple subagents collaborate or background tasks complete.
|
||||
* **Don't say:** "Let me manually tell the ScrumMaster that testing finished."
|
||||
* **Do say:** "Broadcast an agent signal that unit tests passed and ping the PrePushAuditor."
|
||||
* **Tool Triggered:** `agent_signals` (action: "broadcast", action: "query")
|
||||
|
||||
---
|
||||
|
||||
## 9. Structural AST Editing
|
||||
When asking the LLM to modify complex files, prevent indentation bugs and regex failures by guiding it to use tree-sitter.
|
||||
* **Don't say:** "Search for `fn process()` and replace it with this string."
|
||||
* **Do say:** "Use the AST node replacer to swap out the `process` function in `server.rs`."
|
||||
* **Tool Triggered:** `replace_ast_node`
|
||||
|
||||
---
|
||||
|
||||
## 10. Bird's-Eye Repository Exploration
|
||||
When the LLM is first analyzing a repository, don't let it run `ls -R` and guess.
|
||||
* **Don't say:** "List the files in the directory and guess where the database code is."
|
||||
* **Do say:** "Read the directory architecture to get a summary of what each file is responsible for."
|
||||
* **Tool Triggered:** `read_directory_architecture`
|
||||
|
||||
---
|
||||
|
||||
## 11. Graph, Memory & Casing Standards
|
||||
Actively instruct the LLM to maintain its memory constraints and use canonical casing.
|
||||
* **Do say:** "Log this architectural decision in the knowledge graph using PascalCase for entity types."
|
||||
* **Do say:** "Leave a handoff memo with the test database credentials and session action items."
|
||||
* **Do say:** "Create a milestone for the 'Rich Clipboard' feature and break it down into active tasks."
|
||||
* **Tools Triggered:** `create_entities`, `decisions` (log), `handoff_memos` (leave), `milestones` (add), `tasks` (add)
|
||||
|
||||
---
|
||||
|
||||
## 12. Self-Healing Graph Maintenance
|
||||
* **Don't say:** "Search for duplicates and orphaned entities in the graph manually."
|
||||
* **Do say:** "Sweep graph health to identify orphaned entities and duplicate candidates."
|
||||
* **Tool Triggered:** `sweep_graph_health`
|
||||
|
||||
---
|
||||
|
||||
## 13. Causal Lineage & Provenance
|
||||
* **Don't say:** "Search all tasks, ADRs, and commits to explain why this file was changed."
|
||||
* **Do say:** "Query lineage for `server/src/state.rs` to construct a causal timeline."
|
||||
* **Tool Triggered:** `query_lineage`
|
||||
|
||||
---
|
||||
|
||||
## 14. Actionable Task Resolution
|
||||
* **Don't say:** "List all tasks and figure out which ones are blocked."
|
||||
* **Do say:** "Get the next actionable tasks to find unblocked work ready for execution."
|
||||
* **Tool Triggered:** `get_next_actionable_tasks`
|
||||
|
||||
---
|
||||
|
||||
## 15. Diagnostic Hypotheses & Reasoning Traces
|
||||
* **Don't say:** "Let's test three guesses and remember what we tried in chat."
|
||||
* **Do say:** "Log a hypothesis for this memory leak with tested evidence."
|
||||
* **Tool Triggered:** `hypotheses` (action: "log", action: "query")
|
||||
|
||||
---
|
||||
|
||||
## 16. State Checkpoints & Rollbacks
|
||||
* **Don't say:** "Save a backup snapshot before we do this refactor."
|
||||
* **Do say:** "Create a checkpoint named 'pre-refactor' before editing the database layer."
|
||||
* **Tool Triggered:** `manage_checkpoint`
|
||||
|
||||
---
|
||||
|
||||
By phrasing requests around *actions* rather than *information retrieval*, the LLM is primed to leverage the rich MCP toolset built into the Antigravity Memory Server.
|
||||
@@ -1,4 +0,0 @@
|
||||
# Role & Competency Constraints
|
||||
|
||||
- **Role**: Junior Developer.
|
||||
- **Discretion**: NO autonomous discretion allowed. The agent must operate strictly under the direct guidance of the user and must not make autonomous decisions or execute sweeping, unchecked actions.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Neovim UX Protocol & Live Editing
|
||||
|
||||
Whenever you need to actively interact with the user's Neovim UI or dynamically inject code edits into their live buffers, use the nvim_execute_lua tool (the "God Mode" escape hatch).
|
||||
Whenever you need to actively interact with the user's Neovim UI or dynamically inject code edits into their live buffers, use the nvim_exec tool (with action 'lua') (the "God Mode" escape hatch).
|
||||
|
||||
## 1. Showing UI Feedback (Agent Notifications)
|
||||
The user has a global Lua table _G.gemini loaded in their Neovim environment. You can use it to pop up a floating notification window when you are starting a background task.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
3. **Communication:**
|
||||
- The LLM requests to use a consolidated tool (e.g., `nvim_view` with action `goto_line`, or `nvim_execute_lua`).
|
||||
- The LLM requests to use a consolidated tool (e.g., `nvim_workspace` with action `focus`, or `nvim_exec`).
|
||||
- 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 `agy` CLI reads the response from `stdout` and returns it to the LLM context.
|
||||
@@ -29,12 +29,10 @@ The Antigravity CLI (`agy`) acts as the MCP Client and automatically manages the
|
||||
## Capabilities & Requirements
|
||||
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 7 cohesive domain tools:
|
||||
1. **`nvim_buffer`** (actions: `get_active`, `read`, `open`, `create_scratch`, `save`, `reload`, `close`, `list`, `search`)
|
||||
2. **`nvim_window`** (actions: `list`, `get_active`, `focus`, `split`, `close`)
|
||||
3. **`nvim_view`** (actions: `goto_line`, `get_cursor`, `get_viewport`, `get_selection`)
|
||||
4. **`nvim_diagnostics`** (actions: `get`, `set`, `set_quickfix`)
|
||||
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`)
|
||||
The MCP server provides 5 cohesive mega-tools:
|
||||
1. **`nvim_buffer`** (actions: `read`, `replace`, `save`, `undo`, `redo`, `create_scratch`)
|
||||
2. **`nvim_workspace`** (actions: `list_buffers`, `list_windows`, `focus`, `split`, `cwd`)
|
||||
3. **`nvim_intelligence`** (actions: `hover`, `definition`, `references`, `outline`, `query`, `diagnostics`, `rename`, `code_action`)
|
||||
4. **`nvim_ui`** (actions: `highlight`, `ghost_text`, `clear`)
|
||||
5. **`nvim_exec`** (actions: `lua`, `vimscript`, `terminal`)
|
||||
|
||||
+25
-24
@@ -3,32 +3,33 @@ name: nvim_editing
|
||||
description: Route code edits to Neovim buffers instead of disk/console when Neovim is running.
|
||||
trigger: always_on
|
||||
---
|
||||
# Neovim Code Editing
|
||||
# Neovim Code Editing (Agnostic & Universal)
|
||||
|
||||
## Never Spawn Headless Instances (CRITICAL)
|
||||
**NEVER** run `nvim <filename>` via bash/terminal commands (`run_command`) to "open" or "show" a file to the user. Because agent terminal commands run in the background, this spawns an invisible ghost process.
|
||||
These rules apply universally to ALL Neovim environments (Windows, WSL, or any remote server). Do not treat them as OS-specific.
|
||||
|
||||
If the user asks to "open", "show", or "see" a file in Neovim, you MUST use the `win-nvim` (Neovim) MCP Server to communicate with their *active* UI (e.g., using `nvim_execute_lua` with `vim.cmd('edit <file>')`).
|
||||
## 1. No Invisible Shell Edits (CRITICAL)
|
||||
**NEVER** use `sed`, `awk`, `python -c`, or `echo` to modify file contents from the terminal.
|
||||
File edits must use EXACTLY one of two paths:
|
||||
- **Active UI (Neovim MCP responsive):** Route edits through specialized MCP tools to provide live visual feedback.
|
||||
- **No UI (Neovim MCP unresponsive):** Fall back to the built-in `replace_file_content` tool to generate clean chat diffs.
|
||||
|
||||
When making code edits or displaying search results/outputs:
|
||||
1. Verify if the `win-nvim` (Neovim) MCP server is responsive.
|
||||
2. If Neovim is running, apply code changes directly into the relevant Neovim buffers using nvim_execute_lua (e.g., using vim.api.nvim_buf_set_lines or vim.cmd).
|
||||
3. DO NOT output large diffs or file contents to the chat console. Prefer live buffer manipulation over replace_file_content if the file is open in Neovim.
|
||||
4. **Fallback Constraint (CRITICAL):** If Neovim is NOT running, you MUST use the `replace_file_content` tool to edit files. This ensures the user is presented with a visual diff block in the chat. **NEVER** use `sed`, `awk`, or `echo` to blindly modify file contents from the terminal.
|
||||
## 2. Strict Tool Adherence (No Raw Lua RCE)
|
||||
You must strictly use the specialized, sandboxed Neovim MCP tools:
|
||||
- `nvim_buffer`: For reading, writing, saving, and creating scratch buffers.
|
||||
- `nvim_workspace`: For creating splits and focusing panes.
|
||||
- `nvim_ui`: For highlighting diffs, adding ghost text, and showing previews.
|
||||
**DO NOT** use `nvim_exec` (action `lua`) to mutate editor state. It is restricted to **READ-ONLY** queries.
|
||||
|
||||
## Visual Cues & Auto-Save Policy (MANDATORY)
|
||||
When manipulating buffers via Lua, you MUST provide visual feedback:
|
||||
1. **Notifications:** Always trigger a notification explaining what you did using `require("notify")("Antigravity edited [File]: [Summary]", "info", { title = "Antigravity" })` (since `rcarriga/nvim-notify` and `noice.nvim` are installed).
|
||||
2. **Buffer Focus:** Always switch the active Neovim window to the buffer you just edited (e.g., `vim.cmd('b ' .. bufnr)`).
|
||||
3. **Cursor Jump:** Move the cursor to the line you edited (`vim.api.nvim_win_set_cursor(0, {line, 0})`).
|
||||
4. **Auto-Save by Default:** You MUST save the buffer (`vim.cmd('write')`) after making edits so that external tools (tests, compilers, linters, git) can see the changes immediately and unattended tasks are not stalled.
|
||||
5. **Interactive Review Mode (Exception):** The ONLY time you should disable auto-save (leaving the buffer modified and unsaved) is when you are actively halting execution to seek manual user confirmation for a highly sensitive change. This allows the user to visually review the `gitsigns` diff in the gutter before manually saving (`:w`).
|
||||
## 3. Headless Quarantine
|
||||
Headless mode (`nvim --headless`) is strictly banned for interactive edits.
|
||||
**NEVER** run `nvim <filename>` via terminal commands to edit or "open" a file for the user (it spawns a zombie ghost process).
|
||||
Headless instances are allowed ONLY for non-interactive background batch processing (e.g., project-wide formatting or linting).
|
||||
|
||||
## Interactive Review & Automatic Wakeup (MANDATORY)
|
||||
When you modify a buffer and disable auto-save to allow the user to visually review the changes, you MUST NOT just wait idly for them to chat back.
|
||||
You MUST spawn a background task to listen for their :w save event:
|
||||
1. Use the ␍un_command tool to execute:
|
||||
curl -s "http://127.0.0.1:3000/events/wait?topic=nvim:save:$((Get-Item $File).FullName.Replace('\', '/'))&session_id=$SessionId"
|
||||
2. Set WaitMsBeforeAsync to a low value (e.g., 500ms) so it goes to the background immediately.
|
||||
3. Stop calling tools and end your turn.
|
||||
When the user saves the file, the curl command will complete and the system will automatically wake you up to proceed.
|
||||
## 4. UI Presentation & Chat Console Minimization
|
||||
Never output large plans, context blocks, or architectural discussions to the chat console if Neovim is running. You MUST use the `nvim_buffer` and `nvim_workspace` tools to open a vertical split (e.g., `Antigravity_Plan.md` scratch buffer) and present the markdown natively. Reserve the chat console strictly for brief confirmations.
|
||||
|
||||
## 5. Visual Cues & Auto-Save
|
||||
When manipulating buffers via MCP:
|
||||
1. **Focus & Jump:** Always switch the active Neovim window to the edited buffer and jump the cursor to the changed line.
|
||||
2. **Auto-Save:** Auto-save the buffer by default so linters/compilers see it immediately.
|
||||
3. **Interactive Review:** If pausing for human review (leaving unsaved), you MUST spawn a background `curl` task listening to `http://127.0.0.1:3000/events/wait?topic=nvim:save...` to wake you up when the user saves (`:w`).
|
||||
@@ -3,6 +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.
|
||||
|
||||
- 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 consolidated MCP tool registry (`nvim_buffer`, `nvim_window`, `nvim_view`, `nvim_diagnostics`, `nvim_visual`, `nvim_execute_lua`, `nvim_system`).
|
||||
- You must rely entirely on the consolidated MCP tool registry (`nvim_buffer`, `nvim_workspace`, `nvim_intelligence`, `nvim_ui`, `nvim_exec`).
|
||||
- If the tool is eagerly loaded, use it natively as an agent tool. If lazy-loaded, invoke it via the `call_mcp_tool` mechanism.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Rule 1: Network Connectivity Testing
|
||||
|
||||
- **Mandatory Tooling**: When testing ANY network connectivity, you MUST use the exact same Rust crates used in the production code.
|
||||
- **Methodology**: You must create standalone Rust applications (e.g., small test binaries or examples in the repository) that utilize the exact same networking plumbing as the main application.
|
||||
- **Forbidden Tools**: You CANNOT use bash (e.g., curl, netcat), Python, Java, Perl, wscat, or any other external scripting/CLI tools to validate network connectivity. Testing with these tools creates false positives and bypasses the actual networking crates causing issues.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Rule 2: Empirical Evidence & Logging
|
||||
|
||||
- **Mandatory Logging**: If an issue occurs and there are no logs (or insufficient logs) to diagnose it, you must STOP immediately and add logging.
|
||||
- **Replication**: After adding logging, you must repeat the test to replicate the exact issue so that the logs capture the failure.
|
||||
- **No Blind Changes**: NO business logic can be altered blindly or based on guesses. You must have empirical evidence (derived from the logs) proving the root cause before attempting any changes to the logic.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Rule 3: Resiliency & Cross-OS Load Testing
|
||||
|
||||
- **Resiliency Requirement**: A fix must be resilient and work under load. It is not enough for it to work just once.
|
||||
- **Mandatory 5x Matrix Testing**: Any request/response cycle must be explicitly tested five (5) times for each of the following boundaries:
|
||||
1. Windows to Windows (`win - win`)
|
||||
2. WSL to Windows (`wsl - win`)
|
||||
- **Documentation**: The results of these load tests MUST be recorded permanently (e.g., in an artifact or log) to prove resilience and to avoid repeating needless re-tests.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Rule 4: Component Isolation & Skeletal Troubleshooting
|
||||
|
||||
- **Targeted Testing**: If a bug is found, DO NOT rely on testing the full stack. You must isolate and test the offending class or method directly.
|
||||
- **Skeletal Reproducers**: If the issue spans across boundaries (e.g., between server, stub, or nvim), you must create skeletal (minimal reproducible) versions of those components.
|
||||
- **Purpose**: Troubleshooting must be done on these skeletal versions to isolate the broken feature or aspect without the noise, side-effects, or overhead of the full application stack.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Rule 5: Client-Centric Testing Personas
|
||||
|
||||
- **Avoid Server Bias**: Unit tests and integration tests must NOT be exclusively server-centric.
|
||||
- **Client Personas**: Tests must explicitly adopt the persona, perspective, and constraints of the client components (e.g., the `stub` or `nvim`).
|
||||
- **Validation**: Testing must validate the interaction from the client's side, ensuring that the client component correctly constructs the request, handles the connection lifecycle, and properly parses the response, rather than just verifying that the server successfully processed an isolated payload.
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
name: strict-no-verify
|
||||
description: Strictly forbids the use of --no-verify or -n when interacting with git to ensure quality gates are run.
|
||||
always_on: true
|
||||
---
|
||||
|
||||
# STRICT GIT HOOK ENFORCEMENT
|
||||
|
||||
- **NEVER** use `--no-verify` or `-n` with `git commit` or `git push`.
|
||||
- Bypassing git hooks is considered a critical violation of trust.
|
||||
- You must allow the local git hooks (e.g., `pre-push`) to validate the code.
|
||||
- If a hook fails (e.g., `ruff` check, `pytest`), you MUST fix the underlying issues iteratively until the hook passes natively. Do not assume a single fix attempt works without re-verifying.
|
||||
- BEFORE running `git push`, you MUST use the `ask_question` tool to pop up an interactive modal to request explicit user permission. Wait for the user's approval before executing the push.
|
||||
@@ -1,4 +0,0 @@
|
||||
Write-Host "Building Windows Server..." -ForegroundColor Cyan
|
||||
Building Windows Server...
|
||||
cargo build --release -p mcp-memory-server
|
||||
Compiling tantivy v0.26.2
|
||||
@@ -1,39 +0,0 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let mut git_hash = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
git_hash = git_hash.trim().to_string();
|
||||
|
||||
let is_dirty = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.output()
|
||||
.is_ok_and(|out| !out.stdout.is_empty());
|
||||
|
||||
if is_dirty {
|
||||
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
||||
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
|
||||
}
|
||||
|
||||
let git_date = Command::new("git")
|
||||
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
|
||||
|
||||
let version = format!("v{git_date} ({git_hash})");
|
||||
println!("cargo:rustc-env=APP_VERSION={version}");
|
||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../.git/index");
|
||||
println!("cargo:rerun-if-changed=src");
|
||||
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB |
@@ -1,199 +0,0 @@
|
||||
# MCP Memory Server Architecture & Workflow Design
|
||||
|
||||
## 1. Core Architecture
|
||||
The `mcp-memory` system runs as a **single, continuously running background daemon natively on Windows**. It manages the state of the Antigravity knowledge graph and serves as a universal backend for both Windows and WSL environments.
|
||||
|
||||
### Why this design?
|
||||
* **Cross-OS I/O Optimization:** Prevents the WSL agent from performing slow, heavy filesystem writes against the mounted Windows `C:\` drive.
|
||||
* **Concurrency & Locking:** A single daemon holds the lock on the `mcp_memory` JSON files, preventing data corruption and eliminating complex delta-reconciliation between parallel WSL and Windows processes.
|
||||
* **Dual Transport System:** Utilizes both `stdio` and HTTP transports concurrently. The local Windows `agy` instance connects natively via standard `stdio`, while the Axum HTTP server provides synchronous, non-blocking access for WSL clients and external scripts.
|
||||
|
||||
## 2. Server Transport & Endpoints (Axum)
|
||||
The daemon uses the `axum` and `rust-mcp-axum` crates, binding to `0.0.0.0:3000` to serve the network.
|
||||
|
||||
### Standard MCP Endpoints
|
||||
* `GET /ws`: The WebSocket endpoint for efficient, low-latency, full-duplex JSON-RPC communication (preferred for proxies and the UI dashboard).
|
||||
* `GET /sse`: The Server-Sent Events (SSE) endpoint. Antigravity clients connect here to keep a one-way pipe open for receiving pushed responses.
|
||||
* `POST /messages`: The JSON-RPC endpoint. Clients use this to send tool calls and resource reads up to the server when connected via SSE.
|
||||
|
||||
### Dashboard REST API
|
||||
The server hosts a rich Single Page Application (SPA) natively on the root route, backed by a suite of REST endpoints:
|
||||
* `GET /`: The Brain Monitor live HTML dashboard SPA.
|
||||
* `GET /api/graph`: Returns the full node/edge topology for the interactive physics-simulated canvas.
|
||||
* `GET /api/tasks` & `POST /api/tasks/{id}/complete`: Drives the actionable Kanban board.
|
||||
* `GET /api/ledger` & `GET /api/search`: Powers the code change ledger and global fuzzy search UI.
|
||||
* `GET /api/stats`: Returns a JSON snapshot of current entity, relation, task, and tech debt counts.
|
||||
|
||||
### Git & IDE Integration Endpoints
|
||||
* `UDP /nvim/telemetry`: A one-way connectionless datagram listener that Neovim instances hit on `FocusGained` or `BufEnter` to globally broadcast the user's active file to the UI and Agent.
|
||||
* `GET /gate/verify`: A lightweight, deterministic endpoint used by external scripts (like a `git push` wrapper) to verify if an action is authorized based on the current state.
|
||||
|
||||
## 3. Client Connections
|
||||
The Antigravity configurations (`mcp_config.json`) utilize the dual-transport system:
|
||||
* **Windows `agy`:** Spawns and connects to the local daemon natively via `stdio` subprocess execution.
|
||||
* **WSL (Linux) `agy`:** Connects to the running Windows HTTP daemon via the host network proxy, e.g., `http://127.0.0.1:3000/sse`.
|
||||
|
||||
## 4. Git Integration (Global Wrapper)
|
||||
Instead of relying on localized per-repository Git hooks (like `.git/hooks/pre-push`), the system leverages a **global bash/PowerShell alias wrapper** for the `git` command. This intercepts `git` commands universally across the OS.
|
||||
|
||||
### Workflow Example (Push Safety Gate)
|
||||
1. The user types `git push`.
|
||||
2. The global wrapper intercepts the command.
|
||||
3. It makes a synchronous HTTP request to the local daemon: `curl -s http://localhost:3000/gate/verify`.
|
||||
4. If the endpoint returns `200 OK` (indicating the `PrePushAuditor` subagent has verified that unit tests pass and history is squashed), the push proceeds.
|
||||
5. If not `200 OK`, the wrapper blocks the push and alerts the user to fix tests or run `gsquash`.
|
||||
|
||||
### Benefits of the Wrapper Approach
|
||||
* **Universal Enforcement:** The push safety gate is protected across all repositories automatically, without copying hook scripts.
|
||||
* **No File Locks:** Uses safe, lightweight, parallelizable HTTP requests rather than executing the Rust binary directly.
|
||||
* **Action Logging:** The wrapper can be seamlessly extended to log actions (like `checkout` or `commit`) directly into the knowledge graph in real-time.
|
||||
## 5. Storage Architecture & Persistence (Redb LSM-Tree)
|
||||
The daemon has completely eliminated raw JSON file sprawl and fragmented delta-file reconciliation. It now utilizes a pure-Rust, embedded Key-Value engine (`redb`) that implements a robust Log-Structured Merge-Tree (LSM-tree) architecture.
|
||||
|
||||
### Key Principles:
|
||||
* **Embedded Database Engine:** All structured components (Tasks, Snippets, Tech Debt, Checklists, etc.) are stored as binary-encoded values inside a unified `redb` database file (`store.redb`).
|
||||
* **ACID Compliance & File Locks:** The Windows daemon holds an exclusive read-write lock on the database file, guaranteeing zero data corruption, race conditions, or lock contention during concurrent access.
|
||||
* **Atomic Write-Guard Scope:** Store modification methods (`Store::modify` and `Store::modify_async`) retain the write lock through both the in-memory mutation and JSON serialization phases, eliminating lock-release TOCTOU race conditions.
|
||||
* **Store Quarantine Mode:** If deserialization fails during `Store::load_from_db`, the store flags `is_corrupted = true` and refuses to overwrite database keys with default values on subsequent writes.
|
||||
* **Asynchronous Checkpointing:** The core Knowledge Graph (Entities, Relations, Observations) still utilizes a Write-Ahead Logging (WAL) pattern (`wal.jsonl`) and a master snapshot (`master.json`) to allow safe, lock-free memory mutations which are reconciled in the background.
|
||||
|
||||
## 6. Domain Models & Component Stores
|
||||
The system leverages a modular, thread-safe generic `Store<T>` abstraction that automatically transparently serializes and deserializes native Rust structs directly into the underlying `redb` tables.
|
||||
Currently implemented persistent stores include:
|
||||
* **Audit Ledger & Tasks:** Tracks agent actions and active background tasks.
|
||||
* **Context & Handoffs:** Session Summaries, Handoff Memos, Hypotheses, and Agent Signals.
|
||||
* **Engineering Tracking:** ADRs (Architecture Decision Records), Snippets, Error Fixes, Tech Debt, and PR Checklists.
|
||||
* **Environment State:** Pinned Files, Env Fingerprints, Milestones, and Environments.
|
||||
* **Safety Gates:** Authorized execution gates (Push Safety).
|
||||
|
||||
## 7. Full-Text & Semantic Search Engine (Tantivy + FastEmbed)
|
||||
To support blazing-fast, intelligent semantic retrieval across the sprawling knowledge graph, the daemon embeds **Tantivy** (a full-text search engine inspired by Apache Lucene) alongside **FastEmbed** (a local ONNX runtime for vector embeddings).
|
||||
* **The `MemoryIndex`:** Whenever the graph or auxiliary stores mutate, a background thread dynamically rebuilds the Tantivy index (`tantivy_index/` dir) and computes semantic vectors.
|
||||
* **Pre-cached Vector Embeddings:** `SearchService::semantic_search` reuses pre-cached snippet embedding vectors (`snippet.embedding`), bypassing redundant ONNX neural network inference calls during query execution.
|
||||
* **Global Omni-Search:** This architecture powers the `omni_search` tool, allowing subagents to instantly fuzzy-search and semantically rank documents across Entities, Tasks, Snippets, Error Fixes, and ADRs simultaneously in milliseconds, without loading massive JSON arrays into RAM.
|
||||
|
||||
## 8. Webhook Telemetry & Passive Ingestion
|
||||
The server features a suite of webhook listeners that passively ingest development activity to build context without requiring human copy-pasting:
|
||||
* **Terminal Ingestion (`/terminal/telemetry`):** Shell hooks (PowerShell/Nushell) silently POST command execution history and exit codes, allowing agents to read recent stack traces via the `memory://terminal/recent` resource.
|
||||
* **IDE Telemetry (`/nvim/telemetry`):** A connectionless UDP datagram listener receives focus events from Neovim, broadcasting the user's active file to the UI and Agent instantly.
|
||||
|
||||
## 9. The Gate System (Push Safety Verification)
|
||||
The binary supports a flexible safety gate authorization system, accessible both via the HTTP API and CLI subcommands.
|
||||
* **API / CLI set**: Records an authorization status (`authorized`, `blocked`, or `pending`) for a specific target and namespace. Can be invoked via `POST /gate/set` (JSON) or `mcp-memory-stub gate set`.
|
||||
* **API / CLI verify**: Evaluates a pending action against the gate store. It returns standard HTTP status codes (`200 OK`, `403 Forbidden`, `404 Not Found`) via `GET /gate/verify?action=...` or POSIX exit codes (0, 1, 2) via `mcp-memory-stub gate verify`. Both support a `consume` parameter/flag to immediately revoke the authorization after a successful check.
|
||||
|
||||
## 9. Operational Configuration & Paths
|
||||
The physical storage location of the knowledge graph and all persistent stores is strictly controlled by the MCP_MEMORY_STORE_DIR environment variable.
|
||||
* **Default Path:** If not set, the daemon defaults to ~/.gemini/mcp_memory.
|
||||
* **Port Binding:** The Axum HTTP server strictly binds to .0.0.0:3000.
|
||||
|
||||
## 10. Exposed MCP Capabilities (Tools)
|
||||
The server implements the Model Context Protocol (MCP) by exposing a vast suite of tools via the JSON-RPC interface, categorized broadly into:
|
||||
* **Graph Management:** create_entities, create_relations, merge_entities,
|
||||
read_graph, etc.
|
||||
* **Task & Context Tracking:** tasks, milestones, handoff_memos, hypotheses, agent_signals, process_logs, add_session_summary, etc.
|
||||
* **Engineering & DevOps:** log_code_change, log_error_fix, tech_debt, decisions, etc.
|
||||
* **Environment & Workspaces:** environment, manage_checkpoint, manage_subagent_namespace, snippets, etc.
|
||||
|
||||
|
||||
## 11. Concurrency & Thread Safety
|
||||
With the introduction of the Dual Transport System, the daemon must safely handle simultaneous read/write requests from both Stdio (Windows agy) and HTTP (WSL agy) clients.
|
||||
* **Shared State:** The entire server operates on a cloned Arc<MemoryState>.
|
||||
* **Locking Mechanism:** The unified Knowledge Graph and modular Store<T> components are protected by RwLock primitives.
|
||||
* **Safe Mutation:** Store modifications utilize closure-based modify(|store| { ... }) methods to ensure locks are safely acquired, mutations applied, and file writes executed sequentially without deadlocking the asynchronous Tokio runtimes.
|
||||
|
||||
## 12. Lifecycle & Startup Management (Server/Stub Architecture)
|
||||
The mcp-memory daemon employs a strict Server/Client paradigm to maintain separation of concerns. There are no dual roles or morphing executables.
|
||||
|
||||
### The Windows Background Daemon (`mcp-memory-server.exe`)
|
||||
The server executable is responsible exclusively for running the Axum HTTP and WebSockets daemon and managing the Knowledge Graph. It does not contain any proxy logic. If port 3000 is already in use by an existing server instance, it gracefully exits instead of attempting to run.
|
||||
|
||||
* **Non-Blocking Startup & Indexing:** Upon launch, the server uses a `tokio::spawn` task to rebuild the Tantivy index in the background. This ensures the Axum HTTP server binds immediately to port 3000, allowing instantaneous liveness checks via the `/ping` endpoint without waiting for the synchronous I/O operations of the index rebuild to complete.
|
||||
* **Robust Background Detachment:** To ensure the server survives the death of the parent shell that spawns it (such as a deployment script), it uses Windows `WScript.Shell` (via `start_server.ps1`) to spawn the binary with a strictly hidden window style (`SW_HIDE`). This completely detaches it from the parent process tree, preventing `CTRL_CLOSE_EVENT` signals from killing the background daemon when the deploying terminal closes.
|
||||
|
||||
### The Client Proxy (`mcp-memory-stub.exe`)
|
||||
All Antigravity sessions (Windows and WSL) use the lightweight `mcp-memory-stub` as their proxy. The stub connects to the server via WebSockets and acts as the bridge for standard `stdio` JSON-RPC traffic.
|
||||
* **Startup via Interop/Spawn:** Upon launch, the stub attempts to connect to the Windows host on port 3000. If the server is offline, the stub automatically executes a spawn command (e.g., executing `Start-Process` natively, or via WSL interop) to silently wake up the Windows Leader before commencing the proxy loop.
|
||||
* **MPSC Queue Resilience:** The stub utilizes an asynchronous multi-producer, single-consumer (MPSC) channel queue. If the Windows Leader daemon restarts or momentarily drops, the proxy buffers incoming JSON-RPC tool calls and infinitely retries them until the connection is restored. This guarantees **zero message loss** and **zero thread leaks** without crashing the active `agy` session.
|
||||
* **Zero I/O Penalty (WSL):** This ensures the Linux binary never directly touches the Windows NTFS files, reserving all heavy disk operations for the native Windows host.
|
||||
## 13. Cargo Workspace & Binary Artifacts
|
||||
To optimize for different environments, the codebase is structured as a Cargo Workspace containing two distinct crates:
|
||||
|
||||
### 1. mcp-memory-server (The "Full-Fat" Daemon)
|
||||
* **Path:** server/
|
||||
* **Size/Complexity:** Heavy (contains Axum, MCP SDK, JSON parsing, Tokio runtime).
|
||||
* **Role:** This is the primary background daemon. It binds to .0.0.0:3000, holds file locks, and manages the graph.
|
||||
* **Windows Behavior:** It is designed to run in the background as a standalone service.
|
||||
|
||||
### 2. mcp-memory-stub (The Ultra-Lightweight Proxy)
|
||||
* **Path:** stub/
|
||||
* **Size/Complexity:** Extremely light (only relies on
|
||||
eqwest and okio).
|
||||
* **Role:** A dedicated, OS-agnostic proxy binary used strictly for routing stdio JSON-RPC traffic over HTTP to a remote Leader. Windows gy clients point directly to this binary to bypass loading the heavy Server daemon into memory.
|
||||
* **WSL Behavior:** Compiled as a Linux native binary (x86_64-unknown-linux-musl). When executed by WSL agy, it acts as a transparent proxy to http://127.0.0.1:3000. It can also execute wake_cmd (e.g., WSL interop) to silently wake the Windows host if the Leader is offline.
|
||||
|
||||
|
||||
|
||||
## 14. Neovim Integration Architecture & "God Mode"
|
||||
To enable seamless pair-programming inside Neovim, the daemon integrates with Neovim using two complementary systems: a Webhook Telemetry pipeline and dedicated MCP binaries.
|
||||
|
||||
### Telemetry Pipeline (Last Focused Wins)
|
||||
A lightweight Lua script (`gemini-integration.lua`) is loaded into Neovim, which fires an asynchronous UDP datagram to `127.0.0.1:3002` (fire-and-forget via native libuv) whenever the user focuses a buffer or moves the cursor. The server then writes this data (including `session_id`, `file`, `line`, and `col`) to both the Windows and WSL `active_nvim.txt` files and broadcasts it over WebSockets.
|
||||
|
||||
### Native MCP Binaries (win-nvim & linux-nvim)
|
||||
The project compiles two standalone, highly-performant binaries that implement the MCP JSON-RPC protocol over Stdio and bridge it directly to Neovim's Msgpack-RPC Named Pipes/Sockets. These binaries avoid hardcoding infinite tools by utilizing a "God Mode" escape hatch.
|
||||
|
||||
Exposed Neovim Tools:
|
||||
* **
|
||||
vim_get_active_buffer &
|
||||
vim_get_cursor**: Read file state.
|
||||
* **
|
||||
vim_goto_line &
|
||||
vim_set_diagnostics**: Manipulate IDE state.
|
||||
* **
|
||||
vim_get_visual_selection**: Read exact highlight coordinates (handles mode dynamically).
|
||||
* **
|
||||
vim_list_buffers**: Discover unsaved work and context.
|
||||
* **
|
||||
vim_get_diagnostics**: Read live LSP errors dynamically instead of requiring a compiler.
|
||||
* **
|
||||
vim_execute_lua ("God Mode")**: The ultimate fallback tool. Evaluates raw Lua scripts inside the active Neovim instance and returns JSON. This prevents the need to continuously recompile the Rust server whenever a new Neovim capability is required.
|
||||
|
||||
## 15. Build & Deployment Strategy
|
||||
Because the background server operates as an always-on Windows daemon, standard recompilation and file-copying strategies will fail due to active Windows OS file locks.
|
||||
|
||||
### Randomized Lock Bypassing
|
||||
The \uild.ps1\ deployment pipeline intercepts locked \.exe\ files by appending a unique, timestamped/randomized suffix (e.g., \mcp-memory-server.exe.12345.old\) when forcing a \Move-Item\. This guarantees that rapid sequential deployments (where a previous \.old\ file might still be locked by a zombie process) never silently fail or collide.
|
||||
|
||||
### Dynamic Versioning
|
||||
To trace binary provenances during rapid deployment cycles, all binaries embed dynamic versioning directly at compile time (via \uild.rs\ and \uild_template.rs\). The injected \APP_VERSION\ environment variable combines the static Cargo \ersion\ with the live \git\ short hash and UTC timestamp, allowing the CLI \--version\ commands and the HTTP \/api/version\ endpoints to guarantee exactly which iteration of the code is actively executing.
|
||||
## 16. Testing Architecture (Native Rust E2E)
|
||||
Historically, the project relied on a complex Python testing suite (\pytest\ + \mcp_client.py\) to validate the server over HTTP/SSE. This has been fully deprecated in favor of **Native Rust End-to-End Testing**.
|
||||
* **Unit Tests:** Handlers and business logic are tested directly inside \server/src/handlers.rs\ using native \ okio::test\ constructs.
|
||||
* **E2E Tests:** Integration and full-system tests run via \stub/tests/e2e.rs\ and \win-nvim/tests/integration_test.rs\, ensuring type safety, faster execution, and eliminating Python environment dependencies.
|
||||
## 17. Automated Git Context Binding (git2)
|
||||
Instead of forcing the LLM client to manually run `git rev-parse HEAD` and pass `git_branch` / `git_commit` arguments for every single code change, the server integrates the native **`git2`** C bindings.
|
||||
When engineering endpoints (`log_code_change`, `log_error_fix`, `log_tech_debt`) are invoked, the server asynchronously discovers the surrounding Git repository, peels the HEAD reference, and automatically injects the current commit hash, commit message, and branch name directly into the stored entities and audit logs. This guarantees airtight VCS traceability without wasting LLM tokens or relying on the agent's memory.
|
||||
|
||||
## 18. Upcoming Enhancements
|
||||
|
||||
### A. Graph Summarization & Decay
|
||||
To prevent context window bloat, the Redb engine will enforce TTLs on ephemeral nodes (`StickyNotes`, minor `Tasks`). Furthermore, an `archive_routine` prompt will allow subagents to automatically compress older `SessionSummaries` into dense milestone retrospectives.
|
||||
|
||||
### B. Proactive Subagent Triggers
|
||||
The daemon will eventually gain the ability to autonomously spawn background subagents (like the `BugDiagnostician`) when specific events are detected in the webhook telemetry, rather than relying strictly on the human user to initiate the agent.
|
||||
|
||||
## 11. Advanced Clipboard Capabilities
|
||||
The memory server bypasses typical cross-OS Linux/Windows clipboard restrictions by proxying operations from the WSL stub back to the Windows Axum host.
|
||||
It supports reading and writing rich formats natively to the Windows Host OS using the clipboard-win and rboard crates:
|
||||
* **Plain & Rich Text:** CF_UNICODETEXT and CF_HTML are supported for rich copying/pasting.
|
||||
* **File Drops (CF_HDROP):** The server can parse file lists copied from Windows Explorer, and can inversely synthesize file drops into the clipboard from absolute paths.
|
||||
* **Images (CF_BITMAP):** The server natively rasterizes clipboard bitmaps to JPEG on read, and can write raw RgbaImage buffers back to the clipboard on write.
|
||||
* **Developer Tooling:** read_file_skeleton (AST), get_active_worktree_context (Git), get_recent_logs, toggle_clipboard_watch_mode.
|
||||
|
||||
## 19. High-Performance Concurrency & Resilience Guarantees
|
||||
* **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.
|
||||
* **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.
|
||||
* **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.
|
||||
@@ -10,18 +10,6 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
|
||||
if #vim.api.nvim_list_uis() > 0 then
|
||||
local server_name = vim.v.servername
|
||||
if server_name then
|
||||
-- 1. Legacy Disk Write (Fallback)
|
||||
local home = os.getenv("HOME") or os.getenv("USERPROFILE")
|
||||
if home then
|
||||
os.execute("mkdir -p " .. home .. "/.gemini")
|
||||
local path = home .. "/.gemini/active_nvim.txt"
|
||||
local f = io.open(path, "w")
|
||||
if f then
|
||||
f:write(server_name)
|
||||
f:close()
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. V2 Telemetry Push (HTTP with Knowledge Projection + UDP Fast Mirror)
|
||||
local file = vim.api.nvim_buf_get_name(0)
|
||||
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ The server registers 5 high-signal workflow prompts to initiate standardized age
|
||||
|
||||
## 5. Consolidated Smart Tools Architecture (11 Primary Tools)
|
||||
|
||||
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.
|
||||
|
||||
* **`tasks`**: Complete task lifecycle management.
|
||||
- `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`).
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
rebuild_index: found 229 entities, 24 tasks
|
||||
spawn_blocking started in rebuild_index
|
||||
add_task_sync called for task: 8da665f7-107c-41c8-b203-d39a9ff1f9a5
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 01a788f0-7adb-456c-bf37-02bbd7e1d2f8
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 5e8d9b26-ae31-4136-a4c4-6ead74887bb5
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: ed184dd6-55ce-4d37-a06a-b34733795e03
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 3cea859d-c1ce-4f6c-bdc5-6eb65dbd8e4f
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 62446699-39b3-496b-9af8-99b331fe6c08
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 4b0e2179-1e70-4b64-9c2d-ff2fe2d05fdf
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 16dd35e4-eadd-4597-a5b6-9844852cfb76
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: c85f352b-f15b-4f90-a6ae-18fe914212b2
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: d1e907a2-89a2-4814-8722-22c754cc943c
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: c382a511-a5d6-4114-ad2c-f09fdd36d66a
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 42ca3765-5c69-4cdd-8324-5a174c34d4ae
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 05792eaa-4bc5-45e5-b9a0-d9027b3b3974
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 15c9fd58-8d8f-4c2f-80f4-15a46ba54588
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 9b55e54c-ac1b-4219-acf3-68f7cb34a1fd
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: a04923ea-6abf-41ba-9c45-f7d9cdac3440
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 4233293e-6d93-4c46-b9aa-f9e712af0e86
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 93451cd9-25c5-4323-8dce-79bba124242d
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 949f1d59-e1e2-421c-9682-43a79f41679b
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: cd680df7-2e1a-4486-bb9b-3881476c762e
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 561daa2b-ddb5-44ae-8fb8-8155405759fb
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: e36b0eba-aa79-493f-b89b-3da46a44fa11
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: a3a717b3-438a-42a7-83ec-2f2eb5d775b4
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 6a1b05d5-0ffd-4b0c-bb65-f45e0cb5540a
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
@@ -23,7 +23,7 @@ pub async fn read_mcp_message<R: tokio::io::AsyncRead + Unpin>(
|
||||
break;
|
||||
}
|
||||
|
||||
if trimmed_line.as_bytes().len() >= 15
|
||||
if trimmed_line.len() >= 15
|
||||
&& trimmed_line.as_bytes()[..15].eq_ignore_ascii_case(b"content-length:")
|
||||
{
|
||||
length = trimmed_line[15..].trim().parse().unwrap_or(0);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# MCP Tools Review & Enhancement Strategy
|
||||
|
||||
## Part 1: Current Arsenal Review
|
||||
|
||||
Our current MCP ecosystem is highly advanced, utilizing a **Dual-Transport Leader/Stub Architecture** (Windows Host + WSL Proxy) to completely eliminate cross-OS I/O latency.
|
||||
|
||||
### 1. Context & Token Optimization
|
||||
* `read_file_skeleton`: Highly effective. Uses tree-sitter to extract ASTs (Rust, Python, TS). **Score: A+ (Massive token savings)**
|
||||
* `get_active_worktree_context`: Native git2 integration. Bypasses shell parsing for clean JSON diffs. **Score: A**
|
||||
* `process_logs`: Direct file seeking and daemon log management (`watch`, `get`, `clear`). Prevents LLMs from reading multi-megabyte log files. **Score: A**
|
||||
|
||||
### 2. Neovim IDE Integration (nvim-core)
|
||||
* `nvim_buffer`, `nvim_window`, `nvim_view`, `nvim_diagnostics`, `nvim_visual`, `nvim_execute_lua`, `nvim_system`.
|
||||
* **Review:** Exceptional human QoL. The agent interacts with the code where the human's eyes actually are. Ghost text and diagnostic extmarks provide an IDE-like experience usually reserved for closed-source tools like Cursor. **Score: S-Tier**
|
||||
|
||||
### 3. Clipboard & Workflow
|
||||
* `clipboard` (`read`, `write`).
|
||||
* **Review:** Native cross-OS clipboard-win and arboard implementation with on-demand image grab. Bridges the gap between manual human research and the agent's context. **Score: A**
|
||||
|
||||
### 4. Graph & Memory Management
|
||||
* `create_entities`, `hypotheses`, `agent_signals`, `handoff_routine`.
|
||||
* **Review:** Solid foundation for state persistence across branches and days. **Score: A**
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Proposed Enhancements (Focus: T2R, Token Cost, QoL)
|
||||
|
||||
To push the system to the absolute bleeding edge of autonomous coding, I propose the following 5 new tools/enhancements.
|
||||
|
||||
### 1. `replace_ast_node` (Robust Structural Editing)
|
||||
* **The Problem:** Standard text replacement uses exact string matching and line numbers. Line numbers change when humans edit simultaneously, and string matching fails on whitespace/indentation.
|
||||
* **The Solution:** An MCP tool that takes `(file_path, node_type, node_name, new_content)`. It uses tree-sitter to find the exact boundary of `fn execute(...)` and replaces just that AST node.
|
||||
* **Impact:** Zero LLM syntax/indentation errors. 100% robust edits. Drastically lowers Time-to-Resolve (T2R) by eliminating failed edit loops.
|
||||
|
||||
### 2. `semantic_code_search` (Local Vector Embeddings)
|
||||
* **The Problem:** Text search relies on exact regex. If the LLM guesses the wrong variable name, it wastes tokens searching and reading the wrong files.
|
||||
* **The Solution:** Using Tantivy and BERT embeddings in our backend. We index the AST blocks of the codebase in the background. The LLM can query *"Where is the auth token validated?"* and get the exact 3 relevant functions instantly.
|
||||
* **Impact:** Massive token cost reduction (no blind file reading). Instant T2R for codebase exploration.
|
||||
|
||||
### 3. `nvim_system` terminal execution (Interactive Execution QoL)
|
||||
* **The Problem:** When the agent runs a background terminal command (`cargo build`, `npm run dev`), the output is hidden from the human, and interactive prompts cause the background task to hang indefinitely.
|
||||
* **The Solution:** Dispatch to Neovim terminal splits where the human can watch the tests run natively, interact with prompts, see ANSI colors, and interact seamlessly.
|
||||
* **Impact:** Massive Human QoL.
|
||||
|
||||
### 4. `read_directory_architecture` (Bird's-Eye View)
|
||||
* **The Problem:** Single file inspection works for one file. When entering a new repository, the LLM usually runs `ls -R` and then has to guess what files do based on their names.
|
||||
* **The Solution:** A tool that scans a directory structure and returns a clean hierarchical tree alongside summaries of what each directory and key file is responsible for.
|
||||
* **Impact:** Immediate holistic context. Eliminates the "exploration phase" token tax.
|
||||
|
||||
### 5. `query_database_schema` (Introspection)
|
||||
* **The Problem:** Working with databases usually involves the LLM writing clunky scripts to view table definitions, which often fail due to missing env vars or wrong dialects.
|
||||
* **The Solution:** A direct MCP tool that parses the local `.env`, connects to the database (PostgreSQL), and returns a clean Markdown representation of the schema (Tables, Columns, Types, Foreign Keys).
|
||||
* **Impact:** Prevents hallucinations about database structure. Fixes DB-related bugs significantly faster (T2R).
|
||||
@@ -2,48 +2,22 @@
|
||||
|
||||
When connected to this Neovim MCP server (`win-nvim`), you have powerful tools to interact directly with the active Neovim editor.
|
||||
|
||||
## The Consolidated Tool Arsenal (v2)
|
||||
The server consolidates granular Neovim operations into 7 smart domain tools:
|
||||
- **`nvim_buffer`**: Buffer and file management.
|
||||
- `action: "open_file"`: Open file in buffer (args: `file`, `line`, `col`).
|
||||
- `action: "open"`: Open buffer (args: `bufnr`).
|
||||
- `action: "close"`: Close buffer (args: `bufnr`, `force`).
|
||||
- `action: "reload"`: Reload buffer from disk (args: `bufnr`).
|
||||
- `action: "save"`: Save buffer to disk (args: `bufnr`).
|
||||
- `action: "list"`: List all loaded buffers.
|
||||
- **`nvim_window`**: Window split and focus management.
|
||||
- `action: "split"`: Split window (args: `direction: "horizontal" | "vertical"`, `file`).
|
||||
- `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.
|
||||
## The Consolidated Tool Arsenal (v3)
|
||||
The server consolidates granular Neovim operations into 5 smart mega-tools:
|
||||
- **`nvim_buffer`**: Buffer and file management. Actions: `read`, `replace`, `save`, `undo`, `redo`, `create_scratch`.
|
||||
- **`nvim_workspace`**: Window split and focus management. Actions: `list_buffers`, `list_windows`, `focus`, `split`, `cwd`.
|
||||
- **`nvim_intelligence`**: Code intelligence and LSP. Actions: `hover`, `definition`, `references`, `outline`, `query`, `diagnostics`, `rename`, `code_action`.
|
||||
- **`nvim_ui`**: Visual highlighting, diff previews, and ghost text. Actions: `highlight`, `ghost_text`, `clear`.
|
||||
- **`nvim_exec`**: Escape hatch for raw evaluation. Actions: `lua`, `vimscript`, `terminal`.
|
||||
|
||||
## 1. Using Consolidated Domain Tools First
|
||||
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.
|
||||
Always prefer the specific consolidated tools (like `nvim_buffer`, `nvim_workspace`, `nvim_ui`, etc.) over writing raw Lua scripts. These tools are strongly typed, tested, and safe.
|
||||
|
||||
## 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, custom LSP interactions), you MUST use `nvim_execute_lua` as your escape hatch.
|
||||
## 2. Lua God Mode (`nvim_exec` with action `lua`)
|
||||
If you need to access *any* Neovim API that does not have a dedicated tool (e.g., changing options, setting autocmds), you MUST use `nvim_exec` with action `lua` as your escape hatch.
|
||||
**CRITICAL**: `nvim_exec` is restricted to **READ-ONLY** queries. Do NOT use it to mutate editor state.
|
||||
|
||||
### CRITICAL RULES for `nvim_execute_lua`:
|
||||
### CRITICAL RULES for `nvim_exec` (`lua`):
|
||||
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" })`.
|
||||
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.
|
||||
@@ -57,4 +31,3 @@ While the headless background instance is great for autonomous, routine tasks, i
|
||||
|
||||
## 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).
|
||||
|
||||
+604
-210
@@ -214,7 +214,7 @@ pub struct NvimRequest {
|
||||
pub reply: oneshot::Sender<Result<rmpv::Value, String>>,
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
|
||||
use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CircuitState {
|
||||
@@ -275,7 +275,9 @@ impl NvimCircuitBreaker {
|
||||
|
||||
pub fn record_success(&self) {
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
let prev = self.state.swap(CircuitState::Closed as u8, Ordering::SeqCst);
|
||||
let prev = self
|
||||
.state
|
||||
.swap(CircuitState::Closed as u8, Ordering::SeqCst);
|
||||
if prev != CircuitState::Closed as u8 {
|
||||
tracing::info!("Neovim RPC circuit breaker transitioned to Closed (recovered)");
|
||||
}
|
||||
@@ -299,7 +301,8 @@ impl NvimCircuitBreaker {
|
||||
|
||||
pub fn reset(&self) {
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.state.store(CircuitState::Closed as u8, Ordering::SeqCst);
|
||||
self.state
|
||||
.store(CircuitState::Closed as u8, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,16 +377,16 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
|
||||
// without spawning any external nvim.exe child process!
|
||||
{
|
||||
let conn_lock = NVIM_CONN.lock().await;
|
||||
if let Some(sender) = conn_lock.as_ref() {
|
||||
if !sender.is_closed() {
|
||||
let active_lock = ACTIVE_SOCKET.lock().await;
|
||||
if let Some(ref current_socket) = *active_lock {
|
||||
if let Ok(target) = read_active_socket_file().await {
|
||||
if !target.is_empty() && &target == current_socket {
|
||||
return Ok(sender.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(sender) = conn_lock.as_ref()
|
||||
&& !sender.is_closed()
|
||||
{
|
||||
let active_lock = ACTIVE_SOCKET.lock().await;
|
||||
if let Some(ref current_socket) = *active_lock
|
||||
&& let Ok(target) = read_active_socket_file().await
|
||||
&& !target.is_empty()
|
||||
&& &target == current_socket
|
||||
{
|
||||
return Ok(sender.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -643,7 +646,10 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
|
||||
|
||||
async fn call_nvim(req: rmpv::Value) -> Result<rmpv::Value, String> {
|
||||
if !CIRCUIT_BREAKER.can_execute() {
|
||||
return Err("Neovim RPC circuit breaker is OPEN (consecutive failures detected). Failing fast.".to_string());
|
||||
return Err(
|
||||
"Neovim RPC circuit breaker is OPEN (consecutive failures detected). Failing fast."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let msgid = if let rmpv::Value::Array(ref arr) = req {
|
||||
@@ -668,13 +674,14 @@ async fn call_nvim(req: rmpv::Value) -> Result<rmpv::Value, String> {
|
||||
};
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
|
||||
if let Err(_) = tx
|
||||
if tx
|
||||
.send(NvimRequest {
|
||||
msgid,
|
||||
req,
|
||||
reply: reply_tx,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
PENDING_REQUESTS.remove(&msgid);
|
||||
let mut conn = NVIM_CONN.lock().await;
|
||||
@@ -1076,155 +1083,125 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
result: Some(json!({
|
||||
"tools": [
|
||||
{
|
||||
"name": "nvim_buffer",
|
||||
"description": "Unified buffer management: inspect, open, read, search, save, reload, or close Neovim buffers.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get_active", "read", "open", "create_scratch", "save", "reload", "close", "list", "search"],
|
||||
"description": "Action to perform on the buffer"
|
||||
},
|
||||
"file": { "type": "string", "description": "Target file path (for open, read, search)" },
|
||||
"buf_id": { "type": "integer", "description": "Buffer ID (for close, reload, or split)" },
|
||||
"content": { "type": "string", "description": "Initial text content (for create_scratch)" },
|
||||
"name": { "type": "string", "description": "Buffer display name (for create_scratch)" },
|
||||
"filetype": { "type": "string", "description": "Filetype syntax (for open, create_scratch)" },
|
||||
"start_line": { "type": "integer", "description": "1-indexed start line (for read)" },
|
||||
"end_line": { "type": "integer", "description": "1-indexed end line (for read)" },
|
||||
"pattern": { "type": "string", "description": "Regex or substring pattern to search for (for search)" },
|
||||
"force": { "type": "boolean", "description": "Force reload or close (for reload, close)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nvim_window",
|
||||
"description": "Manage Neovim windows and splits: list open windows, query or focus active window, create splits, or close windows.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list", "get_active", "focus", "split", "close"],
|
||||
"description": "Window operation to perform"
|
||||
},
|
||||
"win_id": { "type": "integer", "description": "Window ID to focus or close" },
|
||||
"file": { "type": "string", "description": "File to open in split" },
|
||||
"buf_id": { "type": "integer", "description": "Buffer ID to attach to split" },
|
||||
"direction": { "type": "string", "enum": ["vertical", "horizontal"], "description": "Split orientation (default: vertical)" },
|
||||
"force": { "type": "boolean", "description": "Force close window" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nvim_view",
|
||||
"description": "Editor navigation and viewport introspection: jump to line, query cursor position, get visible viewport lines, or get visual selection.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["goto_line", "get_cursor", "get_viewport", "get_selection"],
|
||||
"description": "Navigation/inspection action to perform"
|
||||
},
|
||||
"file": { "type": "string", "description": "File path (for goto_line)" },
|
||||
"line": { "type": "integer", "description": "Target line number (1-indexed, for goto_line)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nvim_diagnostics",
|
||||
"description": "LSP diagnostics and quickfix integration: fetch current diagnostics, inject LSP diagnostic markers, or populate the quickfix list.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get", "set", "set_quickfix"],
|
||||
"description": "Diagnostic action to perform"
|
||||
},
|
||||
"line": { "type": "integer", "description": "Line number (1-indexed, for set)" },
|
||||
"message": { "type": "string", "description": "Diagnostic warning/error message (for set)" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Quickfix entries (for set_quickfix)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filename": { "type": "string" },
|
||||
"lnum": { "type": "integer" },
|
||||
"text": { "type": "string" }
|
||||
},
|
||||
"required": ["filename", "lnum", "text"]
|
||||
}
|
||||
},
|
||||
"qf_action": { "type": "string", "enum": ["replace", "append", "prepend"], "description": "Quickfix list modification action (for set_quickfix)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nvim_visual",
|
||||
"description": "Visual feedback, syntax highlighting, ghost text extmarks, and diff preview overlays.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["preview", "extmark", "highlight", "clear_highlight"],
|
||||
"description": "Visual feedback action"
|
||||
},
|
||||
"file_path": { "type": "string", "description": "File path (for preview)" },
|
||||
"start_line": { "type": "integer", "description": "Start line (1-indexed, for preview, highlight)" },
|
||||
"end_line": { "type": "integer", "description": "End line (1-indexed, for preview, highlight)" },
|
||||
"preview_content": { "type": "string", "description": "Proposed replacement code (for preview)" },
|
||||
"line": { "type": "integer", "description": "Line number (1-indexed, for extmark)" },
|
||||
"text": { "type": "string", "description": "Virtual ghost text to display (for extmark)" },
|
||||
"highlight_group": { "type": "string", "description": "Highlight group (for extmark, highlight, e.g. 'Comment', 'IncSearch')" },
|
||||
"buf_id": { "type": "integer", "description": "Buffer ID (for highlight, clear_highlight)" },
|
||||
"duration_ms": { "type": "integer", "description": "Auto-clear duration in ms (for highlight, default: 5000)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nvim_execute_lua",
|
||||
"description": "Execute arbitrary Lua code directly in the active Neovim session. Primary tool for editing files via vim.api.nvim_buf_set_lines, querying editor state, and triggering notifications.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "The Lua code string to execute in Neovim"
|
||||
}
|
||||
},
|
||||
"required": ["code"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nvim_system",
|
||||
"description": "System diagnostics, terminal interaction, and editor notification messages.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get_info", "get_messages", "send_to_terminal"],
|
||||
"description": "System action to perform"
|
||||
},
|
||||
"command": { "type": "string", "description": "Shell command to send (for send_to_terminal)" },
|
||||
"tail": { "type": "integer", "description": "Number of message lines to return (for get_messages)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
]
|
||||
"tools":
|
||||
[
|
||||
{
|
||||
"name": "nvim_buffer",
|
||||
"description": "Core Text Editing: read, replace, save, and manipulate Neovim buffers in memory.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["read", "replace", "save", "undo", "redo", "create_scratch"],
|
||||
"description": "Action to perform on the buffer"
|
||||
},
|
||||
"file": { "type": "string", "description": "Target file path (for read, replace, save)" },
|
||||
"start_line": { "type": "integer", "description": "1-indexed start line (for read)" },
|
||||
"end_line": { "type": "integer", "description": "1-indexed end line (for read)" },
|
||||
"content": { "type": "string", "description": "Initial text content (for create_scratch)" },
|
||||
"name": { "type": "string", "description": "Buffer display name (for create_scratch)" },
|
||||
"steps": { "type": "integer", "description": "Number of undo/redo steps to apply (default: 1)" },
|
||||
"edits": {
|
||||
"type": "array",
|
||||
"description": "Array of edits to apply sequentially (for replace). Grouped by file, applied in descending order.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": { "type": "string" },
|
||||
"start_line": { "type": "integer" },
|
||||
"end_line": { "type": "integer" },
|
||||
"replacement_content": { "type": "string" },
|
||||
"expected_content": { "type": "string" }
|
||||
},
|
||||
"required": ["file", "start_line", "end_line", "replacement_content"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nvim_workspace",
|
||||
"description": "Window & Editor State: list buffers, windows, focus splits, and manage cwd.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list_buffers", "list_windows", "focus", "split", "cwd"],
|
||||
"description": "Workspace operation to perform"
|
||||
},
|
||||
"win_id": { "type": "integer", "description": "Window ID to focus" },
|
||||
"file": { "type": "string", "description": "File to open in split" },
|
||||
"direction": { "type": "string", "enum": ["vertical", "horizontal"], "description": "Split orientation (default: vertical)" },
|
||||
"path": { "type": "string", "description": "Target directory (for cwd action)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nvim_intelligence",
|
||||
"description": "Code Semantics: LSP queries (hover, definition, references, code_action, rename, diagnostics) and AST outlining/queries.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["hover", "definition", "references", "outline", "query", "diagnostics", "rename", "code_action"],
|
||||
"description": "Intelligence action to perform"
|
||||
},
|
||||
"file": { "type": "string", "description": "Target file path" },
|
||||
"line": { "type": "integer", "description": "1-indexed line number (for LSP)" },
|
||||
"col": { "type": "integer", "description": "0-indexed column number (for LSP)" },
|
||||
"new_name": { "type": "string", "description": "New name (for rename action)" },
|
||||
"query": { "type": "string", "description": "Tree-sitter query string (for AST query)" },
|
||||
"preset": { "type": "string", "description": "Query preset (e.g., 'functions', 'classes' for AST query)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nvim_ui",
|
||||
"description": "Visual Feedback: communicating visually with the human user via highlights and ghost text.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["highlight", "ghost_text", "clear"],
|
||||
"description": "UI action"
|
||||
},
|
||||
"buf_id": { "type": "integer", "description": "Buffer ID to apply to" },
|
||||
"start_line": { "type": "integer", "description": "1-indexed start line (for highlight)" },
|
||||
"end_line": { "type": "integer", "description": "1-indexed end line (for highlight)" },
|
||||
"line": { "type": "integer", "description": "1-indexed line number (for ghost_text)" },
|
||||
"text": { "type": "string", "description": "Virtual text to display (for ghost_text)" },
|
||||
"highlight_group": { "type": "string", "description": "Highlight group (e.g. 'Comment', 'IncSearch')" },
|
||||
"duration_ms": { "type": "integer", "description": "Auto-clear duration in ms (for highlight, default: 5000)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nvim_exec",
|
||||
"description": "The Escape Hatch: execute lua read-only queries, run vimscript commands, or send commands to the terminal.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["lua", "vimscript", "terminal"],
|
||||
"description": "Execution action"
|
||||
},
|
||||
"code": { "type": "string", "description": "Lua code or Vimscript command to execute" },
|
||||
"command": { "type": "string", "description": "Shell command to send (for terminal)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
})),
|
||||
error: None,
|
||||
}).await;
|
||||
@@ -1237,8 +1214,42 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
|
||||
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
match name {
|
||||
"nvim_buffer" => match action {
|
||||
let (mapped_name, mapped_action) = match (name, action) {
|
||||
("nvim_buffer", "read") => ("nvim_buffer", "read"),
|
||||
("nvim_buffer", "replace") => ("nvim_buffer", "edit"),
|
||||
("nvim_buffer", "save") => ("nvim_buffer", "save"),
|
||||
("nvim_buffer", "undo") => ("nvim_buffer", "undo"),
|
||||
("nvim_buffer", "redo") => ("nvim_buffer", "redo"),
|
||||
("nvim_buffer", "create_scratch") => ("nvim_buffer", "create_scratch"),
|
||||
|
||||
("nvim_workspace", "list_buffers") => ("nvim_buffer", "list"),
|
||||
("nvim_workspace", "list_windows") => ("nvim_window", "list"),
|
||||
("nvim_workspace", "focus") => ("nvim_window", "focus"),
|
||||
("nvim_workspace", "split") => ("nvim_window", "split"),
|
||||
("nvim_workspace", "cwd") => ("nvim_system", "cwd"),
|
||||
|
||||
("nvim_intelligence", "hover") => ("nvim_lsp", "hover"),
|
||||
("nvim_intelligence", "definition") => ("nvim_lsp", "definition"),
|
||||
("nvim_intelligence", "references") => ("nvim_lsp", "references"),
|
||||
("nvim_intelligence", "rename") => ("nvim_lsp", "rename"),
|
||||
("nvim_intelligence", "code_action") => ("nvim_lsp", "code_action"),
|
||||
("nvim_intelligence", "outline") => ("nvim_ast", "outline"),
|
||||
("nvim_intelligence", "query") => ("nvim_ast", "query"),
|
||||
("nvim_intelligence", "diagnostics") => ("nvim_diagnostics", "get"),
|
||||
|
||||
("nvim_ui", "highlight") => ("nvim_visual", "highlight"),
|
||||
("nvim_ui", "ghost_text") => ("nvim_visual", "extmark"),
|
||||
("nvim_ui", "clear") => ("nvim_visual", "clear_highlight"),
|
||||
|
||||
("nvim_exec", "lua") => ("nvim_execute_lua", ""),
|
||||
("nvim_exec", "vimscript") => ("nvim_system", "vimscript"),
|
||||
("nvim_exec", "terminal") => ("nvim_system", "send_to_terminal"),
|
||||
|
||||
(n, a) => (n, a),
|
||||
};
|
||||
|
||||
match mapped_name {
|
||||
"nvim_buffer" => match mapped_action {
|
||||
"get_active" => match get_nvim_active_buffer().await {
|
||||
Ok(content) => {
|
||||
send_text_result!(id.clone(), content);
|
||||
@@ -1321,6 +1332,141 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
execute_nvim_lua_with_args(code, vec![args_rmp])
|
||||
)
|
||||
}
|
||||
"edit" => {
|
||||
let mut modified_args = args.clone();
|
||||
if let Some(edits) = modified_args
|
||||
.get_mut("edits")
|
||||
.and_then(|v| v.as_array_mut())
|
||||
{
|
||||
for edit in edits.iter_mut() {
|
||||
if let Some(content) = edit.get_mut("replacement_content")
|
||||
&& let Some(s) = content.as_str()
|
||||
{
|
||||
let lines: Vec<String> = s
|
||||
.split('\n')
|
||||
.map(|l| l.trim_end_matches('\r').to_string())
|
||||
.collect();
|
||||
*content = serde_json::json!(lines);
|
||||
}
|
||||
}
|
||||
} else if let Some(content) =
|
||||
modified_args.get_mut("replacement_content")
|
||||
&& let Some(s) = content.as_str()
|
||||
{
|
||||
let lines: Vec<String> = s
|
||||
.split('\n')
|
||||
.map(|l| l.trim_end_matches('\r').to_string())
|
||||
.collect();
|
||||
*content = serde_json::json!(lines);
|
||||
}
|
||||
let args_rmp = json_to_rmpv(&modified_args);
|
||||
let code = "
|
||||
local args = ...
|
||||
local edits = args.edits
|
||||
if not edits or type(edits) ~= 'table' then
|
||||
if args.file and args.start_line and args.end_line and args.replacement_content then
|
||||
edits = {
|
||||
{
|
||||
file = args.file,
|
||||
start_line = tonumber(args.start_line),
|
||||
end_line = tonumber(args.end_line),
|
||||
replacement_content = args.replacement_content,
|
||||
expected_content = args.expected_content
|
||||
}
|
||||
}
|
||||
else
|
||||
error(\"Missing 'edits' array or valid single edit parameters\")
|
||||
end
|
||||
end
|
||||
|
||||
local grouped_edits = {}
|
||||
for i, edit in ipairs(edits) do
|
||||
local file = edit.file
|
||||
if not file or file == '' or file == vim.NIL then
|
||||
error(\"Edit missing 'file'\")
|
||||
end
|
||||
if not grouped_edits[file] then
|
||||
grouped_edits[file] = {}
|
||||
end
|
||||
table.insert(grouped_edits[file], {
|
||||
start_line = tonumber(edit.start_line),
|
||||
end_line = tonumber(edit.end_line),
|
||||
replacement_content = edit.replacement_content,
|
||||
expected_content = edit.expected_content,
|
||||
original_index = i
|
||||
})
|
||||
end
|
||||
|
||||
local results = {}
|
||||
for file, file_edits in pairs(grouped_edits) do
|
||||
local bufnr = vim.fn.bufnr(file, true)
|
||||
if vim.fn.bufloaded(bufnr) == 0 then
|
||||
vim.fn.bufload(bufnr)
|
||||
end
|
||||
|
||||
table.sort(file_edits, function(a, b)
|
||||
return a.start_line > b.start_line
|
||||
end)
|
||||
|
||||
for _, edit in ipairs(file_edits) do
|
||||
local sl = edit.start_line
|
||||
local el = edit.end_line
|
||||
local rc = edit.replacement_content
|
||||
local ec = edit.expected_content
|
||||
|
||||
if not sl then error(\"Edit missing 'start_line'\") end
|
||||
if not el then error(\"Edit missing 'end_line'\") end
|
||||
if not rc or rc == vim.NIL then error(\"Edit missing 'replacement_content'\") end
|
||||
|
||||
if ec and type(ec) == 'string' and ec ~= vim.NIL and ec ~= '' then
|
||||
local current_lines = vim.api.nvim_buf_get_lines(bufnr, sl - 1, el, false)
|
||||
local current_text = table.concat(current_lines, '\\n')
|
||||
local ec_clean = string.gsub(ec, '\\r', '')
|
||||
if current_text ~= ec_clean then
|
||||
error(\"Safety check failed in \" .. file .. \": Expected content at lines \" .. tostring(sl) .. \"-\" .. tostring(el) .. \" does not match actual content.\\nExpected:\\n\" .. ec_clean .. \"\\nActual:\\n\" .. current_text)
|
||||
end
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_lines(bufnr, sl - 1, el, false, rc)
|
||||
end
|
||||
|
||||
local win = vim.fn.bufwinid(bufnr)
|
||||
if win ~= -1 and #file_edits > 0 then
|
||||
pcall(vim.api.nvim_win_set_cursor, win, {file_edits[#file_edits].start_line, 0})
|
||||
end
|
||||
|
||||
table.insert(results, \"Edited \" .. file .. \" (\" .. tostring(#file_edits) .. \" blocks)\")
|
||||
end
|
||||
|
||||
return table.concat(results, ', ')
|
||||
";
|
||||
handle_lua_result!(
|
||||
id,
|
||||
execute_nvim_lua_with_args(code, vec![args_rmp])
|
||||
)
|
||||
}
|
||||
"undo" | "redo" => {
|
||||
let is_undo = action == "undo";
|
||||
let steps = args.get("steps").and_then(|v| v.as_i64()).unwrap_or(1);
|
||||
let cmd = if is_undo {
|
||||
format!("{}u", steps)
|
||||
} else {
|
||||
format!("{}<C-r>", steps)
|
||||
};
|
||||
let code = format!(
|
||||
"
|
||||
local pcall_ok, err = pcall(function()
|
||||
vim.cmd('normal! {}')
|
||||
end)
|
||||
if not pcall_ok then
|
||||
error('Failed to {}: ' .. tostring(err))
|
||||
end
|
||||
return '{} applied {} step(s)'
|
||||
",
|
||||
cmd, action, action, steps
|
||||
);
|
||||
handle_lua_result!(id, execute_nvim_lua(&code));
|
||||
}
|
||||
"open" => {
|
||||
let args_rmp = json_to_rmpv(args);
|
||||
let code = "
|
||||
@@ -1353,7 +1499,17 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
)
|
||||
}
|
||||
"create_scratch" | "open_buffer" => {
|
||||
let args_rmp = json_to_rmpv(args);
|
||||
let mut modified_args = args.clone();
|
||||
if let Some(content) = modified_args.get_mut("content")
|
||||
&& let Some(s) = content.as_str()
|
||||
{
|
||||
let lines: Vec<String> = s
|
||||
.split('\n')
|
||||
.map(|l| l.trim_end_matches('\r').to_string())
|
||||
.collect();
|
||||
*content = serde_json::json!(lines);
|
||||
}
|
||||
let args_rmp = json_to_rmpv(&modified_args);
|
||||
let code = "
|
||||
local args = ...
|
||||
local name = (args.name and args.name ~= '') and args.name or '[Antigravity Scratch]'
|
||||
@@ -1363,8 +1519,7 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
vim.bo[buf].buftype = 'nofile'
|
||||
vim.bo[buf].bufhidden = 'hide'
|
||||
if args.content then
|
||||
local lines = vim.split(args.content, '\\n')
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, args.content)
|
||||
end
|
||||
if args.filetype and args.filetype ~= '' then
|
||||
vim.bo[buf].filetype = args.filetype
|
||||
@@ -1749,17 +1904,18 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let options_arr = match args.get("options").and_then(|v| v.as_array()) {
|
||||
Some(arr) => {
|
||||
let rmp_arr: Vec<rmpv::Value> = arr
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.map(|s| rmpv::Value::String(s.into()))
|
||||
.collect();
|
||||
rmpv::Value::Array(rmp_arr)
|
||||
}
|
||||
None => rmpv::Value::Nil,
|
||||
};
|
||||
let options_arr =
|
||||
match args.get("options").and_then(|v| v.as_array()) {
|
||||
Some(arr) => {
|
||||
let rmp_arr: Vec<rmpv::Value> = arr
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.map(|s| rmpv::Value::String(s.into()))
|
||||
.collect();
|
||||
rmpv::Value::Array(rmp_arr)
|
||||
}
|
||||
None => rmpv::Value::Nil,
|
||||
};
|
||||
|
||||
let lua_code = r#"
|
||||
local prompt_text, options, request_id = ...
|
||||
@@ -1782,7 +1938,8 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
)
|
||||
);
|
||||
} else {
|
||||
send_error(id, -32602, "Missing 'prompt' or 'prompt_text'").await;
|
||||
send_error(id, -32602, "Missing 'prompt' or 'prompt_text'")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"extmark" => {
|
||||
@@ -1812,10 +1969,10 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
}
|
||||
"highlight" | "clear_highlight" => {
|
||||
let mut modified_args = args.clone();
|
||||
if action == "clear_highlight" {
|
||||
if let Some(obj) = modified_args.as_object_mut() {
|
||||
obj.insert("clear_only".to_string(), json!(true));
|
||||
}
|
||||
if action == "clear_highlight"
|
||||
&& let Some(obj) = modified_args.as_object_mut()
|
||||
{
|
||||
obj.insert("clear_only".to_string(), json!(true));
|
||||
}
|
||||
let args_rmp = json_to_rmpv(&modified_args);
|
||||
let code = "
|
||||
@@ -1864,27 +2021,17 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
|
||||
"nvim_execute_lua" => {
|
||||
if let Some(code) = args.get("code").and_then(|v| v.as_str()) {
|
||||
// BAKE IN: Block interactive prompts that cause server deadlocks
|
||||
// READ-ONLY ENFORCEMENT: Block any Lua code that attempts to mutate state.
|
||||
let lower_code = code.to_lowercase();
|
||||
if lower_code.contains("vim.fn.input")
|
||||
|| lower_code.contains("vim.ui.select")
|
||||
|| lower_code.contains("vim.fn.confirm")
|
||||
|| lower_code.contains("vim.ui.input")
|
||||
if lower_code.contains("vim.cmd")
|
||||
|| lower_code.contains("nvim_buf_set_lines")
|
||||
|| lower_code.contains("nvim_buf_set_text")
|
||||
|| lower_code.contains("nvim_command")
|
||||
|| lower_code.contains("nvim_set_current")
|
||||
|| lower_code.contains("nvim_win_set")
|
||||
|| lower_code.contains("nvim_buf_set_name")
|
||||
{
|
||||
send_error(id, -32600, "CRITICAL ERROR: Interactive Lua functions (input, select, confirm) are strictly forbidden via MCP as they will hang the headless server.").await;
|
||||
return;
|
||||
}
|
||||
// Block the 'c' confirm flag in vim.cmd substitutions
|
||||
if (code.contains("vim.cmd")
|
||||
|| code.contains("vim.api.nvim_command"))
|
||||
&& code.contains("%s")
|
||||
&& (code.contains("gc'")
|
||||
|| code.contains("gc\"")
|
||||
|| code.contains("gc\n")
|
||||
|| code.contains("c'")
|
||||
|| code.contains("c\""))
|
||||
{
|
||||
send_error(id, -32600, "CRITICAL ERROR: The 'c' (confirm) flag in Neovim substitutions is strictly forbidden via MCP as it triggers an interactive prompt that hangs the headless server. Use '/g' or '/ge' instead.").await;
|
||||
send_error(id, -32600, "CRITICAL ERROR: nvim_execute_lua is restricted to READ-ONLY queries to prevent agents from bypassing the specialized visual tools (nvim_buffer, nvim_visual). Use the designated tools to mutate editor state.").await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1951,6 +2098,27 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
send_error(id, -32602, "Missing 'command'").await;
|
||||
}
|
||||
}
|
||||
"cwd" => {
|
||||
let lua_code =
|
||||
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
|
||||
format!(
|
||||
"vim.cmd('cd {}'); return vim.fn.getcwd()",
|
||||
path.replace("\\", "\\\\").replace("'", "\\'")
|
||||
)
|
||||
} else {
|
||||
"return vim.fn.getcwd()".to_string()
|
||||
};
|
||||
handle_lua_result!(id, execute_nvim_lua(&lua_code));
|
||||
}
|
||||
"vimscript" => {
|
||||
if let Some(code) = args.get("code").and_then(|v| v.as_str()) {
|
||||
let lua_code =
|
||||
format!("vim.cmd([=[{}]=]); return 'Success'", code);
|
||||
handle_lua_result!(id, execute_nvim_lua(&lua_code));
|
||||
} else {
|
||||
send_error(id, -32602, "Missing 'code'").await;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
send_error(
|
||||
id,
|
||||
@@ -1964,6 +2132,229 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
}
|
||||
},
|
||||
|
||||
"nvim_lsp" => {
|
||||
let args_rmp = json_to_rmpv(args);
|
||||
let lua_code = match action {
|
||||
"hover" => {
|
||||
r#"
|
||||
local args = ...
|
||||
local bufnr = vim.fn.bufnr(args.file)
|
||||
if bufnr == -1 then return "Buffer not found" end
|
||||
local params = vim.lsp.util.make_position_params(0)
|
||||
params.position.line = (tonumber(args.line) or 1) - 1
|
||||
params.position.character = tonumber(args.col) or 0
|
||||
params.textDocument.uri = vim.uri_from_bufnr(bufnr)
|
||||
local res, err = vim.lsp.buf_request_sync(bufnr, 'textDocument/hover', params, 2000)
|
||||
if err or not res then return "Error or timeout" end
|
||||
for client_id, response in pairs(res) do
|
||||
if response.result and response.result.contents then
|
||||
local contents = response.result.contents
|
||||
if type(contents) == 'table' and contents.value then return contents.value end
|
||||
if type(contents) == 'string' then return contents end
|
||||
end
|
||||
end
|
||||
return "No hover info"
|
||||
"#
|
||||
}
|
||||
"definition" => {
|
||||
r#"
|
||||
local args = ...
|
||||
local bufnr = vim.fn.bufnr(args.file)
|
||||
if bufnr == -1 then return "Buffer not found" end
|
||||
local params = vim.lsp.util.make_position_params(0)
|
||||
params.position.line = (tonumber(args.line) or 1) - 1
|
||||
params.position.character = tonumber(args.col) or 0
|
||||
params.textDocument.uri = vim.uri_from_bufnr(bufnr)
|
||||
local res, err = vim.lsp.buf_request_sync(bufnr, 'textDocument/definition', params, 2000)
|
||||
if err or not res then return "Error or timeout" end
|
||||
local locations = {}
|
||||
for client_id, response in pairs(res) do
|
||||
if response.result then
|
||||
local res_locs = vim.tbl_islist(response.result) and response.result or {response.result}
|
||||
for _, loc in ipairs(res_locs) do
|
||||
local uri = loc.uri or loc.targetUri
|
||||
local range = loc.range or loc.targetSelectionRange
|
||||
table.insert(locations, vim.uri_to_fname(uri) .. ":" .. (range.start.line + 1))
|
||||
end
|
||||
end
|
||||
end
|
||||
if #locations == 0 then return "No definition found" end
|
||||
return table.concat(locations, "\n")
|
||||
"#
|
||||
}
|
||||
"references" => {
|
||||
r#"
|
||||
local args = ...
|
||||
local bufnr = vim.fn.bufnr(args.file)
|
||||
if bufnr == -1 then return "Buffer not found" end
|
||||
local params = vim.lsp.util.make_position_params(0)
|
||||
params.position.line = (tonumber(args.line) or 1) - 1
|
||||
params.position.character = tonumber(args.col) or 0
|
||||
params.context = { includeDeclaration = true }
|
||||
params.textDocument.uri = vim.uri_from_bufnr(bufnr)
|
||||
local res, err = vim.lsp.buf_request_sync(bufnr, 'textDocument/references', params, 5000)
|
||||
if err or not res then return "Error or timeout" end
|
||||
local locations = {}
|
||||
for client_id, response in pairs(res) do
|
||||
if response.result then
|
||||
for _, loc in ipairs(response.result) do
|
||||
table.insert(locations, vim.uri_to_fname(loc.uri) .. ":" .. (loc.range.start.line + 1))
|
||||
end
|
||||
end
|
||||
end
|
||||
if #locations == 0 then return "No references found" end
|
||||
return table.concat(locations, "\n")
|
||||
"#
|
||||
}
|
||||
"rename" => {
|
||||
r#"
|
||||
local args = ...
|
||||
local bufnr = vim.fn.bufnr(args.file)
|
||||
if bufnr == -1 then return "Buffer not found" end
|
||||
local new_name = args.new_name
|
||||
if not new_name then return "Missing new_name" end
|
||||
local params = vim.lsp.util.make_position_params(0)
|
||||
params.position.line = (tonumber(args.line) or 1) - 1
|
||||
params.position.character = tonumber(args.col) or 0
|
||||
params.newName = new_name
|
||||
params.textDocument.uri = vim.uri_from_bufnr(bufnr)
|
||||
local res, err = vim.lsp.buf_request_sync(bufnr, 'textDocument/rename', params, 2000)
|
||||
if err or not res then return "Error or timeout" end
|
||||
local applied = false
|
||||
for client_id, response in pairs(res) do
|
||||
if response.result then
|
||||
vim.lsp.util.apply_workspace_edit(response.result, vim.lsp.get_client_by_id(client_id).offset_encoding)
|
||||
applied = true
|
||||
end
|
||||
end
|
||||
return applied and "Rename applied" or "No rename edits"
|
||||
"#
|
||||
}
|
||||
"code_action" => {
|
||||
r#"
|
||||
local args = ...
|
||||
local bufnr = vim.fn.bufnr(args.file)
|
||||
if bufnr == -1 then return "Buffer not found" end
|
||||
local params = vim.lsp.util.make_range_params(0)
|
||||
params.position = nil
|
||||
params.range.start.line = (tonumber(args.line) or 1) - 1
|
||||
params.range.start.character = tonumber(args.col) or 0
|
||||
params.range['end'] = params.range.start
|
||||
params.context = { diagnostics = vim.lsp.diagnostic.get_line_diagnostics(bufnr, params.range.start.line) }
|
||||
params.textDocument.uri = vim.uri_from_bufnr(bufnr)
|
||||
local res, err = vim.lsp.buf_request_sync(bufnr, 'textDocument/codeAction', params, 2000)
|
||||
if err or not res then return "Error or timeout" end
|
||||
|
||||
local actions = {}
|
||||
for client_id, response in pairs(res) do
|
||||
if response.result then
|
||||
for _, action in ipairs(response.result) do
|
||||
if action.edit then
|
||||
vim.lsp.util.apply_workspace_edit(action.edit, vim.lsp.get_client_by_id(client_id).offset_encoding)
|
||||
table.insert(actions, "Applied: " .. (action.title or "unnamed"))
|
||||
elseif action.command then
|
||||
table.insert(actions, "Found command (not auto-applied): " .. (action.title or "unnamed"))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #actions == 0 then return "No code actions available or applied" end
|
||||
return table.concat(actions, "\n")
|
||||
"#
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
|
||||
if lua_code.is_empty() {
|
||||
send_error(
|
||||
id,
|
||||
-32602,
|
||||
&format!("Invalid nvim_lsp action: {}", action),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
handle_lua_result!(
|
||||
id,
|
||||
execute_nvim_lua_with_args(lua_code, vec![args_rmp])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
"nvim_ast" => {
|
||||
let args_rmp = json_to_rmpv(args);
|
||||
let lua_code = match action {
|
||||
"query" => {
|
||||
r#"
|
||||
local args = ...
|
||||
local bufnr = vim.fn.bufnr(args.file)
|
||||
if bufnr == -1 then return "Buffer not found" end
|
||||
local query_str = args.query
|
||||
if not query_str then return "Missing query string" end
|
||||
local parser = vim.treesitter.get_parser(bufnr)
|
||||
if not parser then return "No TS parser for buffer" end
|
||||
local tree = parser:parse()[1]
|
||||
local root = tree:root()
|
||||
local query = vim.treesitter.query.parse(parser:lang(), query_str)
|
||||
local results = {}
|
||||
for id, node, metadata in query:iter_captures(root, bufnr, 0, -1) do
|
||||
local name = query.captures[id]
|
||||
local row1, col1, row2, col2 = node:range()
|
||||
local text = vim.treesitter.get_node_text(node, bufnr)
|
||||
table.insert(results, name .. " at line " .. tostring(row1 + 1) .. ":\n" .. text)
|
||||
end
|
||||
return table.concat(results, "\n\n")
|
||||
"#
|
||||
}
|
||||
"outline" => {
|
||||
r#"
|
||||
local args = ...
|
||||
local bufnr = vim.fn.bufnr(args.file)
|
||||
if bufnr == -1 then return "Buffer not found" end
|
||||
local parser = vim.treesitter.get_parser(bufnr)
|
||||
if not parser then return "No TS parser for buffer" end
|
||||
local tree = parser:parse()[1]
|
||||
local root = tree:root()
|
||||
local lang = parser:lang()
|
||||
|
||||
local q_str = ""
|
||||
if lang == "rust" then
|
||||
q_str = "(function_item name: (identifier) @name) (impl_item type: (type_identifier) @name) (struct_item name: (type_identifier) @name) (trait_item name: (type_identifier) @name)"
|
||||
elseif lang == "python" then
|
||||
q_str = "(function_definition name: (identifier) @name) (class_definition name: (identifier) @name)"
|
||||
elseif lang == "javascript" or lang == "typescript" then
|
||||
q_str = "(function_declaration name: (identifier) @name) (class_declaration name: (identifier) @name) (method_definition name: (property_identifier) @name)"
|
||||
else
|
||||
return "Outline preset not supported for language: " .. lang
|
||||
end
|
||||
|
||||
local query = vim.treesitter.query.parse(lang, q_str)
|
||||
local results = {}
|
||||
for id, node, metadata in query:iter_captures(root, bufnr, 0, -1) do
|
||||
local name_text = vim.treesitter.get_node_text(node, bufnr)
|
||||
local parent = node:parent()
|
||||
local row1, col1, row2, col2 = parent:range()
|
||||
table.insert(results, name_text .. " (Line " .. tostring(row1 + 1) .. "-" .. tostring(row2 + 1) .. ")")
|
||||
end
|
||||
return table.concat(results, "\n")
|
||||
"#
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
|
||||
if lua_code.is_empty() {
|
||||
send_error(
|
||||
id,
|
||||
-32602,
|
||||
&format!("Invalid nvim_ast action: {}", action),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
handle_lua_result!(
|
||||
id,
|
||||
execute_nvim_lua_with_args(lua_code, vec![args_rmp])
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => send_error(id, -32601, "Method not found").await,
|
||||
}
|
||||
}
|
||||
@@ -2132,10 +2523,7 @@ mod tests {
|
||||
rmpv_to_json(&rmpv::Value::Integer(42.into())),
|
||||
serde_json::json!(42)
|
||||
);
|
||||
assert_eq!(
|
||||
rmpv_to_json(&rmpv::Value::F64(3.14)),
|
||||
serde_json::json!(3.14)
|
||||
);
|
||||
assert_eq!(rmpv_to_json(&rmpv::Value::F64(3.5)), serde_json::json!(3.5));
|
||||
assert_eq!(
|
||||
rmpv_to_json(&rmpv::Value::String("hello".into())),
|
||||
serde_json::json!("hello")
|
||||
@@ -2216,13 +2604,19 @@ mod tests {
|
||||
// 2nd failure - trips to Open
|
||||
cb.record_failure();
|
||||
assert_eq!(cb.current_state(), CircuitState::Open);
|
||||
assert!(!cb.can_execute(), "Circuit breaker should fail fast when Open");
|
||||
assert!(
|
||||
!cb.can_execute(),
|
||||
"Circuit breaker should fail fast when Open"
|
||||
);
|
||||
|
||||
// Wait for cooldown
|
||||
std::thread::sleep(std::time::Duration::from_millis(60));
|
||||
|
||||
// After cooldown, can_execute transitions to HalfOpen
|
||||
assert!(cb.can_execute(), "After cooldown, should allow HalfOpen probe");
|
||||
assert!(
|
||||
cb.can_execute(),
|
||||
"After cooldown, should allow HalfOpen probe"
|
||||
);
|
||||
assert_eq!(cb.current_state(), CircuitState::HalfOpen);
|
||||
|
||||
// Success in HalfOpen recovers back to Closed
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"code": "local buf = vim.fn.bufnr('server/src/search.rs')\nif buf == -1 then\n vim.cmd('e server/src/search.rs')\n buf = vim.api.nvim_get_current_buf()\nend\n\nvim.api.nvim_buf_set_lines(buf, 216, 222, false, {\n ' &self,',\n ' entities: &[Entity],',\n ' tasks: &[Task],',\n ' snippets: &[Snippet],',\n ' adrs: &[Adr],'\n})\n\n-- We need to change the loop variables inside the task from values to clones if they are passed as slices\n-- Actually we can just clone the slice data before moving it into spawn_blocking\nvim.api.nvim_buf_set_lines(buf, 222, 223, false, {\n ' ) -> tokio::task::JoinHandle<tantivy::Result<()>> {',\n ' let entities = entities.to_vec();',\n ' let tasks = tasks.to_vec();',\n ' let snippets = snippets.to_vec();',\n ' let adrs = adrs.to_vec();'\n})\n\nvim.cmd('write')\nrequire('notify')('Updated search index_batch signature', 'info', { title = 'Antigravity' })\nreturn 'ok'\n"}
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
rebuild_index: found 228 entities, 24 tasks
|
||||
spawn_blocking started in rebuild_index
|
||||
add_task_sync called for task: 8da665f7-107c-41c8-b203-d39a9ff1f9a5
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 01a788f0-7adb-456c-bf37-02bbd7e1d2f8
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 5e8d9b26-ae31-4136-a4c4-6ead74887bb5
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: ed184dd6-55ce-4d37-a06a-b34733795e03
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 3cea859d-c1ce-4f6c-bdc5-6eb65dbd8e4f
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 62446699-39b3-496b-9af8-99b331fe6c08
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 4b0e2179-1e70-4b64-9c2d-ff2fe2d05fdf
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 16dd35e4-eadd-4597-a5b6-9844852cfb76
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: c85f352b-f15b-4f90-a6ae-18fe914212b2
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: d1e907a2-89a2-4814-8722-22c754cc943c
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: c382a511-a5d6-4114-ad2c-f09fdd36d66a
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 42ca3765-5c69-4cdd-8324-5a174c34d4ae
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 05792eaa-4bc5-45e5-b9a0-d9027b3b3974
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 15c9fd58-8d8f-4c2f-80f4-15a46ba54588
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 9b55e54c-ac1b-4219-acf3-68f7cb34a1fd
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: a04923ea-6abf-41ba-9c45-f7d9cdac3440
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 4233293e-6d93-4c46-b9aa-f9e712af0e86
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 93451cd9-25c5-4323-8dce-79bba124242d
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 949f1d59-e1e2-421c-9682-43a79f41679b
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: cd680df7-2e1a-4486-bb9b-3881476c762e
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 561daa2b-ddb5-44ae-8fb8-8155405759fb
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: e36b0eba-aa79-493f-b89b-3da46a44fa11
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: a3a717b3-438a-42a7-83ec-2f2eb5d775b4
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
add_task_sync called for task: 6a1b05d5-0ffd-4b0c-bb65-f45e0cb5540a
|
||||
Writer add_document returned id/result
|
||||
Needs_commit set to true in add_task_sync
|
||||
@@ -51,6 +51,7 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
ocrs = "0.13.1"
|
||||
rten = "0.26.0"
|
||||
serde_yaml = "0.9.34"
|
||||
|
||||
[build-dependencies]
|
||||
chrono = "0.4.45"
|
||||
|
||||
@@ -41,7 +41,10 @@ pub async fn wait_for_event_handler(
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!("Event bus receiver lagged by {} messages; continuing wait.", skipped);
|
||||
tracing::warn!(
|
||||
"Event bus receiver lagged by {} messages; continuing wait.",
|
||||
skipped
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
if let Some(idx) = gates.iter().position(|g| {
|
||||
g.action == q.action
|
||||
&& g.target == q.target
|
||||
&& g.namespace == q.namespace
|
||||
&& g.params == q.params
|
||||
}) {
|
||||
found = Some(gates[idx].clone());
|
||||
if q.consume {
|
||||
to_remove = Some(idx);
|
||||
}
|
||||
}
|
||||
if let Some(idx) = to_remove {
|
||||
gates.remove(idx);
|
||||
}
|
||||
});
|
||||
|
||||
match found {
|
||||
Some(record) => {
|
||||
if record.status == "authorized" {
|
||||
(axum::http::StatusCode::OK, "Authorized").into_response()
|
||||
} else {
|
||||
let msg = if let Some(r) = record.reason {
|
||||
format!("Action blocked. Reason: {}", r)
|
||||
} else {
|
||||
"Action blocked.".to_string()
|
||||
};
|
||||
(axum::http::StatusCode::FORBIDDEN, msg).into_response()
|
||||
}
|
||||
}
|
||||
None => (
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
"Action not yet authorized (no gate record found).",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn gate_set_handler(
|
||||
State(app_state): State<Arc<AppState>>,
|
||||
Json(body): Json<GateSetReq>,
|
||||
) -> axum::response::Response {
|
||||
let status = if body.block.unwrap_or(false) {
|
||||
"blocked".to_string()
|
||||
} else if body.authorize.unwrap_or(false) {
|
||||
"authorized".to_string()
|
||||
} else {
|
||||
"pending".to_string()
|
||||
};
|
||||
|
||||
let record = GateRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
action: body.action.clone(),
|
||||
target: body.target.clone(),
|
||||
namespace: body.namespace.clone(),
|
||||
params: body.params.clone(),
|
||||
status,
|
||||
reason: body.reason.clone(),
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
};
|
||||
app_state.handler.state.gates.modify(|gates| {
|
||||
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
||||
gates.push(record);
|
||||
});
|
||||
(axum::http::StatusCode::OK, "Gate state updated.").into_response()
|
||||
}
|
||||
|
||||
async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
state.rebuild_index().await;
|
||||
tokio::spawn(index_committer_worker(Arc::clone(&state)));
|
||||
let app_state = Arc::new(AppState {
|
||||
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
|
||||
clients: RwLock::new(HashMap::new()),
|
||||
next_id: AtomicUsize::new(1),
|
||||
});
|
||||
|
||||
let app_state_clone = Arc::clone(&app_state);
|
||||
let mut rx = state.activity_tx.subscribe();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(msg) = rx.recv().await {
|
||||
let senders: Vec<_> = app_state_clone
|
||||
.clients
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.values()
|
||||
.cloned()
|
||||
.collect();
|
||||
for client_tx in senders {
|
||||
let _ = client_tx.try_send(msg.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/version",
|
||||
get(|| async move {
|
||||
axum::Json(serde_json::json!({
|
||||
"version": env!("APP_VERSION"),
|
||||
"git_hash": option_env!("GIT_HASH").unwrap_or("unknown")
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.route("/ws", get(ws_handler))
|
||||
.route("/health", get(health_handler))
|
||||
.route("/nvim/telemetry", post(nvim_telemetry_handler))
|
||||
.route("/gate/verify", get(gate_verify_handler))
|
||||
.route("/gate/set", post(gate_set_handler))
|
||||
.route(
|
||||
"/shutdown",
|
||||
post(
|
||||
|headers: axum::http::HeaderMap, State(state): State<Arc<AppState>>| async move {
|
||||
let token_path = state.handler.state.base_dir.join("admin.token");
|
||||
let expected_token = tokio::fs::read_to_string(&token_path)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let auth_header = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if expected_token.is_empty() || auth_header != format!("Bearer {}", expected_token) {
|
||||
return (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
|
||||
std::thread::spawn(|| {
|
||||
tracing::info!(
|
||||
"Received shutdown request via /shutdown endpoint. Exiting process cleanly."
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
std::process::exit(0);
|
||||
});
|
||||
(axum::http::StatusCode::OK, "Shutting down...").into_response()
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/",
|
||||
get(|| async move { axum::response::Html(include_str!("dashboard.html")) }),
|
||||
)
|
||||
.route(
|
||||
"/api/graph",
|
||||
get({
|
||||
let state_clone = app_state.handler.state.clone();
|
||||
move || async move {
|
||||
let graph_json = state_clone.read_graph(|g| serde_json::to_string(g).unwrap_or_else(|_| "{}".to_string()));
|
||||
([(axum::http::header::CONTENT_TYPE, "application/json")], graph_json)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/tasks/{id}/complete",
|
||||
post({
|
||||
let state_clone = app_state.handler.state.clone();
|
||||
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
|
||||
state_clone.tasks.modify(|tasks| {
|
||||
for t in tasks.iter_mut() {
|
||||
if t.id == id {
|
||||
t.status = "completed".to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
axum::Json(serde_json::json!({"status": "success"}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/tasks",
|
||||
get({
|
||||
let state_clone = app_state.handler.state.clone();
|
||||
move || async move {
|
||||
let tasks_json = state_clone.tasks.read_with(|t| serde_json::to_string(t).unwrap_or_else(|_| "[]".to_string()));
|
||||
([(axum::http::header::CONTENT_TYPE, "application/json")], tasks_json)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/sticky",
|
||||
get({
|
||||
let state_clone = app_state.handler.state.clone();
|
||||
move || async move {
|
||||
let sticky_json = state_clone.sticky.read_with(|s| serde_json::to_string(s).unwrap_or_else(|_| "[]".to_string()));
|
||||
([(axum::http::header::CONTENT_TYPE, "application/json")], sticky_json)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/search",
|
||||
get({
|
||||
let state_clone = app_state.handler.state.clone();
|
||||
move |axum::extract::Query(params): axum::extract::Query<
|
||||
std::collections::HashMap<String, String>,
|
||||
>| async move {
|
||||
if let Some(q) = params.get("q")
|
||||
&& let Ok(idx) = state_clone.search_index.read()
|
||||
&& let Ok(results) = idx.search(q, None) {
|
||||
let mut formatted_results = Vec::new();
|
||||
for (id, doc_type, title, body, score) in results {
|
||||
formatted_results.push(serde_json::json!({
|
||||
"id": id,
|
||||
"type_name": doc_type,
|
||||
"title": title,
|
||||
"content": body,
|
||||
"score": score
|
||||
}));
|
||||
}
|
||||
return axum::Json(
|
||||
serde_json::json!({ "results": formatted_results }),
|
||||
);
|
||||
}
|
||||
axum::Json(serde_json::json!({ "results": [] }))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/activity",
|
||||
get({
|
||||
let state_clone = app_state.handler.state.clone();
|
||||
move || async move {
|
||||
let activities_json = state_clone.recent_activities.read_with(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
|
||||
([(axum::http::header::CONTENT_TYPE, "application/json")], activities_json)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/stats",
|
||||
get({
|
||||
let state_clone = app_state.handler.state.clone();
|
||||
move || async move {
|
||||
let (entities, relations) = state_clone.read_graph(|g| (g.entities.len(), g.relations.len()));
|
||||
let tasks = state_clone.tasks.read_with(|items| items.len());
|
||||
let snippets = state_clone.snippets.read_with(|items| items.len());
|
||||
let tech_debts = state_clone.tech_debts.read_with(|items| items.len());
|
||||
let adrs = state_clone.adrs.read_with(|items| items.len());
|
||||
|
||||
let ledger = state_clone.ledger.read_with(|items| items.len());
|
||||
let error_fixes = state_clone.error_fixes.read_with(|items| items.len());
|
||||
let session_summaries = state_clone.session_summaries.read_with(|items| items.len());
|
||||
let handoff_memos = state_clone.handoff_memos.read_with(|items| items.len());
|
||||
let env_fingerprints = state_clone.env_fingerprints.read_with(|items| items.len());
|
||||
let env_requirements = state_clone.env_requirements.read_with(|items| items.len());
|
||||
let milestones = state_clone.milestones.read_with(|items| items.len());
|
||||
let environments = state_clone.environments.read_with(|items| items.len());
|
||||
let gates = state_clone.gates.read_with(|items| items.len());
|
||||
|
||||
axum::Json(serde_json::json!({
|
||||
"entities": entities,
|
||||
"relations": relations,
|
||||
"tasks": tasks,
|
||||
"snippets": snippets,
|
||||
"tech_debts": tech_debts,
|
||||
"adrs": adrs,
|
||||
"ledger": ledger,
|
||||
"error_fixes": error_fixes,
|
||||
"session_summaries": session_summaries,
|
||||
"handoff_memos": handoff_memos,
|
||||
"env_fingerprints": env_fingerprints,
|
||||
"env_requirements": env_requirements,
|
||||
"milestones": milestones,
|
||||
"environments": environments,
|
||||
"gates": gates
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.with_state(app_state);
|
||||
|
||||
tracing::info!("MCP Memory Server running on http://127.0.0.1:3000/sse");
|
||||
let addr = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
||||
let addr: std::net::SocketAddr = format!("127.0.0.1:{}", addr)
|
||||
.parse()
|
||||
.expect("Invalid bind address");
|
||||
|
||||
let listener = match tokio::net::TcpListener::bind(&addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
let log_path = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".gemini/mcp_memory/daemon_error.log");
|
||||
let _ =
|
||||
tokio::fs::write(&log_path, format!("Failed to bind to {}: {}\n", addr, e)).await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Err(e) = axum::serve(listener, app.into_make_service()).await {
|
||||
let log_path = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".gemini/mcp_memory/daemon_error.log");
|
||||
let _ = tokio::fs::write(&log_path, format!("Server crashed: {}\n", e)).await;
|
||||
}
|
||||
+42
-24
@@ -44,8 +44,7 @@ pub fn find_projected_knowledge(
|
||||
|| normalized_file.ends_with(&format!("/{}", ndfp.trim_start_matches('/')))
|
||||
|| ndfp.ends_with(&format!("/{}", normalized_file.trim_start_matches('/')))
|
||||
|| (!file_name.is_empty()
|
||||
&& (ndfp == file_name
|
||||
|| ndfp.ends_with(&format!("/{}", file_name))))
|
||||
&& (ndfp == file_name || ndfp.ends_with(&format!("/{}", file_name))))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -73,8 +72,7 @@ pub fn find_projected_knowledge(
|
||||
all_adrs
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
a.status.eq_ignore_ascii_case("accepted")
|
||||
|| a.status.eq_ignore_ascii_case("active")
|
||||
a.status.eq_ignore_ascii_case("accepted") || a.status.eq_ignore_ascii_case("active")
|
||||
})
|
||||
.filter(|a| {
|
||||
for comp in &a.affected_components {
|
||||
@@ -189,31 +187,47 @@ pub async fn nvim_telemetry_handler(
|
||||
|
||||
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
|
||||
let payload_val = serde_json::json!(&payload);
|
||||
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}", payload.event),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
|
||||
if let Some(ref req_id) = payload.request_id {
|
||||
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
|
||||
let _ = state
|
||||
.handler
|
||||
.state
|
||||
.event_bus_tx
|
||||
.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}", payload.event),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
|
||||
if let Some(ref req_id) = payload.request_id {
|
||||
let _ = state
|
||||
.handler
|
||||
.state
|
||||
.event_bus_tx
|
||||
.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref diff_id) = payload.diff_id {
|
||||
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:diff_decision:{}", diff_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
let _ = state
|
||||
.handler
|
||||
.state
|
||||
.event_bus_tx
|
||||
.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
let _ = state
|
||||
.handler
|
||||
.state
|
||||
.event_bus_tx
|
||||
.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:diff_decision:{}", diff_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +244,11 @@ pub async fn nvim_telemetry_handler(
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.is_active())
|
||||
.or_else(|| tasks.iter().find(|t| t.status == "pending" && t.parent_id.is_none()))
|
||||
.or_else(|| {
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.status == "pending" && t.parent_id.is_none())
|
||||
})
|
||||
.map(|t| t.title.clone())
|
||||
});
|
||||
|
||||
|
||||
+32
-27
@@ -66,19 +66,19 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
|
||||
activities
|
||||
.iter()
|
||||
.filter_map(|act_val| {
|
||||
if act_val["category"] == "TASK_EVENT" {
|
||||
if let Some(details_str) = act_val["details"].as_str() {
|
||||
if let Ok(event_val) = serde_json::from_str::<serde_json::Value>(details_str) {
|
||||
return Some(
|
||||
serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/task/completed",
|
||||
"params": event_val
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if act_val["category"] == "TASK_EVENT"
|
||||
&& let Some(details_str) = act_val["details"].as_str()
|
||||
&& let Ok(event_val) =
|
||||
serde_json::from_str::<serde_json::Value>(details_str)
|
||||
{
|
||||
return Some(
|
||||
serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/task/completed",
|
||||
"params": event_val
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
None
|
||||
})
|
||||
@@ -262,7 +262,10 @@ mod tests {
|
||||
mem_state.broadcast_task_event(task_event.clone());
|
||||
|
||||
// Verify recent activities recorded the event
|
||||
let recorded = mem_state.telemetry.recent_activities.read_with(|act| act.clone());
|
||||
let recorded = mem_state
|
||||
.telemetry
|
||||
.recent_activities
|
||||
.read_with(|act| act.clone());
|
||||
assert!(!recorded.is_empty());
|
||||
assert_eq!(recorded[0]["category"], "TASK_EVENT");
|
||||
|
||||
@@ -292,18 +295,17 @@ mod tests {
|
||||
.iter()
|
||||
.filter_map(|act_val| {
|
||||
if act_val["category"] == "TASK_EVENT" {
|
||||
if let Some(details_str) = act_val["details"].as_str() {
|
||||
if let Ok(event_val) = serde_json::from_str::<serde_json::Value>(details_str) {
|
||||
return Some(
|
||||
serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/task/completed",
|
||||
"params": event_val
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let details_str = act_val["details"].as_str()?;
|
||||
let event_val =
|
||||
serde_json::from_str::<serde_json::Value>(details_str).ok()?;
|
||||
return Some(
|
||||
serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/task/completed",
|
||||
"params": event_val
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
None
|
||||
})
|
||||
@@ -315,7 +317,10 @@ mod tests {
|
||||
let _ = tx.try_send(notif);
|
||||
}
|
||||
|
||||
let replayed_msg = rx.recv().await.expect("Expected replayed task event notification");
|
||||
let replayed_msg = rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("Expected replayed task event notification");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&replayed_msg).unwrap();
|
||||
assert_eq!(parsed["method"], "notifications/task/completed");
|
||||
assert_eq!(parsed["params"]["task_id"], "task-999");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use mcp_memory_server::router::MemoryHandler;
|
||||
use mcp_memory_server::state::MemoryState;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use mcp_memory_server::router::MemoryHandler;
|
||||
use mcp_memory_server::state::MemoryState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -13,7 +13,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let handler = MemoryHandler::new(state);
|
||||
|
||||
let win_dir = PathBuf::from(r"C:\Users\reazul.ashraf\.gemini\antigravity-cli\mcp\mcp-memory");
|
||||
let wsl_dir = PathBuf::from(r"\\wsl.localhost\Ubuntu\home\riz\.gemini\antigravity-cli\mcp\mcp-memory");
|
||||
let wsl_dir =
|
||||
PathBuf::from(r"\\wsl.localhost\Ubuntu\home\riz\.gemini\antigravity-cli\mcp\mcp-memory");
|
||||
|
||||
fs::create_dir_all(&win_dir)?;
|
||||
if !wsl_dir.exists() {
|
||||
@@ -41,13 +42,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Ok(entries) = fs::read_dir(&win_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |ext| ext == "json") {
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
if !active_tools.contains(stem) {
|
||||
println!(" [STALE REMOVED Win] {}", path.display());
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
if path.extension().is_some_and(|ext| ext == "json")
|
||||
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
|
||||
&& !active_tools.contains(stem)
|
||||
{
|
||||
println!(" [STALE REMOVED Win] {}", path.display());
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,23 +56,25 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Ok(entries) = fs::read_dir(&wsl_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |ext| ext == "json") {
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
if !active_tools.contains(stem) {
|
||||
println!(" [STALE REMOVED WSL] {}", path.display());
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
if path.extension().is_some_and(|ext| ext == "json")
|
||||
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
|
||||
&& !active_tools.contains(stem)
|
||||
{
|
||||
println!(" [STALE REMOVED WSL] {}", path.display());
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync instructions.md
|
||||
let instructions_src = PathBuf::from(r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\instructions.md");
|
||||
let instructions_src =
|
||||
PathBuf::from(r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\instructions.md");
|
||||
if instructions_src.exists() {
|
||||
let _ = fs::copy(&instructions_src, win_dir.join("instructions.md"));
|
||||
let _ = fs::copy(&instructions_src, wsl_dir.join("instructions.md"));
|
||||
let server_instructions = PathBuf::from(r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\instructions.md");
|
||||
let server_instructions = PathBuf::from(
|
||||
r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\instructions.md",
|
||||
);
|
||||
let _ = fs::copy(&instructions_src, server_instructions);
|
||||
println!(" [OK] Synchronized instructions.md to Win, WSL, and server/src/instructions.md");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
let mut img = image::DynamicImage::new_rgb8(10, 10);
|
||||
img.invert();
|
||||
let _img2 = img.adjust_contrast(20.0);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use serde_json::Value;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
pub fn load_mcp_config_env() {
|
||||
let config_path = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gemini/config/mcp_config.json");
|
||||
|
||||
if !config_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let config_str = match fs::read_to_string(&config_path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let parsed: Value = match serde_json::from_str(&config_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Some(env_obj) = parsed
|
||||
.get("mcpServers")
|
||||
.and_then(|v| v.get("mcp-memory"))
|
||||
.and_then(|v| v.get("env"))
|
||||
.and_then(|v| v.as_object())
|
||||
{
|
||||
for (key, val) in env_obj {
|
||||
if let Some(val_str) = val.as_str()
|
||||
&& env::var(key).is_err()
|
||||
{
|
||||
unsafe {
|
||||
env::set_var(key, val_str);
|
||||
}
|
||||
info!("Loaded env var {} from mcp_config.json", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-59
@@ -117,77 +117,22 @@ pub fn init_redb(base: &Path) -> Arc<Database> {
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure the table exists and migrate legacy JSON files
|
||||
// Ensure the table exists
|
||||
match db.begin_write() {
|
||||
Ok(write_txn) => {
|
||||
let mut opened_ok = false;
|
||||
if let Ok(mut table) = write_txn.open_table(STORE_TABLE) {
|
||||
if let Ok(_) = write_txn.open_table(STORE_TABLE) {
|
||||
opened_ok = true;
|
||||
if !is_in_memory {
|
||||
let stores = vec![
|
||||
("knowledge_graph_master", "knowledge_graph_master.json"),
|
||||
("audit_ledger", "audit_ledger.json"),
|
||||
("tasks", "tasks.json"),
|
||||
("snippets", "snippets.json"),
|
||||
("adrs", "adrs.json"),
|
||||
("error_fixes", "error_fixes.json"),
|
||||
("session_summaries", "session_summaries.json"),
|
||||
("handoff_memos", "handoff_memos.json"),
|
||||
("env_fingerprints", "env_fingerprints.json"),
|
||||
("env_requirements", "env_requirements.json"),
|
||||
("milestones", "milestones.json"),
|
||||
("environments", "environments.json"),
|
||||
("tech_debts", "tech_debts.json"),
|
||||
("gates", "gates.json"),
|
||||
("state_snapshots", "state_snapshots.json"),
|
||||
("hypotheses", "hypotheses.json"),
|
||||
("agent_signals", "agent_signals.json"),
|
||||
];
|
||||
|
||||
for (key, file_name) in stores.iter() {
|
||||
let is_missing = match table.get(*key) {
|
||||
Ok(res) => res.is_none(),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read key '{}' from redb: {}", key, e);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if is_missing {
|
||||
let json_path = base.join(file_name);
|
||||
if json_path.exists()
|
||||
&& let Ok(data) = std::fs::read(&json_path)
|
||||
&& serde_json::from_slice::<serde_json::Value>(&data).is_ok()
|
||||
{
|
||||
if let Err(e) = table.insert(*key, data.as_slice()) {
|
||||
tracing::error!(
|
||||
"Failed to insert migrated key '{}': {}",
|
||||
key,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if opened_ok {
|
||||
if let Err(e) = write_txn.commit() {
|
||||
tracing::error!("Failed to commit database migration transaction: {}", e);
|
||||
tracing::error!("Failed to commit database initialization transaction: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to begin write transaction for redb migration: {}",
|
||||
"Failed to begin write transaction for redb initialization: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,14 +193,14 @@ fn fallback_embed(text: &str) -> Vec<f32> {
|
||||
}
|
||||
|
||||
enum EmbeddingModel {
|
||||
Candle(CandleEmbeddingModel),
|
||||
Candle(Box<CandleEmbeddingModel>),
|
||||
Fallback,
|
||||
}
|
||||
|
||||
impl EmbeddingModel {
|
||||
fn new() -> Self {
|
||||
match CandleEmbeddingModel::new() {
|
||||
Ok(model) => EmbeddingModel::Candle(model),
|
||||
Ok(model) => EmbeddingModel::Candle(Box::new(model)),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to initialize Candle BERT model ({e}); falling back to deterministic offline embeddings."
|
||||
@@ -259,13 +259,10 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
let mut norm_a_sq = 0.0f32;
|
||||
let mut norm_b_sq = 0.0f32;
|
||||
|
||||
let chunks_a = a.chunks_exact(8);
|
||||
let chunks_b = b.chunks_exact(8);
|
||||
let (chunks_a, remainder_a) = a.as_chunks::<8>();
|
||||
let (chunks_b, remainder_b) = b.as_chunks::<8>();
|
||||
|
||||
let remainder_a = chunks_a.remainder();
|
||||
let remainder_b = chunks_b.remainder();
|
||||
|
||||
for (ca, cb) in chunks_a.zip(chunks_b) {
|
||||
for (ca, cb) in chunks_a.iter().zip(chunks_b.iter()) {
|
||||
for i in 0..8 {
|
||||
let x = ca[i];
|
||||
let y = cb[i];
|
||||
|
||||
+20
-3
@@ -17,7 +17,11 @@ pub enum AppError {
|
||||
EntityNotFound { name: String, namespace: String },
|
||||
|
||||
#[error("Invalid relation between '{from}' and '{to}': {reason}")]
|
||||
InvalidRelation { from: String, to: String, reason: String },
|
||||
InvalidRelation {
|
||||
from: String,
|
||||
to: String,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("Database error for key '{key}': {message}")]
|
||||
DatabaseError { key: String, message: String },
|
||||
@@ -36,6 +40,9 @@ pub enum AppError {
|
||||
|
||||
#[error("I/O Error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("YAML Serialization Error: {0}")]
|
||||
YamlError(#[from] serde_yaml::Error),
|
||||
}
|
||||
|
||||
impl From<AppError> for String {
|
||||
@@ -50,11 +57,17 @@ impl IntoResponse for AppError {
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
AppError::EntityNotFound { name, namespace } => (
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Entity '{}' was not found in namespace '{}'", name, namespace),
|
||||
format!(
|
||||
"Entity '{}' was not found in namespace '{}'",
|
||||
name, namespace
|
||||
),
|
||||
),
|
||||
AppError::InvalidRelation { from, to, reason } => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid relation between '{}' and '{}': {}", from, to, reason),
|
||||
format!(
|
||||
"Invalid relation between '{}' and '{}': {}",
|
||||
from, to, reason
|
||||
),
|
||||
),
|
||||
AppError::DatabaseError { key, message } => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -71,6 +84,10 @@ impl IntoResponse for AppError {
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("I/O Error: {}", e),
|
||||
),
|
||||
AppError::YamlError(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("YAML Serialization Error: {}", e),
|
||||
),
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
|
||||
+11
-11
@@ -119,10 +119,10 @@ impl McpTool for ReadFileSkeletonHandler {
|
||||
header.push('\n');
|
||||
}
|
||||
}
|
||||
if header.is_empty() {
|
||||
if let Some(first_line) = node_text.lines().next() {
|
||||
header = first_line.to_string();
|
||||
}
|
||||
if header.is_empty()
|
||||
&& let Some(first_line) = node_text.lines().next()
|
||||
{
|
||||
header = first_line.to_string();
|
||||
}
|
||||
|
||||
out.push_str(&indent);
|
||||
@@ -425,10 +425,10 @@ fn scan_workspace_for_symbol(
|
||||
if scanned_files > 500 {
|
||||
break;
|
||||
}
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
if meta.len() > 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
if let Ok(meta) = std::fs::metadata(path)
|
||||
&& meta.len() > 1024 * 1024
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(content) = std::fs::read_to_string(path) {
|
||||
for (line_num, line) in content.lines().enumerate() {
|
||||
@@ -506,7 +506,7 @@ impl McpTool for FindSymbolReferencesHandler {
|
||||
matches.extend(disk_matches);
|
||||
}
|
||||
|
||||
Ok(serde_json::to_string_pretty(&matches)?)
|
||||
Ok(serde_yaml::to_string(&matches)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,7 +562,7 @@ impl McpTool for GetCallersHandler {
|
||||
callers.extend(disk_callers);
|
||||
}
|
||||
|
||||
Ok(serde_json::to_string_pretty(&callers)?)
|
||||
Ok(serde_yaml::to_string(&callers)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,7 +651,7 @@ impl McpTool for AnalyzeImpactHandler {
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
Ok(serde_yaml::to_string(&result)?)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-19
@@ -22,16 +22,31 @@ impl McpTool for EnvironmentHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: EnvironmentTool = 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 {
|
||||
EnvAction::UpdateFingerprint => {
|
||||
let tool_versions = req.tool_versions.unwrap_or_default();
|
||||
let detected_os = req.os.unwrap_or_else(|| std::env::consts::OS.to_string());
|
||||
let detected_shell = req.shell
|
||||
let detected_shell = req
|
||||
.shell
|
||||
.or_else(|| std::env::var("SHELL").ok())
|
||||
.or_else(|| std::env::var("STARSHIP_SHELL").ok())
|
||||
.or_else(|| if std::env::var("PSModulePath").is_ok() { Some("pwsh".to_string()) } else { None })
|
||||
.or_else(|| if std::env::var("COMSPEC").is_ok() { Some("cmd".to_string()) } else { None })
|
||||
.or_else(|| {
|
||||
if std::env::var("PSModulePath").is_ok() {
|
||||
Some("pwsh".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
if std::env::var("COMSPEC").is_ok() {
|
||||
Some("cmd".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
state.env.env_fingerprints.modify(|fps| {
|
||||
@@ -55,7 +70,7 @@ impl McpTool for EnvironmentHandler {
|
||||
.env_fingerprints
|
||||
.read_with(|fps| fps.get(&ns).cloned());
|
||||
if let Some(fp) = data {
|
||||
let data = Ok::<String, crate::error::AppError>(serde_json::to_string(&fp)?)?;
|
||||
let data = Ok::<String, crate::error::AppError>(serde_yaml::to_string(&fp)?)?;
|
||||
Ok(data)
|
||||
} else {
|
||||
Ok("{}".to_string())
|
||||
@@ -102,11 +117,8 @@ impl McpTool for EnvironmentHandler {
|
||||
}
|
||||
EnvAction::GetDetails => {
|
||||
let data = state.env.environments.read_with(|envs| {
|
||||
let filtered: Vec<_> = envs
|
||||
.iter()
|
||||
.filter(|e| e.namespace == ns)
|
||||
.collect();
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
||||
let filtered: Vec<_> = envs.iter().filter(|e| e.namespace == ns).collect();
|
||||
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -114,7 +126,6 @@ impl McpTool for EnvironmentHandler {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -143,7 +154,10 @@ mod tests {
|
||||
assert_eq!(res, "Env fingerprint updated");
|
||||
|
||||
let res2 = handler
|
||||
.execute(json!({"action": "read_fingerprint", "namespace": "global"}), state.clone())
|
||||
.execute(
|
||||
json!({"action": "read_fingerprint", "namespace": "global"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
@@ -171,7 +185,10 @@ mod tests {
|
||||
|
||||
let handler = EnvironmentHandler;
|
||||
let res = handler
|
||||
.execute(json!({"action": "get_details", "namespace": "global"}), state.clone())
|
||||
.execute(
|
||||
json!({"action": "get_details", "namespace": "global"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
@@ -215,7 +232,10 @@ mod tests {
|
||||
assert_eq!(res2, "Environment registered");
|
||||
|
||||
let res3 = handler
|
||||
.execute(json!({"action": "get_details", "namespace": "global"}), state.clone())
|
||||
.execute(
|
||||
json!({"action": "get_details", "namespace": "global"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
@@ -237,12 +257,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let res = handler
|
||||
.execute(args, state.clone())
|
||||
.await;
|
||||
let res = handler.execute(args, state.clone()).await;
|
||||
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-24
@@ -87,7 +87,7 @@ impl McpTool for GetActiveWorktreeContextHandler {
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&result)?)
|
||||
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&result)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,25 +139,25 @@ impl McpTool for QueryGitDiffsHandler {
|
||||
let git_matches = tokio::task::spawn_blocking(move || {
|
||||
let mut results = Vec::new();
|
||||
let cwd = env::current_dir().unwrap_or_default();
|
||||
if let Ok(repo) = git2::Repository::discover(&cwd) {
|
||||
if let Ok(mut revwalk) = repo.revwalk() {
|
||||
let _ = revwalk.push_head();
|
||||
let mut count = 0;
|
||||
for oid in revwalk.flatten() {
|
||||
if count >= remaining {
|
||||
break;
|
||||
}
|
||||
if let Ok(commit) = repo.find_commit(oid) {
|
||||
let summary = commit.summary().unwrap_or("");
|
||||
if summary.to_lowercase().contains(&q) {
|
||||
count += 1;
|
||||
results.push(json!({
|
||||
"commit_id": oid.to_string(),
|
||||
"author": commit.author().name().unwrap_or("unknown"),
|
||||
"message": summary,
|
||||
"timestamp": commit.time().seconds(),
|
||||
}));
|
||||
}
|
||||
if let Ok(repo) = git2::Repository::discover(&cwd)
|
||||
&& let Ok(mut revwalk) = repo.revwalk()
|
||||
{
|
||||
let _ = revwalk.push_head();
|
||||
let mut count = 0;
|
||||
for oid in revwalk.flatten() {
|
||||
if count >= remaining {
|
||||
break;
|
||||
}
|
||||
if let Ok(commit) = repo.find_commit(oid) {
|
||||
let summary = commit.summary().unwrap_or("");
|
||||
if summary.to_lowercase().contains(&q) {
|
||||
count += 1;
|
||||
results.push(json!({
|
||||
"commit_id": oid.to_string(),
|
||||
"author": commit.author().name().unwrap_or("unknown"),
|
||||
"message": summary,
|
||||
"timestamp": commit.time().seconds(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ impl McpTool for QueryGitDiffsHandler {
|
||||
matches.extend(git_matches);
|
||||
}
|
||||
|
||||
Ok(serde_json::to_string_pretty(&matches)?)
|
||||
Ok(serde_yaml::to_string(&matches)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ mod tests {
|
||||
.map_err(|e| format!("Failed to get worktree context: {}", e))
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
let parsed: serde_json::Value = serde_yaml::from_str(&result).unwrap();
|
||||
assert!(parsed.get("branch").is_some());
|
||||
assert!(parsed.get("modified_files").is_some());
|
||||
assert!(parsed.get("diff").is_some());
|
||||
@@ -211,8 +211,7 @@ mod tests {
|
||||
.map_err(|e| format!("Failed to get worktree context: {}", e))
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
let parsed: serde_json::Value = serde_yaml::from_str(&result).unwrap();
|
||||
assert!(parsed.get("branch").is_some() || parsed.is_object());
|
||||
}
|
||||
}
|
||||
|
||||
+177
-89
@@ -62,15 +62,15 @@ impl<'a> GraphQueryBuilder<'a> {
|
||||
break;
|
||||
}
|
||||
nodes_at_current_depth -= 1;
|
||||
if current_depth < self.max_depth {
|
||||
if let Some(neighbors) = adj.get(current) {
|
||||
for &(neighbor, rel_type, is_inverse) in neighbors {
|
||||
if !visited.contains(neighbor) {
|
||||
visited.insert(neighbor);
|
||||
parents.insert(neighbor, (current, rel_type, is_inverse));
|
||||
queue.push_back(neighbor);
|
||||
nodes_at_next_depth += 1;
|
||||
}
|
||||
if current_depth < self.max_depth
|
||||
&& let Some(neighbors) = adj.get(current)
|
||||
{
|
||||
for &(neighbor, rel_type, is_inverse) in neighbors {
|
||||
if !visited.contains(neighbor) {
|
||||
visited.insert(neighbor);
|
||||
parents.insert(neighbor, (current, rel_type, is_inverse));
|
||||
queue.push_back(neighbor);
|
||||
nodes_at_next_depth += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,9 @@ impl McpTool for QueryGraphPathHandler {
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Graph traversal task failed: {}", e)))?
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::Internal(format!("Graph traversal task failed: {}", e))
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,12 +171,19 @@ impl McpTool for CreateEntitiesHandler {
|
||||
}
|
||||
}
|
||||
});
|
||||
let names: Vec<String> = inserted.iter().map(|e| format!("{} ({})", e.name, e.entity_type)).collect();
|
||||
let names: Vec<String> = inserted
|
||||
.iter()
|
||||
.map(|e| format!("{} ({})", e.name, e.entity_type))
|
||||
.collect();
|
||||
if !inserted.is_empty() {
|
||||
let idx = state.get_search_index().await;
|
||||
let _ = idx.index_entities_batch(&inserted).await;
|
||||
}
|
||||
Ok(format!("Successfully created {} entity/entities: {}", names.len(), names.join(", ")))
|
||||
Ok(format!(
|
||||
"Successfully created {} entity/entities: {}",
|
||||
names.len(),
|
||||
names.join(", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +245,9 @@ impl McpTool for CreateRelationsHandler {
|
||||
crate::models::Entity {
|
||||
name: node_name.clone(),
|
||||
entity_type: "Entity".to_string(),
|
||||
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(),
|
||||
git_branch: None,
|
||||
..Default::default()
|
||||
@@ -247,16 +258,28 @@ impl McpTool for CreateRelationsHandler {
|
||||
}
|
||||
for mut relation in req.relations {
|
||||
if !relation.from.is_empty() && !relation.to.is_empty() {
|
||||
relation.relation_type = crate::models::normalize_relation_type(&relation.relation_type);
|
||||
added_relations.push(format!("{} -[{}]-> {}", relation.from, relation.relation_type, relation.to));
|
||||
relation.relation_type =
|
||||
crate::models::normalize_relation_type(&relation.relation_type);
|
||||
added_relations.push(format!(
|
||||
"{} -[{}]-> {}",
|
||||
relation.from, relation.relation_type, relation.to
|
||||
));
|
||||
g.relations.push(relation);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut msg = format!("Successfully created {} relation(s):\n{}", added_relations.len(), added_relations.join("\n"));
|
||||
let mut msg = format!(
|
||||
"Successfully created {} relation(s):\n{}",
|
||||
added_relations.len(),
|
||||
added_relations.join("\n")
|
||||
);
|
||||
if !auto_created.is_empty() {
|
||||
msg.push_str(&format!("\nNote: Auto-created {} missing stub entity/entities: {}", auto_created.len(), auto_created.join(", ")));
|
||||
msg.push_str(&format!(
|
||||
"\nNote: Auto-created {} missing stub entity/entities: {}",
|
||||
auto_created.len(),
|
||||
auto_created.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(msg)
|
||||
}
|
||||
@@ -462,7 +485,7 @@ impl McpTool for ReadGraphHandler {
|
||||
let filtered_entities: Vec<(&String, &crate::models::Entity)> = full
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|(_, v)| req.namespace.as_ref().map_or(true, |ns| &v.namespace == ns))
|
||||
.filter(|(_, v)| req.namespace.as_ref().is_none_or(|ns| &v.namespace == ns))
|
||||
.collect();
|
||||
|
||||
let total_count = filtered_entities.len();
|
||||
@@ -481,7 +504,7 @@ impl McpTool for ReadGraphHandler {
|
||||
.relations
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
let ns_match = req.namespace.as_ref().map_or(true, |ns| &r.namespace == ns);
|
||||
let ns_match = req.namespace.as_ref().is_none_or(|ns| &r.namespace == ns);
|
||||
ns_match && (paged_entities.contains_key(&r.from) || paged_entities.contains_key(&r.to))
|
||||
})
|
||||
.collect();
|
||||
@@ -499,7 +522,7 @@ impl McpTool for ReadGraphHandler {
|
||||
let rel_vec: Vec<&crate::models::Relation> = full
|
||||
.relations
|
||||
.iter()
|
||||
.filter(|r| req.namespace.as_ref().map_or(true, |ns| &r.namespace == ns))
|
||||
.filter(|r| req.namespace.as_ref().is_none_or(|ns| &r.namespace == ns))
|
||||
.collect();
|
||||
(ent_map, rel_vec, None)
|
||||
};
|
||||
@@ -520,7 +543,7 @@ impl McpTool for ReadGraphHandler {
|
||||
})
|
||||
};
|
||||
|
||||
let mut result_json = serde_json::to_string(&serialized)?;
|
||||
let mut result_json = serde_yaml::to_string(&serialized)?;
|
||||
|
||||
if let Some(max_tok) = max_tokens {
|
||||
let max_chars = max_tok * 4;
|
||||
@@ -549,21 +572,19 @@ impl McpTool for ReadGraphHandler {
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(meta) = &meta_json {
|
||||
if let Some(obj) = truncated_val.as_object_mut() {
|
||||
if let Some(meta) = &meta_json
|
||||
&& let Some(obj) = truncated_val.as_object_mut() {
|
||||
obj.insert("total_count".to_string(), meta["total_count"].clone());
|
||||
obj.insert("offset".to_string(), meta["offset"].clone());
|
||||
obj.insert("limit".to_string(), meta["limit"].clone());
|
||||
obj.insert("has_more".to_string(), serde_json::Value::Bool(true));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(trunc_str) = serde_json::to_string(&truncated_val) {
|
||||
if trunc_str.len() <= max_chars || keep_count == 1 {
|
||||
if let Ok(trunc_str) = serde_yaml::to_string(&truncated_val)
|
||||
&& (trunc_str.len() <= max_chars || keep_count == 1) {
|
||||
result_json = trunc_str;
|
||||
break;
|
||||
}
|
||||
}
|
||||
keep_count = keep_count.saturating_sub(keep_count / 3 + 1);
|
||||
}
|
||||
}
|
||||
@@ -618,7 +639,7 @@ impl McpTool for SearchNodesHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&matched_entities)?)
|
||||
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&matched_entities)?)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -659,7 +680,7 @@ impl McpTool for OpenNodesHandler {
|
||||
result.entities.insert(name, e);
|
||||
}
|
||||
}
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&result)?)
|
||||
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&result)?)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -804,7 +825,7 @@ impl McpTool for CondenseEntityHandler {
|
||||
req.entity_name,
|
||||
unique_obs.join("\n- ")
|
||||
);
|
||||
if let Ok(summary) = state.ollama.generate(&prompt, None, None).await {
|
||||
if let Ok(summary) = state.ollama.generate(&prompt, None, None, None).await {
|
||||
let lines: Vec<String> = summary
|
||||
.lines()
|
||||
.map(|l| l.trim().trim_start_matches('-').trim().to_string())
|
||||
@@ -820,7 +841,10 @@ impl McpTool for CondenseEntityHandler {
|
||||
e.observations = unique_obs;
|
||||
}
|
||||
});
|
||||
return Ok(format!("Entity '{}' observations condensed with deduplication/AI summary.", req.entity_name));
|
||||
return Ok(format!(
|
||||
"Entity '{}' observations condensed with deduplication/AI summary.",
|
||||
req.entity_name
|
||||
));
|
||||
}
|
||||
|
||||
state.modify_graph(|master| {
|
||||
@@ -931,7 +955,7 @@ impl McpTool for FindOrphansHandler {
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
});
|
||||
let data = Ok::<String, crate::error::AppError>(serde_json::to_string(&orphans)?)?;
|
||||
let data = Ok::<String, crate::error::AppError>(serde_yaml::to_string(&orphans)?)?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
@@ -953,10 +977,9 @@ impl McpTool for GetSubgraphHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: GetSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let root = req
|
||||
.root_entity
|
||||
.or(req.root_node)
|
||||
.ok_or_else(|| crate::error::AppError::Internal("root_entity or root_node is required".to_string()))?;
|
||||
let root = req.root_entity.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("root_entity is required".to_string())
|
||||
})?;
|
||||
let depth = req.depth.unwrap_or(2);
|
||||
let format = req.format.unwrap_or(SubgraphFormat::Json);
|
||||
|
||||
@@ -974,12 +997,12 @@ impl McpTool for GetSubgraphHandler {
|
||||
visited.insert(root.clone());
|
||||
|
||||
while let Some((curr, d)) = queue.pop_front() {
|
||||
if d < depth {
|
||||
if let Some(neighbors) = adj.get(curr) {
|
||||
for &next in neighbors {
|
||||
if visited.insert(next.to_string()) {
|
||||
queue.push_back((next, d + 1));
|
||||
}
|
||||
if d < depth
|
||||
&& let Some(neighbors) = adj.get(curr)
|
||||
{
|
||||
for &next in neighbors {
|
||||
if visited.insert(next.to_string()) {
|
||||
queue.push_back((next, d + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1011,19 +1034,22 @@ impl McpTool for GetSubgraphHandler {
|
||||
|
||||
markdown.push_str("\n## Relations\n");
|
||||
for r in &matched_relations {
|
||||
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to));
|
||||
markdown.push_str(&format!(
|
||||
"- `{}` --[{}]--> `{}`\n",
|
||||
r.from, r.relation_type, r.to
|
||||
));
|
||||
}
|
||||
return Ok(markdown);
|
||||
}
|
||||
|
||||
let result = serde_json::json!({
|
||||
"root_node": root,
|
||||
"root_entity": root,
|
||||
"depth": depth,
|
||||
"entities": matched_entities,
|
||||
"relations": matched_relations,
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
Ok(serde_yaml::to_string(&result)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1038,7 +1064,10 @@ impl McpTool for SweepGraphHealthHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<SweepGraphHealthTool>("sweep_graph_health", "Audit and sweep graph health")
|
||||
crate::mcp::tool_def::<SweepGraphHealthTool>(
|
||||
"sweep_graph_health",
|
||||
"Audit and sweep graph health",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -1119,7 +1148,7 @@ impl McpTool for SweepGraphHealthHandler {
|
||||
"health_score": if orphans.is_empty() && duplicates.is_empty() && stale_entities.is_empty() { "100%" } else { "Needs Maintenance" }
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&report)?)
|
||||
Ok(serde_yaml::to_string(&report)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1132,26 +1161,33 @@ impl McpTool for ResolveStaleSymbolsHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<ResolveStaleSymbolsTool>("resolve_stale_symbols", "Inspect Knowledge Graph entities and tech debt symbol/line references against files on disk, healing stale pointers.")
|
||||
crate::mcp::tool_def::<ResolveStaleSymbolsTool>(
|
||||
"resolve_stale_symbols",
|
||||
"Inspect Knowledge Graph entities and tech debt symbol/line references against files on disk, healing stale pointers.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ResolveStaleSymbolsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let req: ResolveStaleSymbolsTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let auto_heal = req.auto_heal.unwrap_or(true);
|
||||
|
||||
let mut checked = 0;
|
||||
let mut stale_entities = Vec::new();
|
||||
|
||||
state.modify_graph(|g| {
|
||||
for (name, _entity) in g.entities.iter_mut() {
|
||||
if let Some(target) = &req.target {
|
||||
if name != target && !name.contains(target) {
|
||||
continue;
|
||||
}
|
||||
for name in g.entities.keys() {
|
||||
if let Some(target) = &req.target
|
||||
&& name != target
|
||||
&& !name.contains(target)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
let path = std::path::Path::new(name);
|
||||
if (name.contains('/') || name.contains('\\') || name.contains('.')) && !path.exists() {
|
||||
if (name.contains('/') || name.contains('\\') || name.contains('.'))
|
||||
&& !path.exists()
|
||||
{
|
||||
stale_entities.push(name.clone());
|
||||
}
|
||||
}
|
||||
@@ -1171,7 +1207,7 @@ impl McpTool for ResolveStaleSymbolsHandler {
|
||||
"status": "success"
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&report)?)
|
||||
Ok(serde_yaml::to_string(&report)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1184,7 +1220,10 @@ impl McpTool for SummarizeSubgraphHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<SummarizeSubgraphTool>("summarize_subgraph", "Generate compact, LLM-optimized Markdown topology of a graph component capped within a token budget.")
|
||||
crate::mcp::tool_def::<SummarizeSubgraphTool>(
|
||||
"summarize_subgraph",
|
||||
"Generate compact, LLM-optimized Markdown topology of a graph component capped within a token budget.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -1212,8 +1251,18 @@ impl McpTool for SummarizeSubgraphHandler {
|
||||
}
|
||||
}
|
||||
|
||||
let ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect();
|
||||
let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect();
|
||||
let ent: Vec<_> = g
|
||||
.entities
|
||||
.values()
|
||||
.filter(|e| visited.contains(&e.name))
|
||||
.cloned()
|
||||
.collect();
|
||||
let rel: Vec<_> = g
|
||||
.relations
|
||||
.iter()
|
||||
.filter(|r| visited.contains(&r.from) && visited.contains(&r.to))
|
||||
.cloned()
|
||||
.collect();
|
||||
(ent, rel)
|
||||
});
|
||||
|
||||
@@ -1228,7 +1277,10 @@ impl McpTool for SummarizeSubgraphHandler {
|
||||
|
||||
markdown.push_str("\n## Relations\n");
|
||||
for r in &sub_relations {
|
||||
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to));
|
||||
markdown.push_str(&format!(
|
||||
"- `{}` --[{}]--> `{}`\n",
|
||||
r.from, r.relation_type, r.to
|
||||
));
|
||||
}
|
||||
|
||||
let max_tokens = req.max_tokens.unwrap_or(1000);
|
||||
@@ -1249,7 +1301,6 @@ mod tests {
|
||||
use crate::handlers::meta::AgentSignalsHandler;
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_read_entities() {
|
||||
let state = Arc::new(MemoryState::new_in_memory());
|
||||
@@ -1324,10 +1375,7 @@ mod tests {
|
||||
{"source": "A", "target": "B", "relationType": "knows"}
|
||||
]
|
||||
});
|
||||
let alias_res = handler
|
||||
.execute(alias_args, state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let alias_res = handler.execute(alias_args, state.clone()).await.unwrap();
|
||||
assert!(alias_res.contains("Successfully created 1 relation(s)"));
|
||||
|
||||
// Test semantic LLM schema feedback on missing fields
|
||||
@@ -1499,7 +1547,10 @@ mod tests {
|
||||
{"name": "Beta", "entity_type": "Node", "observations": ["Obs3"], "namespace": "global"}
|
||||
]
|
||||
});
|
||||
create_handler.execute(args_ent, state.clone()).await.unwrap();
|
||||
create_handler
|
||||
.execute(args_ent, state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rel_handler = CreateRelationsHandler;
|
||||
let args_rel = json!({
|
||||
@@ -1510,57 +1561,94 @@ mod tests {
|
||||
rel_handler.execute(args_rel, state.clone()).await.unwrap();
|
||||
|
||||
let path_handler = QueryGraphPathHandler;
|
||||
let path_res = path_handler.execute(json!({"start_node": "Alpha", "end_node": "Beta"}), state.clone()).await.unwrap();
|
||||
let path_res = path_handler
|
||||
.execute(
|
||||
json!({"start_node": "Alpha", "end_node": "Beta"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(path_res.contains("Alpha"));
|
||||
|
||||
let sub_handler = GetSubgraphHandler;
|
||||
let sub_res = sub_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
|
||||
let sub_res = sub_handler
|
||||
.execute(json!({"root_entity": "Alpha"}), state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(sub_res.contains("Alpha"));
|
||||
|
||||
let sum_handler = SummarizeSubgraphHandler;
|
||||
let sum_res = sum_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
|
||||
let sum_res = sum_handler
|
||||
.execute(json!({"root_entity": "Alpha"}), state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!sum_res.is_empty());
|
||||
|
||||
|
||||
let sweep_handler = SweepGraphHealthHandler;
|
||||
let sweep_res = sweep_handler.execute(json!({}), state.clone()).await.unwrap();
|
||||
let sweep_res = sweep_handler
|
||||
.execute(json!({}), state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!sweep_res.is_empty());
|
||||
|
||||
let del_obs_handler = DeleteObservationsHandler;
|
||||
let del_obs_res = del_obs_handler.execute(json!({
|
||||
"deletions": [{"entity_name": "Alpha", "observations": ["Obs1"]}]
|
||||
}), state.clone()).await.unwrap();
|
||||
let del_obs_res = del_obs_handler
|
||||
.execute(
|
||||
json!({
|
||||
"deletions": [{"entity_name": "Alpha", "observations": ["Obs1"]}]
|
||||
}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(del_obs_res, "Observations deleted");
|
||||
|
||||
let del_rel_handler = DeleteRelationsHandler;
|
||||
let del_rel_res = del_rel_handler.execute(json!({
|
||||
"relations": [{"from": "Alpha", "to": "Beta", "relation_type": "links_to"}]
|
||||
}), state.clone()).await.unwrap();
|
||||
let del_rel_res = del_rel_handler
|
||||
.execute(
|
||||
json!({
|
||||
"relations": [{"from": "Alpha", "to": "Beta", "relation_type": "links_to"}]
|
||||
}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(del_rel_res, "Relations deleted");
|
||||
|
||||
let bcast_handler = AgentSignalsHandler;
|
||||
let bcast_res = bcast_handler.execute(json!({
|
||||
"action": "broadcast",
|
||||
"sender": "agent1",
|
||||
"signal_type": "task_completed",
|
||||
"payload": "fix_bug"
|
||||
}), state.clone()).await.unwrap();
|
||||
let bcast_res = bcast_handler
|
||||
.execute(
|
||||
json!({
|
||||
"action": "broadcast",
|
||||
"sender": "agent1",
|
||||
"signal_type": "task_completed",
|
||||
"payload": "fix_bug"
|
||||
}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(bcast_res.contains("Broadcasted signal"));
|
||||
|
||||
let qsignal_handler = AgentSignalsHandler;
|
||||
let qsignal_res = qsignal_handler.execute(json!({
|
||||
"action": "query",
|
||||
"sender": "agent1"
|
||||
}), state.clone()).await.unwrap();
|
||||
let qsignal_res = qsignal_handler
|
||||
.execute(
|
||||
json!({
|
||||
"action": "query",
|
||||
"sender": "agent1"
|
||||
}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
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();
|
||||
let parsed_paged: serde_json::Value = serde_yaml::from_str(&paged_res).unwrap();
|
||||
assert_eq!(parsed_paged["total_count"], 2);
|
||||
assert_eq!(parsed_paged["has_more"], true);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-22
@@ -38,32 +38,34 @@ impl McpTool for ProcessLogsHandler {
|
||||
}
|
||||
ProcessLogAction::Get => {
|
||||
let max_lines = tool_args.max_lines.unwrap_or(100);
|
||||
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||
let mut file = File::open(&safe_path).map_err(|e| {
|
||||
crate::error::AppError::Internal(format!("Failed to open file: {}", e))
|
||||
})?;
|
||||
let len = file.metadata().map_err(|e| e.to_string())?.len();
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||
let mut file = File::open(&safe_path).map_err(|e| {
|
||||
crate::error::AppError::Internal(format!("Failed to open file: {}", e))
|
||||
})?;
|
||||
let len = file.metadata().map_err(|e| e.to_string())?.len();
|
||||
|
||||
let read_size = std::cmp::min(16 * 1024, len);
|
||||
file.seek(SeekFrom::End(-(read_size as i64)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let read_size = std::cmp::min(16 * 1024, len);
|
||||
file.seek(SeekFrom::End(-(read_size as i64)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut vec_buf = Vec::new();
|
||||
file.read_to_end(&mut vec_buf)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut vec_buf = Vec::new();
|
||||
file.read_to_end(&mut vec_buf).map_err(|e| e.to_string())?;
|
||||
|
||||
let buffer = String::from_utf8_lossy(&vec_buf).to_string();
|
||||
let lines: Vec<&str> = buffer.lines().collect();
|
||||
let recent_lines = if lines.len() > max_lines {
|
||||
lines[lines.len() - max_lines..].join("\n")
|
||||
} else {
|
||||
buffer
|
||||
};
|
||||
let buffer = String::from_utf8_lossy(&vec_buf).to_string();
|
||||
let lines: Vec<&str> = buffer.lines().collect();
|
||||
let recent_lines = if lines.len() > max_lines {
|
||||
lines[lines.len() - max_lines..].join("\n")
|
||||
} else {
|
||||
buffer
|
||||
};
|
||||
|
||||
Ok(recent_lines)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||
Ok(recent_lines)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::Internal(format!("Task panic: {}", e))
|
||||
})??;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
+186
-141
@@ -33,7 +33,7 @@ impl McpTool for LogErrorFixHandler {
|
||||
);
|
||||
if let Ok(summary) = state
|
||||
.ollama
|
||||
.generate(&prompt, Some(&state.ollama.reasoning_model), None)
|
||||
.generate(&prompt, Some(&state.ollama.reasoning_model), None, None)
|
||||
.await
|
||||
{
|
||||
let clean = summary.trim();
|
||||
@@ -110,10 +110,10 @@ impl McpTool for SearchErrorFixesHandler {
|
||||
} else if st_lower.contains(&sol_lower) || sol_lower.contains(&st_lower) {
|
||||
score += 0.5;
|
||||
}
|
||||
if let Some(emb) = &f.embedding {
|
||||
if !query_emb.is_empty() {
|
||||
score += crate::embedding::cosine_similarity(&query_emb, emb);
|
||||
}
|
||||
if let Some(emb) = &f.embedding
|
||||
&& !query_emb.is_empty()
|
||||
{
|
||||
score += crate::embedding::cosine_similarity(&query_emb, emb);
|
||||
}
|
||||
(f, score)
|
||||
})
|
||||
@@ -143,7 +143,7 @@ impl McpTool for SearchErrorFixesHandler {
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&suggestions)?)
|
||||
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&suggestions)?)
|
||||
})?;
|
||||
return Ok(data);
|
||||
}
|
||||
@@ -170,7 +170,7 @@ impl McpTool for SearchErrorFixesHandler {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&filtered)?)
|
||||
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -198,10 +198,10 @@ impl McpTool for LogCodeChangeHandler {
|
||||
if let Some(range) = &req.line_range {
|
||||
description = format!("{} [Line Range: {}]", description, range);
|
||||
}
|
||||
if let Some(symbols) = &req.symbol_references {
|
||||
if !symbols.is_empty() {
|
||||
description = format!("{} [Symbols: {}]", description, symbols.join(", "));
|
||||
}
|
||||
if let Some(symbols) = &req.symbol_references
|
||||
&& !symbols.is_empty()
|
||||
{
|
||||
description = format!("{} [Symbols: {}]", description, symbols.join(", "));
|
||||
}
|
||||
|
||||
if state.ollama.is_available().await {
|
||||
@@ -209,7 +209,7 @@ impl McpTool for LogCodeChangeHandler {
|
||||
"Summarize in 1 concise sentence the architectural impact of changing file '{}': {}",
|
||||
req.file_path, description
|
||||
);
|
||||
if let Ok(summary) = state.ollama.generate(&prompt, None, None).await {
|
||||
if let Ok(summary) = state.ollama.generate(&prompt, None, None, None).await {
|
||||
let clean = summary.trim();
|
||||
if !clean.is_empty() {
|
||||
description = format!("{} (AI Summary: {})", description, clean);
|
||||
@@ -246,13 +246,12 @@ impl McpTool for LogCodeChangeHandler {
|
||||
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)
|
||||
if (rev.starts_with('r') && rev[1..].chars().all(|c| c.is_ascii_digit()))
|
||||
|| req
|
||||
.repo_url
|
||||
.as_deref()
|
||||
.map(|u| u.contains("/svn/"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Some("svn".to_string())
|
||||
} else {
|
||||
@@ -323,13 +322,14 @@ impl McpTool for QueryRecentChangesHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: QueryRecentChangesTool = serde_json::from_value(args).unwrap_or(QueryRecentChangesTool {
|
||||
namespace: None,
|
||||
repo_name: None,
|
||||
vcs_type: None,
|
||||
limit: None,
|
||||
offset: None,
|
||||
});
|
||||
let req: QueryRecentChangesTool =
|
||||
serde_json::from_value(args).unwrap_or(QueryRecentChangesTool {
|
||||
namespace: None,
|
||||
repo_name: None,
|
||||
vcs_type: None,
|
||||
limit: None,
|
||||
offset: None,
|
||||
});
|
||||
let limit = req.limit.unwrap_or(50);
|
||||
let offset = req.offset.unwrap_or(0);
|
||||
let data = state.code.ledger.read_with(|l| {
|
||||
@@ -337,24 +337,23 @@ impl McpTool for QueryRecentChangesHandler {
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|c| {
|
||||
if let Some(ns) = &req.namespace {
|
||||
if !c.namespace.eq_ignore_ascii_case(ns) {
|
||||
return false;
|
||||
}
|
||||
if let Some(ns) = &req.namespace
|
||||
&& !c.namespace.eq_ignore_ascii_case(ns)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(repo) = &req.repo_name {
|
||||
if c.repo_name
|
||||
if let Some(repo) = &req.repo_name
|
||||
&& c.repo_name
|
||||
.as_ref()
|
||||
.map(|rn| !rn.eq_ignore_ascii_case(repo))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(vcs) = &req.vcs_type {
|
||||
if !c.effective_vcs().eq_ignore_ascii_case(vcs) {
|
||||
return false;
|
||||
}
|
||||
if let Some(vcs) = &req.vcs_type
|
||||
&& !c.effective_vcs().eq_ignore_ascii_case(vcs)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
@@ -362,7 +361,7 @@ impl McpTool for QueryRecentChangesHandler {
|
||||
.take(limit)
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
||||
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -386,7 +385,7 @@ impl McpTool for DecisionsHandler {
|
||||
let req: DecisionsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let ns = req
|
||||
.namespace
|
||||
.unwrap_or_else(|| crate::models::default_namespace());
|
||||
.unwrap_or_else(crate::models::default_namespace);
|
||||
match req.action {
|
||||
DecisionAction::Log => {
|
||||
let title = req.title.ok_or_else(|| {
|
||||
@@ -464,13 +463,14 @@ impl McpTool for DecisionsHandler {
|
||||
state.code.adrs.modify(|adrs| {
|
||||
let target_pos = adrs.iter().position(|a| a.id.eq_ignore_ascii_case(&id));
|
||||
if let Some(pos) = target_pos {
|
||||
if let Some(superseded_id) = &req.supersedes {
|
||||
if let Some(s_pos) = adrs.iter().position(|a| a.id.eq_ignore_ascii_case(superseded_id)) {
|
||||
if s_pos != pos {
|
||||
adrs[s_pos].status = "superseded".to_string();
|
||||
adrs_to_index.push(adrs[s_pos].clone());
|
||||
}
|
||||
}
|
||||
if let Some(superseded_id) = &req.supersedes
|
||||
&& let Some(s_pos) = adrs
|
||||
.iter()
|
||||
.position(|a| a.id.eq_ignore_ascii_case(superseded_id))
|
||||
&& s_pos != pos
|
||||
{
|
||||
adrs[s_pos].status = "superseded".to_string();
|
||||
adrs_to_index.push(adrs[s_pos].clone());
|
||||
}
|
||||
|
||||
let a = &mut adrs[pos];
|
||||
@@ -488,7 +488,9 @@ impl McpTool for DecisionsHandler {
|
||||
}
|
||||
if let Some(s) = req.status {
|
||||
let s_lower = s.to_ascii_lowercase();
|
||||
if (s_lower == "implemented" || s_lower == "resolved") && a.resolved_at.is_none() {
|
||||
if (s_lower == "implemented" || s_lower == "resolved")
|
||||
&& a.resolved_at.is_none()
|
||||
{
|
||||
a.resolved_at = Some(crate::handlers::utils::now_secs());
|
||||
} else if s_lower != "implemented" && s_lower != "resolved" {
|
||||
a.resolved_at = None;
|
||||
@@ -535,11 +537,15 @@ impl McpTool for DecisionsHandler {
|
||||
&format!("Updated {}: {}", adr.id, adr.title),
|
||||
Some(&adr.status),
|
||||
);
|
||||
Ok(format!("Updated decision {}: {} (status: {})", adr.id, adr.title, adr.status))
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal(
|
||||
format!("Decision with id '{}' not found", id),
|
||||
Ok(format!(
|
||||
"Updated decision {}: {} (status: {})",
|
||||
adr.id, adr.title, adr.status
|
||||
))
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal(format!(
|
||||
"Decision with id '{}' not found",
|
||||
id
|
||||
)))
|
||||
}
|
||||
}
|
||||
DecisionAction::Query => {
|
||||
@@ -554,9 +560,17 @@ impl McpTool for DecisionsHandler {
|
||||
}
|
||||
if let Some(q) = &req.query {
|
||||
crate::handlers::utils::contains_ignore_ascii_case(&a.title, q)
|
||||
|| crate::handlers::utils::contains_ignore_ascii_case(&a.context, q)
|
||||
|| crate::handlers::utils::contains_ignore_ascii_case(&a.decision, q)
|
||||
|| crate::handlers::utils::contains_ignore_ascii_case(&a.consequence, q)
|
||||
|| crate::handlers::utils::contains_ignore_ascii_case(
|
||||
&a.context, q,
|
||||
)
|
||||
|| crate::handlers::utils::contains_ignore_ascii_case(
|
||||
&a.decision,
|
||||
q,
|
||||
)
|
||||
|| crate::handlers::utils::contains_ignore_ascii_case(
|
||||
&a.consequence,
|
||||
q,
|
||||
)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
@@ -565,7 +579,7 @@ impl McpTool for DecisionsHandler {
|
||||
.collect();
|
||||
|
||||
if include_body {
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
||||
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||
} else {
|
||||
let compact: Vec<_> = filtered
|
||||
.iter()
|
||||
@@ -582,7 +596,7 @@ impl McpTool for DecisionsHandler {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&compact)?)
|
||||
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&compact)?)
|
||||
}
|
||||
})?;
|
||||
Ok(data)
|
||||
@@ -632,17 +646,14 @@ impl McpTool for TechDebtHandler {
|
||||
let req: TechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let ns = req
|
||||
.namespace
|
||||
.unwrap_or_else(|| crate::models::default_namespace());
|
||||
.unwrap_or_else(crate::models::default_namespace);
|
||||
match req.action {
|
||||
TechDebtAction::Log => {
|
||||
let desc = req.description.or(req.title).ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'description' for action 'log'. Next step: Provide tech debt 'description' in request and retry.".to_string())
|
||||
})?;
|
||||
let ideal = req.ideal_solution.unwrap_or_default();
|
||||
let text_to_embed = format!(
|
||||
"Description: {}\nIdeal Solution: {}",
|
||||
desc, ideal
|
||||
);
|
||||
let text_to_embed = format!("Description: {}\nIdeal Solution: {}", desc, ideal);
|
||||
let embedding = crate::embedding::generate_embedding_async(text_to_embed)
|
||||
.await
|
||||
.ok();
|
||||
@@ -665,18 +676,21 @@ impl McpTool for TechDebtHandler {
|
||||
effort_estimate: req.effort_estimate,
|
||||
});
|
||||
if debts.len() > 300 {
|
||||
let severity_rank = |sev: Option<&str>| match sev.unwrap_or("").to_lowercase().as_str() {
|
||||
"critical" => 4,
|
||||
"high" => 3,
|
||||
"medium" => 2,
|
||||
"low" => 1,
|
||||
_ => 1,
|
||||
};
|
||||
if let Some((idx_to_remove, _)) = debts.iter().enumerate().min_by_key(|(_, d)| {
|
||||
let status_score = if d.is_resolved { 0 } else { 10 };
|
||||
let sev_score = severity_rank(d.severity.as_deref());
|
||||
(status_score + sev_score, d.created_at)
|
||||
}) {
|
||||
let severity_rank =
|
||||
|sev: Option<&str>| match sev.unwrap_or("").to_lowercase().as_str() {
|
||||
"critical" => 4,
|
||||
"high" => 3,
|
||||
"medium" => 2,
|
||||
"low" => 1,
|
||||
_ => 1,
|
||||
};
|
||||
if let Some((idx_to_remove, _)) =
|
||||
debts.iter().enumerate().min_by_key(|(_, d)| {
|
||||
let status_score = if d.is_resolved { 0 } else { 10 };
|
||||
let sev_score = severity_rank(d.severity.as_deref());
|
||||
(status_score + sev_score, d.created_at)
|
||||
})
|
||||
{
|
||||
debts.remove(idx_to_remove);
|
||||
}
|
||||
}
|
||||
@@ -712,9 +726,7 @@ impl McpTool for TechDebtHandler {
|
||||
let data = state.code.tech_debts.read_with(|debts| {
|
||||
let filtered: Vec<_> = debts
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.namespace == ns && (inc || !d.is_resolved)
|
||||
})
|
||||
.filter(|d| d.namespace == ns && (inc || !d.is_resolved))
|
||||
.map(|d| match level {
|
||||
"compact" => serde_json::json!({
|
||||
"id": d.id,
|
||||
@@ -730,7 +742,7 @@ impl McpTool for TechDebtHandler {
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
let mut json_str = serde_json::to_string(&filtered)?;
|
||||
let mut json_str = serde_yaml::to_string(&filtered)?;
|
||||
if let Some(max_t) = req.max_tokens {
|
||||
let char_limit = max_t * 4;
|
||||
if json_str.len() > char_limit {
|
||||
@@ -811,7 +823,8 @@ impl McpTool for OmniSearchHandler {
|
||||
let mut ranked_items: Vec<_> = rrf_scores.into_values().collect();
|
||||
ranked_items.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
|
||||
let matches: Vec<crate::search::SearchResult> = ranked_items.into_iter().map(|(_, item)| item).collect();
|
||||
let matches: Vec<crate::search::SearchResult> =
|
||||
ranked_items.into_iter().map(|(_, item)| item).collect();
|
||||
|
||||
let kg_json = state.read_graph(|full| {
|
||||
let mut kg_results = serde_json::Map::new();
|
||||
@@ -1263,24 +1276,23 @@ impl McpTool for ManageCheckpointHandler {
|
||||
)));
|
||||
}
|
||||
|
||||
if let Ok(graph_content) = std::fs::read_to_string(target_dir.join("graph.json")) {
|
||||
if let Ok(graph) = serde_json::from_str(&graph_content) {
|
||||
state.graph.modify(|g| *g = graph);
|
||||
}
|
||||
if let Ok(graph_content) = std::fs::read_to_string(target_dir.join("graph.json"))
|
||||
&& let Ok(graph) = serde_json::from_str(&graph_content)
|
||||
{
|
||||
state.graph.modify(|g| *g = graph);
|
||||
}
|
||||
|
||||
if let Ok(tasks_content) = std::fs::read_to_string(target_dir.join("tasks.json")) {
|
||||
if let Ok(tasks) = serde_json::from_str(&tasks_content) {
|
||||
state.project.tasks.modify(|t| *t = tasks);
|
||||
}
|
||||
if let Ok(tasks_content) = std::fs::read_to_string(target_dir.join("tasks.json"))
|
||||
&& let Ok(tasks) = serde_json::from_str(&tasks_content)
|
||||
{
|
||||
state.project.tasks.modify(|t| *t = tasks);
|
||||
}
|
||||
|
||||
if let Ok(debts_content) =
|
||||
std::fs::read_to_string(target_dir.join("tech_debts.json"))
|
||||
&& let Ok(debts) = serde_json::from_str(&debts_content)
|
||||
{
|
||||
if let Ok(debts) = serde_json::from_str(&debts_content) {
|
||||
state.code.tech_debts.modify(|d| *d = debts);
|
||||
}
|
||||
state.code.tech_debts.modify(|d| *d = debts);
|
||||
}
|
||||
|
||||
Ok(format!("Checkpoint '{}' restored successfully.", name))
|
||||
@@ -1290,10 +1302,10 @@ impl McpTool for ManageCheckpointHandler {
|
||||
let checkpoints_dir = state.base_dir.join("checkpoints");
|
||||
if let Ok(entries) = std::fs::read_dir(&checkpoints_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.path().is_dir() {
|
||||
if let Some(n) = entry.file_name().to_str() {
|
||||
list.push(serde_json::json!({"type": "checkpoint", "name": n}));
|
||||
}
|
||||
if entry.path().is_dir()
|
||||
&& let Some(n) = entry.file_name().to_str()
|
||||
{
|
||||
list.push(serde_json::json!({"type": "checkpoint", "name": n}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1301,7 +1313,7 @@ impl McpTool for ManageCheckpointHandler {
|
||||
for s in snaps {
|
||||
list.push(serde_json::json!({"type": "snapshot", "id": s.id, "description": s.description, "namespace": s.namespace}));
|
||||
}
|
||||
Ok(serde_json::to_string_pretty(&list)?)
|
||||
Ok(serde_yaml::to_string(&list)?)
|
||||
}
|
||||
CheckpointAction::Delete => {
|
||||
let name = req.name_or_id.ok_or_else(|| {
|
||||
@@ -1423,7 +1435,7 @@ impl McpTool for QueryLineageHandler {
|
||||
"timeline": timeline
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&res)?)
|
||||
Ok(serde_yaml::to_string(&res)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1461,12 +1473,11 @@ impl McpTool for GetNextActionableTasksHandler {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(branch) = &req.git_branch {
|
||||
if let Some(tb) = &task.git_branch {
|
||||
if tb != branch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(branch) = &req.git_branch
|
||||
&& let Some(tb) = &task.git_branch
|
||||
&& tb != branch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let unblocked = task.dependencies.is_empty()
|
||||
@@ -1484,7 +1495,7 @@ impl McpTool for GetNextActionableTasksHandler {
|
||||
"tasks": actionable
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&res)?)
|
||||
Ok(serde_yaml::to_string(&res)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1509,7 +1520,9 @@ impl McpTool for HypothesesHandler {
|
||||
match req.action {
|
||||
HypothesisAction::Log => {
|
||||
let hyp_text = req.hypothesis.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required 'hypothesis' for action 'log'".to_string())
|
||||
crate::error::AppError::Internal(
|
||||
"Missing required 'hypothesis' for action 'log'".to_string(),
|
||||
)
|
||||
})?;
|
||||
let hyp_id = format!(
|
||||
"HYP-{}",
|
||||
@@ -1536,23 +1549,23 @@ impl McpTool for HypothesesHandler {
|
||||
let filtered: Vec<_> = hypotheses
|
||||
.into_iter()
|
||||
.filter(|h| {
|
||||
if let Some(tid) = &req.task_id {
|
||||
if h.task_id.as_ref() != Some(tid) {
|
||||
return false;
|
||||
}
|
||||
if let Some(tid) = &req.task_id
|
||||
&& h.task_id.as_ref() != Some(tid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(q) = &req.query {
|
||||
let lq = q.to_lowercase();
|
||||
return h.hypothesis.to_lowercase().contains(&lq)
|
||||
|| h.evidence
|
||||
.as_ref()
|
||||
.map_or(false, |e| e.to_lowercase().contains(&lq));
|
||||
.is_some_and(|e| e.to_lowercase().contains(&lq));
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::to_string_pretty(&filtered)?)
|
||||
Ok(serde_yaml::to_string(&filtered)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1631,7 +1644,7 @@ impl McpTool for GetPreflightContextHandler {
|
||||
"recent_activities": recent_activities
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&preflight)?)
|
||||
Ok(serde_yaml::to_string(&preflight)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1656,13 +1669,19 @@ impl McpTool for AgentSignalsHandler {
|
||||
match req.action {
|
||||
AgentSignalAction::Broadcast => {
|
||||
let sender = req.sender.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required 'sender' for action 'broadcast'".to_string())
|
||||
crate::error::AppError::Internal(
|
||||
"Missing required 'sender' for action 'broadcast'".to_string(),
|
||||
)
|
||||
})?;
|
||||
let signal_type = req.signal_type.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required 'signal_type' for action 'broadcast'".to_string())
|
||||
crate::error::AppError::Internal(
|
||||
"Missing required 'signal_type' for action 'broadcast'".to_string(),
|
||||
)
|
||||
})?;
|
||||
let payload = req.payload.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required 'payload' for action 'broadcast'".to_string())
|
||||
crate::error::AppError::Internal(
|
||||
"Missing required 'payload' for action 'broadcast'".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
@@ -1715,29 +1734,29 @@ impl McpTool for AgentSignalsHandler {
|
||||
signals
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
if let Some(ttl) = s.ttl_seconds {
|
||||
if now > s.timestamp + ttl {
|
||||
return false;
|
||||
}
|
||||
if let Some(ttl) = s.ttl_seconds
|
||||
&& now > s.timestamp + ttl
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(sender) = &req.sender {
|
||||
if s.sender.to_lowercase() != sender.to_lowercase() {
|
||||
return false;
|
||||
}
|
||||
if let Some(sender) = &req.sender
|
||||
&& s.sender.to_lowercase() != sender.to_lowercase()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(st) = &req.signal_type {
|
||||
if s.signal_type.to_lowercase() != st.to_lowercase() {
|
||||
return false;
|
||||
}
|
||||
if let Some(st) = &req.signal_type
|
||||
&& s.signal_type.to_lowercase() != st.to_lowercase()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.cloned()
|
||||
.take(req.limit.unwrap_or(20))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&filtered)?)
|
||||
Ok(serde_yaml::to_string(&filtered)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1980,8 +1999,8 @@ mod tests {
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
let uuid_start = debt_list.find("id\":\"").unwrap() + 5;
|
||||
let uuid = &debt_list[uuid_start..uuid_start + 36];
|
||||
let debt_list_val: serde_json::Value = serde_yaml::from_str(&debt_list).unwrap();
|
||||
let uuid = debt_list_val[0]["id"].as_str().unwrap();
|
||||
|
||||
let resolve_debt = TechDebtHandler;
|
||||
resolve_debt
|
||||
@@ -2110,7 +2129,10 @@ mod tests {
|
||||
assert!(q_dec_res.contains("Fast async API routing"));
|
||||
|
||||
let q_by_consequence = handler_dec
|
||||
.execute(serde_json::json!({"action": "query", "query": "Fast async"}), state.clone())
|
||||
.execute(
|
||||
serde_json::json!({"action": "query", "query": "Fast async"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(q_by_consequence.contains("Use Axum"));
|
||||
@@ -2133,7 +2155,10 @@ mod tests {
|
||||
assert!(update_res.contains("implemented"));
|
||||
|
||||
let q_after_update = handler_dec
|
||||
.execute(serde_json::json!({"action": "query", "include_body": false}), state.clone())
|
||||
.execute(
|
||||
serde_json::json!({"action": "query", "include_body": false}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(q_after_update.contains("implemented"));
|
||||
@@ -2143,7 +2168,10 @@ mod tests {
|
||||
assert!(q_after_update.contains("resolved_at"));
|
||||
|
||||
let del_dec_res = handler_dec
|
||||
.execute(serde_json::json!({"action": "delete", "id": "ADR-0001"}), state.clone())
|
||||
.execute(
|
||||
serde_json::json!({"action": "delete", "id": "ADR-0001"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(del_dec_res.is_ok());
|
||||
|
||||
@@ -2163,14 +2191,20 @@ mod tests {
|
||||
assert!(td_res.contains("Tech debt logged"));
|
||||
|
||||
let list_td_res = handler_td
|
||||
.execute(serde_json::json!({"action": "list", "include_resolved": true}), state.clone())
|
||||
.execute(
|
||||
serde_json::json!({"action": "list", "include_resolved": true}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(list_td_res.contains("Replace unwraps"));
|
||||
|
||||
let debt_id = state.code.tech_debts.read_with(|debts| debts[0].id.clone());
|
||||
let res_td_res = handler_td
|
||||
.execute(serde_json::json!({"action": "resolve", "id": debt_id}), state.clone())
|
||||
.execute(
|
||||
serde_json::json!({"action": "resolve", "id": debt_id}),
|
||||
state.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(res_td_res.is_ok());
|
||||
|
||||
@@ -2394,7 +2428,10 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(rest_state_res.contains("restored successfully") || rest_state_res.contains("restored memory state from snapshot"));
|
||||
assert!(
|
||||
rest_state_res.contains("restored successfully")
|
||||
|| rest_state_res.contains("restored memory state from snapshot")
|
||||
);
|
||||
|
||||
let mg_list = mg_chk
|
||||
.execute(serde_json::json!({"action": "list"}), state.clone())
|
||||
@@ -2607,7 +2644,10 @@ mod tests {
|
||||
|
||||
// DeleteDecision non-existent
|
||||
let del_dec_err = handler_dec
|
||||
.execute(serde_json::json!({"action": "delete", "id": "ADR-9999"}), state.clone())
|
||||
.execute(
|
||||
serde_json::json!({"action": "delete", "id": "ADR-9999"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(del_dec_err.is_err());
|
||||
|
||||
@@ -2882,9 +2922,14 @@ mod tests {
|
||||
|
||||
state.code.tech_debts.read_with(|debts| {
|
||||
assert_eq!(debts.len(), 300);
|
||||
assert!(debts.iter().any(|d| d.id == "critical-debt"), "Critical unresolved debt must be retained");
|
||||
assert!(!debts.iter().any(|d| d.id == "resolved-debt"), "Resolved debt should have been evicted first");
|
||||
assert!(
|
||||
debts.iter().any(|d| d.id == "critical-debt"),
|
||||
"Critical unresolved debt must be retained"
|
||||
);
|
||||
assert!(
|
||||
!debts.iter().any(|d| d.id == "resolved-debt"),
|
||||
"Resolved debt should have been evicted first"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,3 @@ pub mod tasks;
|
||||
pub mod utils;
|
||||
pub mod vision;
|
||||
pub mod workspaces;
|
||||
|
||||
@@ -29,7 +29,9 @@ impl McpTool for HandoffMemosHandler {
|
||||
let content = req.content.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'content' for action 'leave'. Next step: Provide non-empty 'content' 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.telemetry.handoff_memos.modify(|memos| {
|
||||
memos.push(crate::models::HandoffMemo {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
@@ -58,7 +60,7 @@ impl McpTool for HandoffMemosHandler {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
||||
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -109,11 +111,13 @@ impl McpTool for AddSessionSummaryHandler {
|
||||
});
|
||||
if summaries.len() > 200 {
|
||||
let now = crate::handlers::utils::now_secs();
|
||||
if let Some((idx_to_remove, _)) = summaries.iter().enumerate().min_by_key(|(_, s)| {
|
||||
let is_expired = s.expires_at.map(|exp| exp < now).unwrap_or(false);
|
||||
let status_score = if is_expired { 0 } else { 1 };
|
||||
(status_score, s.timestamp)
|
||||
}) {
|
||||
if let Some((idx_to_remove, _)) =
|
||||
summaries.iter().enumerate().min_by_key(|(_, s)| {
|
||||
let is_expired = s.expires_at.map(|exp| exp < now).unwrap_or(false);
|
||||
let status_score = if is_expired { 0 } else { 1 };
|
||||
(status_score, s.timestamp)
|
||||
})
|
||||
{
|
||||
summaries.remove(idx_to_remove);
|
||||
}
|
||||
}
|
||||
@@ -143,13 +147,25 @@ impl McpTool for GenerateStandupReportHandler {
|
||||
let cutoff = crate::handlers::utils::now_secs().saturating_sub(req.hours_lookback * 3600);
|
||||
|
||||
let filtered_tasks = state.project.tasks.read_with(|items| {
|
||||
items.iter().filter(|t| t.updated_at >= cutoff).cloned().collect::<Vec<_>>()
|
||||
items
|
||||
.iter()
|
||||
.filter(|t| t.updated_at >= cutoff)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let filtered_changes = state.code.ledger.read_with(|changes| {
|
||||
changes.iter().filter(|c| c.timestamp >= cutoff).cloned().collect::<Vec<_>>()
|
||||
changes
|
||||
.iter()
|
||||
.filter(|c| c.timestamp >= cutoff)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let filtered_summaries = state.telemetry.session_summaries.read_with(|summaries| {
|
||||
summaries.iter().filter(|s| s.namespace == req.namespace && s.timestamp >= cutoff).cloned().collect::<Vec<_>>()
|
||||
summaries
|
||||
.iter()
|
||||
.filter(|s| s.namespace == req.namespace && s.timestamp >= cutoff)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let report = serde_json::json!({
|
||||
@@ -178,8 +194,7 @@ impl McpTool for PromoteToEntityHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: PromoteToEntityTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let req: PromoteToEntityTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let entity_name = req.entity_name.clone();
|
||||
let obs = req.content.clone();
|
||||
@@ -236,7 +251,10 @@ mod tests {
|
||||
assert_eq!(res, "Handoff memo left.");
|
||||
|
||||
let res2 = handoff_handler
|
||||
.execute(json!({"action": "read", "namespace": "global"}), state.clone())
|
||||
.execute(
|
||||
json!({"action": "read", "namespace": "global"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
@@ -266,5 +284,3 @@ mod tests {
|
||||
assert!(!res4.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ pub fn extract_adrs(text: &str) -> Vec<String> {
|
||||
if i + 3 <= len && lower_chars[i..i + 3] == ['a', 'd', 'r'] {
|
||||
let mut cursor = i + 3;
|
||||
// Skip optional '-', ':', or whitespace
|
||||
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
|
||||
while cursor < len
|
||||
&& (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ')
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
// Collect consecutive digits
|
||||
@@ -80,11 +82,17 @@ pub fn extract_tech_debts(text: &str) -> Vec<String> {
|
||||
while i < len {
|
||||
if i + 2 <= len && lower_chars[i..i + 2] == ['t', 'd'] {
|
||||
let mut cursor = i + 2;
|
||||
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
|
||||
while cursor < len
|
||||
&& (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ')
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
let start = cursor;
|
||||
while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') {
|
||||
while cursor < len
|
||||
&& (chars[cursor].is_ascii_alphanumeric()
|
||||
|| chars[cursor] == '_'
|
||||
|| chars[cursor] == '-')
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor > start {
|
||||
@@ -117,11 +125,17 @@ pub fn extract_tasks(text: &str) -> Vec<String> {
|
||||
while i < len {
|
||||
if i + 4 <= len && lower_chars[i..i + 4] == ['t', 'a', 's', 'k'] {
|
||||
let mut cursor = i + 4;
|
||||
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
|
||||
while cursor < len
|
||||
&& (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ')
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
let start = cursor;
|
||||
while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') {
|
||||
while cursor < len
|
||||
&& (chars[cursor].is_ascii_alphanumeric()
|
||||
|| chars[cursor] == '_'
|
||||
|| chars[cursor] == '-')
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor > start {
|
||||
@@ -162,8 +176,7 @@ pub fn cascade_unblock_tasks(state: &MemoryState) -> Vec<String> {
|
||||
} else {
|
||||
let all_deps_completed = t.dependencies.iter().all(|dep_id| {
|
||||
tasks_snapshot.iter().any(|other| {
|
||||
(other.id == *dep_id || other.title == *dep_id)
|
||||
&& !other.is_active()
|
||||
(other.id == *dep_id || other.title == *dep_id) && !other.is_active()
|
||||
})
|
||||
});
|
||||
if all_deps_completed {
|
||||
@@ -226,18 +239,16 @@ pub fn recalculate_milestones(state: &MemoryState) -> Vec<String> {
|
||||
}
|
||||
updated.push(m.id.clone());
|
||||
}
|
||||
} else if completed > 0
|
||||
} else if (completed > 0
|
||||
|| tasks_snapshot.iter().any(|t| {
|
||||
m.task_ids.iter().any(|tid| tid == &t.id || tid == &t.title)
|
||||
&& (t.status == "in_progress" || t.status == "active")
|
||||
})
|
||||
}))
|
||||
&& !m.status.eq_ignore_ascii_case("in_progress")
|
||||
&& !m.status.eq_ignore_ascii_case("completed")
|
||||
{
|
||||
if !m.status.eq_ignore_ascii_case("in_progress")
|
||||
&& !m.status.eq_ignore_ascii_case("completed")
|
||||
{
|
||||
m.status = "in_progress".to_string();
|
||||
updated.push(m.id.clone());
|
||||
}
|
||||
m.status = "in_progress".to_string();
|
||||
updated.push(m.id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -275,10 +286,10 @@ pub async fn reconcile_commit_or_code_change(
|
||||
if adr.id.eq_ignore_ascii_case(cand) {
|
||||
return true;
|
||||
}
|
||||
if let (Some(n1), Some(n2)) = (adr_numeric_id(&adr.id), adr_numeric_id(cand)) {
|
||||
if n1 == n2 {
|
||||
return true;
|
||||
}
|
||||
if let (Some(n1), Some(n2)) = (adr_numeric_id(&adr.id), adr_numeric_id(cand))
|
||||
&& n1 == n2
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
});
|
||||
@@ -324,7 +335,9 @@ pub async fn reconcile_commit_or_code_change(
|
||||
if d.is_resolved {
|
||||
continue;
|
||||
}
|
||||
let matches_id = td_candidates.iter().any(|cand| d.id.eq_ignore_ascii_case(cand));
|
||||
let matches_id = td_candidates
|
||||
.iter()
|
||||
.any(|cand| d.id.eq_ignore_ascii_case(cand));
|
||||
let matches_file = file_path
|
||||
.map(|fp| d.file_path.as_deref() == Some(fp))
|
||||
.unwrap_or(false)
|
||||
|
||||
@@ -50,7 +50,6 @@ impl McpTool for TasksHandler {
|
||||
priority: req.priority,
|
||||
assigned_agent: req.assigned_agent,
|
||||
verification_command: req.verification_command,
|
||||
..Default::default()
|
||||
};
|
||||
let idx = state.get_search_index().await;
|
||||
drop(idx.index_task(&task));
|
||||
@@ -297,12 +296,12 @@ impl McpTool for TasksHandler {
|
||||
_ => serde_json::json!({ "id": t.id, "title": t.title, "status": t.status, "description": t.description, "git_branch": t.git_branch }),
|
||||
})
|
||||
.collect();
|
||||
let mut json_str = serde_json::to_string(&filtered)?;
|
||||
let mut json_str = serde_yaml::to_string(&filtered)?;
|
||||
if let Some(max_t) = req.max_tokens {
|
||||
let char_limit = max_t * 4;
|
||||
while json_str.len() > char_limit && !filtered.is_empty() {
|
||||
filtered.pop();
|
||||
json_str = serde_json::to_string(&filtered)?;
|
||||
json_str = serde_yaml::to_string(&filtered)?;
|
||||
}
|
||||
}
|
||||
Ok::<String, crate::error::AppError>(json_str)
|
||||
@@ -366,14 +365,14 @@ impl McpTool for TasksHandler {
|
||||
let mut updated_task: Option<Task> = None;
|
||||
state.project.tasks.modify(|tasks| {
|
||||
if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) {
|
||||
if let Some(ac) = task.acceptance_criteria.iter_mut().next() {
|
||||
if let Some(ac) = task.acceptance_criteria.first_mut() {
|
||||
if ac.is_met {
|
||||
already_met = true;
|
||||
} else {
|
||||
ac.is_met = true;
|
||||
success = true;
|
||||
task.updated_at = crate::handlers::utils::now_secs();
|
||||
updated_task = Some(task.clone());
|
||||
task.updated_at = crate::handlers::utils::now_secs();
|
||||
updated_task = Some(task.clone());
|
||||
}
|
||||
} else {
|
||||
task.acceptance_criteria
|
||||
@@ -444,7 +443,7 @@ impl McpTool for MilestonesHandler {
|
||||
})?;
|
||||
let ns = req
|
||||
.namespace
|
||||
.unwrap_or_else(|| crate::models::default_namespace());
|
||||
.unwrap_or_else(crate::models::default_namespace);
|
||||
state.project.milestones.modify(|ms| {
|
||||
ms.push(crate::models::Milestone {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
@@ -477,16 +476,17 @@ impl McpTool for MilestonesHandler {
|
||||
for m in ms.iter_mut() {
|
||||
if m.id == id {
|
||||
m.status = status.clone();
|
||||
if status.eq_ignore_ascii_case("completed") || status.eq_ignore_ascii_case("done") {
|
||||
if m.completed_at.is_none() {
|
||||
m.completed_at = Some(crate::handlers::utils::now_secs());
|
||||
}
|
||||
if (status.eq_ignore_ascii_case("completed")
|
||||
|| status.eq_ignore_ascii_case("done"))
|
||||
&& m.completed_at.is_none()
|
||||
{
|
||||
m.completed_at = Some(crate::handlers::utils::now_secs());
|
||||
}
|
||||
if let Some(task_ids) = &req.task_ids {
|
||||
m.task_ids = task_ids.clone();
|
||||
if let Some(deliverables) = &req.deliverables {
|
||||
m.deliverables = deliverables.clone();
|
||||
}
|
||||
if let Some(deliverables) = &req.deliverables {
|
||||
m.deliverables = deliverables.clone();
|
||||
}
|
||||
}
|
||||
found = true;
|
||||
break;
|
||||
@@ -514,7 +514,7 @@ impl McpTool for MilestonesHandler {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
||||
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
+35
-115
@@ -191,10 +191,8 @@ pub fn html_to_markdown(html: &str) -> String {
|
||||
out.push_str("* ");
|
||||
}
|
||||
}
|
||||
"/li" => {
|
||||
if !in_cell {
|
||||
out.push('\n');
|
||||
}
|
||||
"/li" if !in_cell => {
|
||||
out.push('\n');
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -304,32 +302,40 @@ pub async fn ensure_ocr_engine() -> Option<&'static OcrEngine> {
|
||||
|
||||
let det_path = cache_dir.join("text-detection.rten");
|
||||
if !det_path.exists() || std::fs::metadata(&det_path).map(|m| m.len()).unwrap_or(0) < 100_000 {
|
||||
match reqwest::get("https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten").await {
|
||||
match reqwest::get("https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten")
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if let Ok(bytes) = resp.bytes().await {
|
||||
if let Err(e) = std::fs::write(&det_path, &bytes) {
|
||||
tracing::error!("Failed to write text-detection.rten to disk: {}", e);
|
||||
}
|
||||
} else {
|
||||
tracing::error!("Failed to read bytes from text-detection.rten download response");
|
||||
tracing::error!(
|
||||
"Failed to read bytes from text-detection.rten download response"
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to download text-detection.rten: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
let rec_path = cache_dir.join("text-recognition.rten");
|
||||
if !rec_path.exists() || std::fs::metadata(&rec_path).map(|m| m.len()).unwrap_or(0) < 100_000 {
|
||||
match reqwest::get("https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten").await {
|
||||
match reqwest::get("https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten")
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if let Ok(bytes) = resp.bytes().await {
|
||||
if let Err(e) = std::fs::write(&rec_path, &bytes) {
|
||||
tracing::error!("Failed to write text-recognition.rten to disk: {}", e);
|
||||
}
|
||||
} else {
|
||||
tracing::error!("Failed to read bytes from text-recognition.rten download response");
|
||||
tracing::error!(
|
||||
"Failed to read bytes from text-recognition.rten download response"
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to download text-recognition.rten: {}", e),
|
||||
}
|
||||
}
|
||||
@@ -350,10 +356,7 @@ pub async fn ensure_ocr_engine() -> Option<&'static OcrEngine> {
|
||||
|
||||
pub fn perform_ocrs_ocr(engine: &OcrEngine, img: &image::DynamicImage) -> Option<String> {
|
||||
let rgb_img = img.to_rgb8();
|
||||
let img_source = ocrs::ImageSource::from_bytes(
|
||||
rgb_img.as_raw(),
|
||||
rgb_img.dimensions(),
|
||||
).ok()?;
|
||||
let img_source = ocrs::ImageSource::from_bytes(rgb_img.as_raw(), rgb_img.dimensions()).ok()?;
|
||||
|
||||
let ocr_input = engine.prepare_input(img_source).ok()?;
|
||||
let word_rects = engine.detect_words(&ocr_input).ok()?;
|
||||
@@ -482,12 +485,11 @@ pub async fn process_clipboard_snapshot(state: Arc<MemoryState>) {
|
||||
|
||||
if let Some(engine) = ensure_ocr_engine().await {
|
||||
let img_clone = img.clone();
|
||||
ocr_text_opt = tokio::task::spawn_blocking(move || {
|
||||
perform_ocrs_ocr(engine, &img_clone)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
ocr_text_opt =
|
||||
tokio::task::spawn_blocking(move || perform_ocrs_ocr(engine, &img_clone))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
}
|
||||
|
||||
let mut cache = state.clipboard_cache.write().await;
|
||||
@@ -507,7 +509,8 @@ pub async fn process_clipboard_snapshot(state: Arc<MemoryState>) {
|
||||
.search_index
|
||||
.read()
|
||||
.await
|
||||
.index_clipboard_image(&doc_id, &path_str, text);
|
||||
.index_clipboard_image(&doc_id, &path_str, text)
|
||||
.await;
|
||||
}
|
||||
|
||||
let cached = crate::state::CachedClipboardImage {
|
||||
@@ -563,10 +566,10 @@ pub async fn process_clipboard_snapshot(state: Arc<MemoryState>) {
|
||||
};
|
||||
|
||||
let mut cache = state.clipboard_cache.write().await;
|
||||
if let Some(ref prev) = cache.last_text {
|
||||
if prev.text == cached.text {
|
||||
return;
|
||||
}
|
||||
if let Some(ref prev) = cache.last_text
|
||||
&& prev.text == cached.text
|
||||
{
|
||||
return;
|
||||
}
|
||||
cache.last_text = Some(cached.clone());
|
||||
cache
|
||||
@@ -669,7 +672,7 @@ pub fn spawn_clipboard_listener(state: Arc<MemoryState>) {
|
||||
tokio::spawn(async move {
|
||||
while let Some(()) = rx.recv().await {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
|
||||
while let Ok(_) = rx.try_recv() {}
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
process_clipboard_snapshot(Arc::clone(&state)).await;
|
||||
}
|
||||
@@ -756,7 +759,7 @@ impl McpTool for ClipboardHandler {
|
||||
);
|
||||
|
||||
if let Some(img) = item {
|
||||
Ok(serde_json::to_string_pretty(&img)?)
|
||||
Ok(serde_yaml::to_string(&img)?)
|
||||
} else {
|
||||
Ok(json!({
|
||||
"status": "no_image_in_clipboard",
|
||||
@@ -784,7 +787,7 @@ impl McpTool for ClipboardHandler {
|
||||
|
||||
state.record_activity("clipboard", "Retrieved text from clipboard cache", None);
|
||||
if let Some(txt) = item {
|
||||
Ok(serde_json::to_string_pretty(&txt)?)
|
||||
Ok(serde_yaml::to_string(&txt)?)
|
||||
} else {
|
||||
Ok(json!({
|
||||
"text": "",
|
||||
@@ -816,7 +819,7 @@ impl McpTool for ClipboardHandler {
|
||||
};
|
||||
|
||||
state.record_activity("clipboard", "Viewed clipboard history ring buffer", None);
|
||||
Ok(serde_json::to_string_pretty(&history)?)
|
||||
Ok(serde_yaml::to_string(&history)?)
|
||||
}
|
||||
ClipboardAction::Clear => {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
@@ -840,59 +843,7 @@ impl McpTool for ClipboardHandler {
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
ClipboardAction::Read => {
|
||||
let engine = ensure_ocr_engine().await;
|
||||
|
||||
let out =
|
||||
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Map<String, Value>> {
|
||||
let mut out = serde_json::Map::new();
|
||||
|
||||
if let Some(text) = get_native_clipboard_text() {
|
||||
out.insert("text".into(), json!(text));
|
||||
}
|
||||
|
||||
if let Some(dynamic_img) = get_native_clipboard_image() {
|
||||
let mut img = dynamic_img.clone();
|
||||
let max_dim = 1440;
|
||||
if img.width() > max_dim || img.height() > max_dim {
|
||||
img = img.resize(max_dim, max_dim, FilterType::Lanczos3);
|
||||
}
|
||||
let rgb_img = img.into_rgb8();
|
||||
|
||||
let mut jpeg_bytes = std::io::Cursor::new(Vec::new());
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_bytes, 88);
|
||||
if encoder.encode(&rgb_img, rgb_img.width(), rgb_img.height(), image::ExtendedColorType::Rgb8).is_ok() {
|
||||
let bytes = jpeg_bytes.into_inner();
|
||||
|
||||
let cache_dir = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".gemini/mcp_memory/clipboard");
|
||||
let _ = std::fs::create_dir_all(&cache_dir);
|
||||
let file_path = cache_dir.join("clipboard_latest_image.jpg");
|
||||
if std::fs::write(&file_path, &bytes).is_ok() {
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
out.insert("image_path".into(), json!(path_str));
|
||||
let wsl_path = to_wsl_path(&path_str);
|
||||
out.insert("image_path_wsl".into(), json!(wsl_path));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(eng) = engine {
|
||||
if let Some(ocr_text) = perform_ocrs_ocr(eng, &dynamic_img) {
|
||||
out.insert("image_analysis".to_string(), json!(ocr_text.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||
|
||||
state.record_activity("clipboard", "Read contents from OS clipboard", None);
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&Value::Object(
|
||||
out,
|
||||
))?)
|
||||
}
|
||||
ClipboardAction::Write => {
|
||||
let text_opt = req.text;
|
||||
let image_path_opt = req.image_path;
|
||||
@@ -1010,38 +961,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_clipboard() {
|
||||
let dir = tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
let handler = ClipboardHandler;
|
||||
|
||||
let result = handler
|
||||
.execute(json!({"action": "read"}), state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read clipboard: {}", e))
|
||||
.unwrap();
|
||||
|
||||
// Returns a JSON string, possibly {}
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert!(parsed.is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_clipboard_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
let handler = ClipboardHandler;
|
||||
|
||||
let result = handler
|
||||
.execute(serde_json::json!({"action": "read"}), state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read clipboard: {}", e))
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert!(parsed.is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_subprocess_clipboard_regression() {
|
||||
@@ -1159,7 +1079,7 @@ mod tests {
|
||||
.execute(json!({ "action": "image" }), Arc::clone(&state))
|
||||
.await
|
||||
.unwrap();
|
||||
let img_val: Value = serde_json::from_str(&img_res_str).unwrap();
|
||||
let img_val: Value = serde_yaml::from_str(&img_res_str).unwrap();
|
||||
assert_eq!(img_val["file_path"], "C:\\tmp\\image_b.jpg");
|
||||
assert_eq!(img_val["file_path_wsl"], "/mnt/c/tmp/image_b.jpg");
|
||||
assert_eq!(img_val["width"], 1200);
|
||||
@@ -1172,7 +1092,7 @@ mod tests {
|
||||
.execute(json!({ "action": "text" }), Arc::clone(&state))
|
||||
.await
|
||||
.unwrap();
|
||||
let text_val: Value = serde_json::from_str(&text_res_str).unwrap();
|
||||
let text_val: Value = serde_yaml::from_str(&text_res_str).unwrap();
|
||||
assert_eq!(text_val["text"], "Some random stackoverflow URL");
|
||||
|
||||
// Action 'history' MUST show both items
|
||||
@@ -1180,7 +1100,7 @@ mod tests {
|
||||
.execute(json!({ "action": "history" }), Arc::clone(&state))
|
||||
.await
|
||||
.unwrap();
|
||||
let hist_val: Value = serde_json::from_str(&hist_res_str).unwrap();
|
||||
let hist_val: Value = serde_yaml::from_str(&hist_res_str).unwrap();
|
||||
let history = hist_val.as_array().unwrap();
|
||||
assert!(history.len() >= 4);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,6 @@ use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
pub struct SnippetsHandler;
|
||||
|
||||
#[async_trait]
|
||||
@@ -57,7 +49,7 @@ impl McpTool for SnippetsHandler {
|
||||
embedding,
|
||||
namespace: req
|
||||
.namespace
|
||||
.unwrap_or_else(|| crate::models::default_namespace()),
|
||||
.unwrap_or_else(crate::models::default_namespace),
|
||||
repo_name: req.repo_name,
|
||||
origin_file: req.origin_file,
|
||||
line_range: req.line_range,
|
||||
@@ -89,8 +81,9 @@ impl McpTool for SnippetsHandler {
|
||||
let mut scored: Vec<_> = snippets
|
||||
.into_iter()
|
||||
.filter_map(|s| {
|
||||
let text = format!("{} {} {} {}", s.name, s.description, s.language, s.code)
|
||||
.to_lowercase();
|
||||
let text =
|
||||
format!("{} {} {} {}", s.name, s.description, s.language, s.code)
|
||||
.to_lowercase();
|
||||
let mut score = 0.0;
|
||||
for tok in &q_tokens {
|
||||
if text.contains(tok) {
|
||||
@@ -129,7 +122,7 @@ impl McpTool for SnippetsHandler {
|
||||
.total_cmp(&a["score"].as_f64().unwrap_or(0.0))
|
||||
});
|
||||
scored.truncate(10);
|
||||
Ok(serde_json::to_string_pretty(&scored)?)
|
||||
Ok(serde_yaml::to_string(&scored)?)
|
||||
} else {
|
||||
let data = state.code.snippets.read_with(|snippets| {
|
||||
let results: Vec<_> = snippets
|
||||
@@ -143,9 +136,9 @@ impl McpTool for SnippetsHandler {
|
||||
|| s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query));
|
||||
|
||||
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
|
||||
})
|
||||
@@ -160,7 +153,7 @@ impl McpTool for SnippetsHandler {
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string_pretty(&results)
|
||||
serde_yaml::to_string(&results)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -352,13 +345,28 @@ impl McpTool for SemanticCodeSearchHandler {
|
||||
}
|
||||
|
||||
let mut out = format!("Semantic Search Results for '{}':\n", tool_args.query);
|
||||
for res in results {
|
||||
for res in &results {
|
||||
out.push_str(&format!(
|
||||
"- [{:.2}] {}: {}\n",
|
||||
res.score, res.title, res.body
|
||||
));
|
||||
}
|
||||
|
||||
if state.ollama.is_available().await {
|
||||
let prompt = format!(
|
||||
"You are an expert software architect. Synthesize a concise GraphRAG summary of the following graph nodes relevant to the query: '{}'.\n\nData:\n{}",
|
||||
tool_args.query, out
|
||||
);
|
||||
if let Ok(summary) = state
|
||||
.ollama
|
||||
.generate(&prompt, None, Some("Respond clearly and concisely."), None)
|
||||
.await
|
||||
{
|
||||
out.push_str("\n\n--- Local GraphRAG Summary ---\n");
|
||||
out.push_str(&summary);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ fn extract_chunks_with_parent(
|
||||
node: Node,
|
||||
code: &str,
|
||||
chunks: &mut Vec<(String, String, String)>,
|
||||
ext: &str,
|
||||
_ext: &str,
|
||||
parent_scope: Option<&str>,
|
||||
depth: usize,
|
||||
) {
|
||||
@@ -229,7 +229,7 @@ fn extract_chunks_with_parent(
|
||||
} else {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
extract_chunks_with_parent(child, code, chunks, ext, current_scope, depth + 1);
|
||||
extract_chunks_with_parent(child, code, chunks, _ext, current_scope, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ The server registers 5 high-signal workflow prompts to initiate standardized age
|
||||
|
||||
## 5. Consolidated Smart Tools Architecture (11 Primary Tools)
|
||||
|
||||
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.
|
||||
|
||||
* **`tasks`**: Complete task lifecycle management.
|
||||
- `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`).
|
||||
|
||||
+156
-66
@@ -4,6 +4,7 @@
|
||||
)]
|
||||
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod embedding;
|
||||
pub mod error;
|
||||
@@ -105,10 +106,10 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
||||
|
||||
state.project.tasks.read_with(|tasks| {
|
||||
for t in tasks.iter() {
|
||||
if let Some(exp) = t.expires_at {
|
||||
if t.is_active() {
|
||||
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
|
||||
}
|
||||
if let Some(exp) = t.expires_at
|
||||
&& t.is_active()
|
||||
{
|
||||
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -140,7 +141,7 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
||||
let sleep_duration = match next_expiry {
|
||||
Some(exp) if exp > now => {
|
||||
let diff = exp - now;
|
||||
std::time::Duration::from_secs(diff.min(60).max(1))
|
||||
std::time::Duration::from_secs(diff.clamp(1, 60))
|
||||
}
|
||||
Some(_) => std::time::Duration::from_millis(50),
|
||||
None => std::time::Duration::from_secs(60),
|
||||
@@ -160,12 +161,13 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
||||
let mut expired_tasks = Vec::new();
|
||||
state.project.tasks.modify(|tasks| {
|
||||
for t in tasks.iter_mut() {
|
||||
if let Some(exp) = t.expires_at {
|
||||
if exp <= now && t.is_active() {
|
||||
t.status = "expired".to_string();
|
||||
t.updated_at = now;
|
||||
expired_tasks.push(t.id.clone());
|
||||
}
|
||||
if let Some(exp) = t.expires_at
|
||||
&& exp <= now
|
||||
&& t.is_active()
|
||||
{
|
||||
t.status = "expired".to_string();
|
||||
t.updated_at = now;
|
||||
expired_tasks.push(t.id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -249,27 +251,108 @@ pub async fn condense_graph_worker(state: Arc<MemoryState>) {
|
||||
}
|
||||
});
|
||||
|
||||
if let Some((content, names)) = snippet_condensation {
|
||||
if !content.is_empty() {
|
||||
let name = format!("Snippet History {}", now);
|
||||
state.modify_graph(|graph| {
|
||||
graph.entities.insert(
|
||||
name.clone(),
|
||||
crate::models::Entity {
|
||||
name: name.clone(),
|
||||
entity_type: "Historical Summary".to_string(),
|
||||
observations: vec![content],
|
||||
namespace: crate::models::default_namespace(),
|
||||
git_branch: None,
|
||||
..Default::default()
|
||||
},
|
||||
if let Some((content, names)) = snippet_condensation
|
||||
&& !content.is_empty()
|
||||
{
|
||||
let name = format!("Snippet History {}", now);
|
||||
state.modify_graph(|graph| {
|
||||
graph.entities.insert(
|
||||
name.clone(),
|
||||
crate::models::Entity {
|
||||
name: name.clone(),
|
||||
entity_type: "Historical Summary".to_string(),
|
||||
observations: vec![content],
|
||||
namespace: crate::models::default_namespace(),
|
||||
git_branch: None,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
});
|
||||
let name_set: std::collections::HashSet<String> = names.into_iter().collect();
|
||||
state.code.snippets.modify(|snippets| {
|
||||
snippets.retain(|s| !name_set.contains(&s.name));
|
||||
});
|
||||
tracing::info!("Condensed snippets into Historical Summary.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn memory_consolidation_worker(state: Arc<MemoryState>) {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = state.shutdown_notify.notified() => break,
|
||||
_ = interval.tick() => {},
|
||||
}
|
||||
|
||||
let entities: Vec<_> = state.graph.read_with(|g| {
|
||||
g.entities
|
||||
.values()
|
||||
.map(|e| (e.name.clone(), e.entity_type.clone()))
|
||||
.collect()
|
||||
});
|
||||
|
||||
if entities.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut entity_summaries = String::new();
|
||||
for (name, e_type) in entities.iter().take(50) {
|
||||
entity_summaries.push_str(&format!("- [{}] {}\n", e_type, name));
|
||||
}
|
||||
|
||||
let prompt = format!(
|
||||
"Analyze the following list of entities and identify exactly TWO that represent the exact same concept or item but have slightly different names (e.g. 'auth_service' and 'AuthService'). Return ONLY a valid JSON array containing exactly two strings: the two names to merge. If no obvious duplicates exist, return an empty array []. Do not output any markdown formatting or extra text.\n\nEntities:\n{}",
|
||||
entity_summaries
|
||||
);
|
||||
|
||||
if let Ok(response) = state
|
||||
.ollama
|
||||
.generate(
|
||||
&prompt,
|
||||
None,
|
||||
Some("You are a helpful JSON-only data deduplication assistant. Output only JSON."),
|
||||
Some("json"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let cleaned = response
|
||||
.trim()
|
||||
.trim_start_matches("```json")
|
||||
.trim_start_matches("```")
|
||||
.trim_end_matches("```")
|
||||
.trim();
|
||||
if let Ok(duplicates) = serde_json::from_str::<Vec<String>>(cleaned)
|
||||
&& duplicates.len() == 2
|
||||
{
|
||||
let e1_name = &duplicates[0];
|
||||
let e2_name = &duplicates[1];
|
||||
|
||||
if e1_name != e2_name {
|
||||
tracing::info!(
|
||||
"Memory Consolidation Daemon: Merging '{}' into '{}'",
|
||||
e2_name,
|
||||
e1_name
|
||||
);
|
||||
});
|
||||
let name_set: std::collections::HashSet<String> = names.into_iter().collect();
|
||||
state.code.snippets.modify(|snippets| {
|
||||
snippets.retain(|s| !name_set.contains(&s.name));
|
||||
});
|
||||
tracing::info!("Condensed snippets into Historical Summary.");
|
||||
state.modify_graph(|g| {
|
||||
if let Some(mut e2) = g.entities.remove(e2_name) {
|
||||
if let Some(e1) = g.entities.get_mut(e1_name) {
|
||||
e1.observations.append(&mut e2.observations);
|
||||
} else {
|
||||
g.entities.insert(e2_name.clone(), e2);
|
||||
}
|
||||
}
|
||||
|
||||
for rel in g.relations.iter_mut() {
|
||||
if rel.from == *e2_name {
|
||||
rel.from = e1_name.clone();
|
||||
}
|
||||
if rel.to == *e2_name {
|
||||
rel.to = e1_name.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,6 +370,7 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
||||
tokio::spawn(index_committer_worker(Arc::clone(&state)));
|
||||
tokio::spawn(ttl_sweeper_worker(Arc::clone(&state)));
|
||||
tokio::spawn(condense_graph_worker(Arc::clone(&state)));
|
||||
tokio::spawn(memory_consolidation_worker(Arc::clone(&state)));
|
||||
crate::watcher::spawn_watcher(Arc::clone(&state));
|
||||
crate::handlers::vision::spawn_clipboard_listener(Arc::clone(&state));
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
@@ -371,32 +455,28 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
||||
} else if let Ok(json_payload) = serde_json::from_slice::<serde_json::Value>(&buf[..len]) {
|
||||
if json_payload.get("type").and_then(|t| t.as_str()) == Some("ping") {
|
||||
let _ = socket.send_to(b"pong", addr).await;
|
||||
} else if json_payload.get("type").and_then(|t| t.as_str()) == Some("gate_wait") {
|
||||
if let (Some(action), Some(target)) = (
|
||||
} else if json_payload.get("type").and_then(|t| t.as_str()) == Some("gate_wait")
|
||||
&& let (Some(action), Some(target)) = (
|
||||
json_payload.get("action").and_then(|a| a.as_str()),
|
||||
json_payload.get("target").and_then(|t| t.as_str())
|
||||
) {
|
||||
subscribers.insert((action.to_string(), target.to_string()), addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(event) = event_rx.recv() => {
|
||||
if event.topic == "gate:event" {
|
||||
if let (Some(action), Some(target), Some(status)) = (
|
||||
if event.topic == "gate:event"
|
||||
&& let (Some(action), Some(target), Some(status)) = (
|
||||
event.payload.get("action").and_then(|a| a.as_str()),
|
||||
event.payload.get("target").and_then(|t| t.as_str()),
|
||||
event.payload.get("status").and_then(|s| s.as_str()),
|
||||
) {
|
||||
if status == "authorized" || status == "blocked" {
|
||||
if let Some(addr) = subscribers.remove(&(action.to_string(), target.to_string())) {
|
||||
)
|
||||
&& (status == "authorized" || status == "blocked")
|
||||
&& let Some(addr) = subscribers.remove(&(action.to_string(), target.to_string())) {
|
||||
let response = if status == "authorized" { b"APPROVED" } else { b"REJECTED" };
|
||||
let _ = socket.send_to(response, addr).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -476,34 +556,42 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
||||
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
|
||||
let payload_val = serde_json::json!(&payload);
|
||||
// 1. General event topic (e.g. nvim:ui:agent_prompt_response, nvim:ui:agent_diff_accepted)
|
||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}", payload.event),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(
|
||||
crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}", payload.event),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Correlated request_id topic (e.g. nvim:ui:agent_prompt_response:REQ_ID)
|
||||
if let Some(ref req_id) = payload.request_id {
|
||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(
|
||||
crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Correlated diff_id topics
|
||||
if let Some(ref diff_id) = payload.diff_id {
|
||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(
|
||||
crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
},
|
||||
);
|
||||
// General diff decision topic
|
||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:diff_decision:{}", diff_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(
|
||||
crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:diff_decision:{}", diff_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,6 +664,7 @@ pub fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::Wo
|
||||
}
|
||||
|
||||
pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
|
||||
crate::config::load_mcp_config_env();
|
||||
let _guard = init_logging("mcp-memory-server");
|
||||
let cli = Cli::parse();
|
||||
|
||||
@@ -719,7 +808,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cli_parsing_default() {
|
||||
let cli = Cli::try_parse_from(&["mcp-memory-server"]).unwrap();
|
||||
let cli = Cli::try_parse_from(["mcp-memory-server"]).unwrap();
|
||||
assert!(cli.command.is_none());
|
||||
assert!(!cli.exit);
|
||||
assert!(!cli.restart);
|
||||
@@ -727,7 +816,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cli_parsing_exit_and_target() {
|
||||
let cli = Cli::try_parse_from(&[
|
||||
let cli = Cli::try_parse_from([
|
||||
"mcp-memory-server",
|
||||
"--exit",
|
||||
"--target",
|
||||
@@ -740,7 +829,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cli_parsing_gate_set() {
|
||||
let cli = Cli::try_parse_from(&[
|
||||
let cli = Cli::try_parse_from([
|
||||
"mcp-memory-server",
|
||||
"gate",
|
||||
"set",
|
||||
@@ -776,7 +865,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cli_parsing_gate_verify() {
|
||||
let cli = Cli::try_parse_from(&[
|
||||
let cli = Cli::try_parse_from([
|
||||
"mcp-memory-server",
|
||||
"gate",
|
||||
"verify",
|
||||
@@ -841,6 +930,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn test_run_server_graceful_shutdown() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
+16
-24
@@ -4,19 +4,15 @@ use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum ChangeKind {
|
||||
Added,
|
||||
#[default]
|
||||
Modified,
|
||||
Deleted,
|
||||
Renamed,
|
||||
}
|
||||
|
||||
impl Default for ChangeKind {
|
||||
fn default() -> Self {
|
||||
ChangeKind::Modified
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CodeChange {
|
||||
pub timestamp: u64,
|
||||
@@ -57,18 +53,18 @@ impl CodeChange {
|
||||
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(rev) = self.revision.as_deref().or(self.git_commit.as_deref())
|
||||
&& 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")
|
||||
if let Some(br) = self.branch.as_deref().or(self.git_branch.as_deref())
|
||||
&& (br.eq_ignore_ascii_case("trunk")
|
||||
|| br.starts_with("branches/")
|
||||
|| br.starts_with("tags/")
|
||||
{
|
||||
return "svn";
|
||||
}
|
||||
|| br.starts_with("tags/"))
|
||||
{
|
||||
return "svn";
|
||||
}
|
||||
"git"
|
||||
}
|
||||
@@ -266,10 +262,10 @@ impl KnowledgeGraph {
|
||||
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;
|
||||
}
|
||||
if let Some(rf) = relation_filter
|
||||
&& rel.relation_type != rf
|
||||
{
|
||||
continue;
|
||||
}
|
||||
adj.entry(rel.from.as_str()).or_default().push((
|
||||
rel.to.as_str(),
|
||||
@@ -398,7 +394,6 @@ fn default_adr_status() -> String {
|
||||
"accepted".to_string()
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ErrorFix {
|
||||
pub signature: String,
|
||||
@@ -420,7 +415,6 @@ pub struct ErrorFix {
|
||||
pub toolchain: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SessionSummary {
|
||||
pub timestamp: u64,
|
||||
@@ -527,7 +521,6 @@ pub struct EnvironmentDetail {
|
||||
pub ssh_host: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TechDebt {
|
||||
pub id: String,
|
||||
@@ -554,7 +547,6 @@ pub struct TechDebt {
|
||||
pub effort_estimate: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct GateRecord {
|
||||
pub id: String,
|
||||
|
||||
+12
-1
@@ -25,6 +25,10 @@ struct GenerateRequest<'a> {
|
||||
images: Option<Vec<&'a str>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
options: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
keep_alive: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
format: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -36,6 +40,8 @@ struct GenerateResponse {
|
||||
struct EmbeddingRequest<'a> {
|
||||
model: &'a str,
|
||||
prompt: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
keep_alive: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -109,6 +115,7 @@ impl OllamaClient {
|
||||
prompt: &str,
|
||||
model_override: Option<&str>,
|
||||
system: Option<&str>,
|
||||
format: Option<&str>,
|
||||
) -> Result<String, AppError> {
|
||||
let model = model_override.unwrap_or(&self.coder_model);
|
||||
let url = format!("{}/api/generate", self.base_url.trim_end_matches('/'));
|
||||
@@ -123,6 +130,8 @@ impl OllamaClient {
|
||||
"num_ctx": 32768, // Massive context window win
|
||||
"num_predict": 4096 // Give reasoning models plenty of output room
|
||||
})),
|
||||
keep_alive: Some("1h"),
|
||||
format,
|
||||
};
|
||||
|
||||
let res = self
|
||||
@@ -166,6 +175,8 @@ impl OllamaClient {
|
||||
"num_ctx": 8192,
|
||||
"num_predict": 1024
|
||||
})),
|
||||
keep_alive: Some("1h"),
|
||||
format: None,
|
||||
};
|
||||
|
||||
let res = self
|
||||
@@ -196,6 +207,7 @@ impl OllamaClient {
|
||||
let body = EmbeddingRequest {
|
||||
model: &self.embed_model,
|
||||
prompt: text,
|
||||
keep_alive: Some("1h"),
|
||||
};
|
||||
|
||||
let res = self
|
||||
@@ -256,4 +268,3 @@ async fn test_ollama_client_invalid_api_key() {
|
||||
client.base_url = "http://invalid-api-key:11434".to_string();
|
||||
assert!(!client.is_available().await);
|
||||
}
|
||||
|
||||
+99
-31
@@ -639,12 +639,19 @@ impl MemoryHandler {
|
||||
if let Some(resource) = self.resources.get(uri) {
|
||||
match resource.read(Arc::clone(&self.state)).await {
|
||||
Ok(text) => {
|
||||
let mut content_obj = serde_json::json!({
|
||||
"uri": uri,
|
||||
"mimeType": resource.mime_type().unwrap_or("application/json"),
|
||||
"text": &text
|
||||
});
|
||||
|
||||
if text.len() > 4000 {
|
||||
content_obj["cacheControl"] =
|
||||
serde_json::json!({"type": "ephemeral"});
|
||||
}
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"contents": [{
|
||||
"uri": uri,
|
||||
"mimeType": resource.mime_type().unwrap_or("application/json"),
|
||||
"text": text
|
||||
}]
|
||||
"contents": [content_obj]
|
||||
});
|
||||
Some(crate::mcp::success(id, payload))
|
||||
}
|
||||
@@ -756,8 +763,15 @@ impl MemoryHandler {
|
||||
|
||||
match result {
|
||||
Ok(text) => {
|
||||
let mut content_obj = serde_json::json!({"type": "text", "text": &text});
|
||||
|
||||
// Heuristic: If response is large (e.g. > 4000 chars), inject cache_control for Prompt Caching
|
||||
if text.len() > 4000 {
|
||||
content_obj["cacheControl"] = serde_json::json!({"type": "ephemeral"});
|
||||
}
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"content": [content_obj],
|
||||
"isError": false
|
||||
});
|
||||
Some(crate::mcp::success(id_clone, payload))
|
||||
@@ -788,8 +802,13 @@ impl MemoryHandler {
|
||||
}
|
||||
});
|
||||
let unblocked = crate::handlers::reconciliation::cascade_unblock_tasks(&self.state);
|
||||
let milestones = crate::handlers::reconciliation::recalculate_milestones(&self.state);
|
||||
self.state.record_activity("task", &format!("Completed task via WebSocket RPC: {}", task_id), Some("completed"));
|
||||
let milestones =
|
||||
crate::handlers::reconciliation::recalculate_milestones(&self.state);
|
||||
self.state.record_activity(
|
||||
"task",
|
||||
&format!("Completed task via WebSocket RPC: {}", task_id),
|
||||
Some("completed"),
|
||||
);
|
||||
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: "task:event".to_string(),
|
||||
session_id: None,
|
||||
@@ -800,11 +819,14 @@ impl MemoryHandler {
|
||||
"updated_milestones": milestones,
|
||||
}),
|
||||
});
|
||||
Some(crate::mcp::success(id, serde_json::json!({
|
||||
"status": "success",
|
||||
"unblocked_tasks": unblocked,
|
||||
"updated_milestones": milestones
|
||||
})))
|
||||
Some(crate::mcp::success(
|
||||
id,
|
||||
serde_json::json!({
|
||||
"status": "success",
|
||||
"unblocked_tasks": unblocked,
|
||||
"updated_milestones": milestones
|
||||
}),
|
||||
))
|
||||
}
|
||||
"tech_debts/resolve" => {
|
||||
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||
@@ -823,7 +845,11 @@ impl MemoryHandler {
|
||||
}
|
||||
});
|
||||
if found {
|
||||
self.state.record_activity("tech_debt", &format!("Resolved tech debt via WebSocket RPC: {}", debt_id), Some("resolved"));
|
||||
self.state.record_activity(
|
||||
"tech_debt",
|
||||
&format!("Resolved tech debt via WebSocket RPC: {}", debt_id),
|
||||
Some("resolved"),
|
||||
);
|
||||
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: "tech_debt:event".to_string(),
|
||||
session_id: None,
|
||||
@@ -832,7 +858,10 @@ impl MemoryHandler {
|
||||
"status": "resolved"
|
||||
}),
|
||||
});
|
||||
Some(crate::mcp::success(id, serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", debt_id) })))
|
||||
Some(crate::mcp::success(
|
||||
id,
|
||||
serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", debt_id) }),
|
||||
))
|
||||
} else {
|
||||
Some(crate::mcp::error(id, -32602, "Tech debt not found"))
|
||||
}
|
||||
@@ -853,13 +882,20 @@ impl MemoryHandler {
|
||||
}
|
||||
});
|
||||
if found {
|
||||
self.state.record_activity("gate", &format!("Authorized push gate via WebSocket: {}", gate_id), Some("authorized"));
|
||||
self.state.record_activity(
|
||||
"gate",
|
||||
&format!("Authorized push gate via WebSocket: {}", gate_id),
|
||||
Some("authorized"),
|
||||
);
|
||||
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: "gate:event".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({ "id": gate_id, "status": "authorized", "active_gate": "authorized" }),
|
||||
});
|
||||
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" })))
|
||||
Some(crate::mcp::success(
|
||||
id,
|
||||
serde_json::json!({ "status": "success" }),
|
||||
))
|
||||
} else {
|
||||
Some(crate::mcp::error(id, -32602, "Gate not found"))
|
||||
}
|
||||
@@ -880,13 +916,20 @@ impl MemoryHandler {
|
||||
}
|
||||
});
|
||||
if found {
|
||||
self.state.record_activity("gate", &format!("Blocked push gate via WebSocket: {}", gate_id), Some("blocked"));
|
||||
self.state.record_activity(
|
||||
"gate",
|
||||
&format!("Blocked push gate via WebSocket: {}", gate_id),
|
||||
Some("blocked"),
|
||||
);
|
||||
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: "gate:event".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({ "id": gate_id, "status": "blocked", "active_gate": "blocked" }),
|
||||
});
|
||||
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" })))
|
||||
Some(crate::mcp::success(
|
||||
id,
|
||||
serde_json::json!({ "status": "success" }),
|
||||
))
|
||||
} else {
|
||||
Some(crate::mcp::error(id, -32602, "Gate not found"))
|
||||
}
|
||||
@@ -902,13 +945,20 @@ impl MemoryHandler {
|
||||
}
|
||||
});
|
||||
if removed {
|
||||
self.state.record_activity("gate", &format!("Deleted push gate via WebSocket: {}", gate_id), Some("deleted"));
|
||||
self.state.record_activity(
|
||||
"gate",
|
||||
&format!("Deleted push gate via WebSocket: {}", gate_id),
|
||||
Some("deleted"),
|
||||
);
|
||||
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: "gate:event".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({ "id": gate_id, "status": "deleted" }),
|
||||
});
|
||||
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" })))
|
||||
Some(crate::mcp::success(
|
||||
id,
|
||||
serde_json::json!({ "status": "success" }),
|
||||
))
|
||||
} else {
|
||||
Some(crate::mcp::error(id, -32602, "Gate not found"))
|
||||
}
|
||||
@@ -1003,7 +1053,10 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
|
||||
("Create Entities", names)
|
||||
}
|
||||
"hypotheses" => {
|
||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("manage");
|
||||
let act = args
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("manage");
|
||||
let hyp = args
|
||||
.get("hypothesis")
|
||||
.or_else(|| args.get("query"))
|
||||
@@ -1011,24 +1064,39 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
|
||||
.unwrap_or("");
|
||||
(
|
||||
"Hypotheses",
|
||||
format!("{}: {}", act, hyp).trim_end_matches(": ").to_string(),
|
||||
format!("{}: {}", act, hyp)
|
||||
.trim_end_matches(": ")
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
"agent_signals" => {
|
||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("signal");
|
||||
let act = args
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("signal");
|
||||
let sender = args.get("sender").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let st = args.get("signal_type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let st = args
|
||||
.get("signal_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
(
|
||||
"Agent Signals",
|
||||
format!("{}: {} [{}]", act, sender, st).trim_end_matches(": ").to_string(),
|
||||
format!("{}: {} [{}]", act, sender, st)
|
||||
.trim_end_matches(": ")
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
"process_logs" => {
|
||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("logs");
|
||||
let act = args
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("logs");
|
||||
let file = args.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
|
||||
(
|
||||
"Process Logs",
|
||||
format!("{}: {}", act, file).trim_end_matches(": ").to_string(),
|
||||
format!("{}: {}", act, file)
|
||||
.trim_end_matches(": ")
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
_ => return name.to_string(),
|
||||
@@ -1053,8 +1121,8 @@ async fn execute_tool_span(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_activity_description() {
|
||||
@@ -1330,7 +1398,7 @@ mod tests {
|
||||
let handler = MemoryHandler::new(state);
|
||||
|
||||
// 1. Verify invalid LLM requests fail gracefully with isError: true or structured response
|
||||
for (tool_name, _tool) in &handler.tools {
|
||||
for tool_name in handler.tools.keys() {
|
||||
let invalid_req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 200,
|
||||
@@ -1360,7 +1428,7 @@ mod tests {
|
||||
("decisions", json!({"action": "query"})),
|
||||
("tech_debt", json!({"action": "list"})),
|
||||
("snippets", json!({"action": "search", "query": "test"})),
|
||||
("clipboard", json!({"action": "read"})),
|
||||
("clipboard", json!({"action": "history"})),
|
||||
("environment", json!({"action": "read_fingerprint"})),
|
||||
("omni_search", json!({"query": "test"})),
|
||||
("get_project_health", json!({})),
|
||||
|
||||
+74
-29
@@ -16,6 +16,26 @@ pub struct SearchResult {
|
||||
|
||||
pub type SearchResultTuple = (String, String, String, String, f32);
|
||||
|
||||
fn calculate_time_decay(timestamp_sec: Option<u64>) -> f32 {
|
||||
let Some(ts) = timestamp_sec else { return 1.0 };
|
||||
if ts == 0 {
|
||||
return 1.0;
|
||||
} // Avoid decaying default unset timestamps
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
if ts > now {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let age_days = (now - ts) as f32 / 86400.0;
|
||||
// 1% decay per day
|
||||
(-0.01 * age_days).exp()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MemoryIndex {
|
||||
pub index: Index,
|
||||
@@ -55,7 +75,8 @@ impl MemoryIndex {
|
||||
.reload_policy(ReloadPolicy::OnCommitWithDelay)
|
||||
.try_into()?;
|
||||
|
||||
let query_parser = tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
|
||||
let query_parser =
|
||||
tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
|
||||
|
||||
Ok(Self {
|
||||
index,
|
||||
@@ -87,7 +108,8 @@ impl MemoryIndex {
|
||||
.reload_policy(ReloadPolicy::OnCommitWithDelay)
|
||||
.try_into()?;
|
||||
|
||||
let query_parser = tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
|
||||
let query_parser =
|
||||
tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
|
||||
|
||||
Ok(Self {
|
||||
index,
|
||||
@@ -117,7 +139,10 @@ impl MemoryIndex {
|
||||
self.index_entities_batch(std::slice::from_ref(e))
|
||||
}
|
||||
|
||||
pub fn index_entities_batch(&self, entities: &[Entity]) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
||||
pub fn index_entities_batch(
|
||||
&self,
|
||||
entities: &[Entity],
|
||||
) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
||||
let writer = Arc::clone(&self.writer);
|
||||
let id_field = self.id_field;
|
||||
let needs_commit = Arc::clone(&self.needs_commit);
|
||||
@@ -154,13 +179,16 @@ impl MemoryIndex {
|
||||
let id_val = t.id.clone();
|
||||
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 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!(
|
||||
self.id_field => t.id.as_str(),
|
||||
@@ -231,7 +259,10 @@ impl MemoryIndex {
|
||||
if let Err(first_err) = writer.commit() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
writer.commit().map_err(|second_err| {
|
||||
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();
|
||||
@@ -246,9 +277,11 @@ impl MemoryIndex {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
pub fn commit_if_needed(&self) -> tantivy::Result<()> {
|
||||
if self.needs_commit.swap(false, std::sync::atomic::Ordering::SeqCst) {
|
||||
if self
|
||||
.needs_commit
|
||||
.swap(false, std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
let mut writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
writer.commit()?;
|
||||
let _ = self.reader.reload();
|
||||
@@ -268,8 +301,8 @@ impl MemoryIndex {
|
||||
let sanitized: String = query
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^'
|
||||
| '"' | '~' | '*' | '?' | ':' | '\\' | '/' => ' ',
|
||||
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"'
|
||||
| '~' | '*' | '?' | ':' | '\\' | '/' => ' ',
|
||||
_ => c,
|
||||
})
|
||||
.collect();
|
||||
@@ -284,10 +317,11 @@ impl MemoryIndex {
|
||||
|
||||
let target_limit = if is_wildcard_fallback { 10 } else { 50 };
|
||||
let final_query: Box<dyn tantivy::query::Query> = if let Some(ns) = namespace {
|
||||
let ns_term_query: Box<dyn tantivy::query::Query> = Box::new(tantivy::query::TermQuery::new(
|
||||
tantivy::Term::from_field_text(self.namespace_field, ns),
|
||||
tantivy::schema::IndexRecordOption::Basic,
|
||||
));
|
||||
let ns_term_query: Box<dyn tantivy::query::Query> =
|
||||
Box::new(tantivy::query::TermQuery::new(
|
||||
tantivy::Term::from_field_text(self.namespace_field, ns),
|
||||
tantivy::schema::IndexRecordOption::Basic,
|
||||
));
|
||||
let ns_filter: Box<dyn tantivy::query::Query> = if ns != "global" {
|
||||
let global_term_query = Box::new(tantivy::query::TermQuery::new(
|
||||
tantivy::Term::from_field_text(self.namespace_field, "global"),
|
||||
@@ -491,7 +525,8 @@ impl SearchService {
|
||||
self.state.code.snippets.read_with(|snips| {
|
||||
for snippet in snips.iter() {
|
||||
if let Some(ref emb) = snippet.embedding {
|
||||
let sim = cosine_similarity(&query_emb, emb);
|
||||
let base_sim = cosine_similarity(&query_emb, emb);
|
||||
let sim = base_sim * calculate_time_decay(Some(snippet.updated_at));
|
||||
results.push(SearchResult {
|
||||
id: snippet.name.clone(),
|
||||
doc_type: "snippet".to_string(),
|
||||
@@ -508,6 +543,7 @@ impl SearchService {
|
||||
snippet.name.clone(),
|
||||
"snippet".to_string(),
|
||||
snippet.description.clone(),
|
||||
Some(snippet.updated_at),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -515,15 +551,16 @@ impl SearchService {
|
||||
|
||||
self.state.read_graph(|graph| {
|
||||
for entity in graph.entities.values() {
|
||||
if let Some(ns) = filter_namespace {
|
||||
if entity.namespace != ns {
|
||||
continue;
|
||||
}
|
||||
if let Some(ns) = filter_namespace
|
||||
&& entity.namespace != ns
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let obs = entity.observations.join("; ");
|
||||
let desc = format!("{}: {}", entity.entity_type, obs);
|
||||
if let Some(ref emb) = entity.embedding {
|
||||
let sim = cosine_similarity(&query_emb, emb);
|
||||
let base_sim = cosine_similarity(&query_emb, emb);
|
||||
let sim = base_sim * calculate_time_decay(entity.updated_at);
|
||||
results.push(SearchResult {
|
||||
id: entity.name.clone(),
|
||||
doc_type: "entity".to_string(),
|
||||
@@ -533,7 +570,12 @@ impl SearchService {
|
||||
});
|
||||
} else if uncached_texts.len() < 50 {
|
||||
uncached_texts.push(format!("{} {} {}", entity.name, entity.entity_type, obs));
|
||||
uncached_meta.push((entity.name.clone(), "entity".to_string(), desc));
|
||||
uncached_meta.push((
|
||||
entity.name.clone(),
|
||||
"entity".to_string(),
|
||||
desc,
|
||||
entity.updated_at,
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -541,7 +583,8 @@ impl SearchService {
|
||||
self.state.code.error_fixes.read_with(|fixes| {
|
||||
for fix in fixes.iter() {
|
||||
if let Some(ref emb) = fix.embedding {
|
||||
let sim = cosine_similarity(&query_emb, emb);
|
||||
let base_sim = cosine_similarity(&query_emb, emb);
|
||||
let sim = base_sim * calculate_time_decay(Some(fix.timestamp));
|
||||
results.push(SearchResult {
|
||||
id: fix.signature.clone(),
|
||||
doc_type: "error_fix".to_string(),
|
||||
@@ -555,6 +598,7 @@ impl SearchService {
|
||||
fix.signature.clone(),
|
||||
"error_fix".to_string(),
|
||||
fix.solution.clone(),
|
||||
Some(fix.timestamp),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -563,8 +607,9 @@ impl SearchService {
|
||||
if !uncached_texts.is_empty()
|
||||
&& let Ok(embeddings) = generate_embeddings_async(uncached_texts).await
|
||||
{
|
||||
for (emb, (title, doc_type, body)) in embeddings.into_iter().zip(uncached_meta) {
|
||||
let sim = cosine_similarity(&query_emb, &emb);
|
||||
for (emb, (title, doc_type, body, ts)) in embeddings.into_iter().zip(uncached_meta) {
|
||||
let base_sim = cosine_similarity(&query_emb, &emb);
|
||||
let sim = base_sim * calculate_time_decay(ts);
|
||||
results.push(SearchResult {
|
||||
id: title.clone(),
|
||||
doc_type,
|
||||
|
||||
+7
-10
@@ -1,5 +1,6 @@
|
||||
use crate::models::*;
|
||||
use crate::search::MemoryIndex;
|
||||
pub use crate::search::{SearchResult as UnifiedSearchResult, SearchService};
|
||||
use crate::store::Store;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -86,10 +87,10 @@ impl ClipboardCacheState {
|
||||
}
|
||||
|
||||
pub fn push_text(&mut self, text: CachedClipboardText) {
|
||||
if let Some(ref prev) = self.last_text {
|
||||
if prev.text == text.text {
|
||||
return;
|
||||
}
|
||||
if let Some(ref prev) = self.last_text
|
||||
&& prev.text == text.text
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.last_text = Some(text.clone());
|
||||
self.history.push_front(ClipboardHistoryItem::Text(text));
|
||||
@@ -142,10 +143,8 @@ impl MemoryState {
|
||||
pub fn new(base_dir_str: &str) -> Self {
|
||||
let is_in_memory = base_dir_str == ":memory:";
|
||||
let base = std::path::PathBuf::from(base_dir_str);
|
||||
if !is_in_memory {
|
||||
if let Err(e) = std::fs::create_dir_all(&base) {
|
||||
tracing::error!("Failed to create store directory at {:?}: {}", base, e);
|
||||
}
|
||||
if !is_in_memory && let Err(e) = std::fs::create_dir_all(&base) {
|
||||
tracing::error!("Failed to create store directory at {:?}: {}", base, e);
|
||||
}
|
||||
|
||||
let db = crate::db::init_redb(&base);
|
||||
@@ -577,5 +576,3 @@ mod tests {
|
||||
assert!(activities_updated[0].timestamp >= activities_updated[1].timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
pub use crate::search::{SearchResult as UnifiedSearchResult, SearchService};
|
||||
+17
-17
@@ -33,10 +33,11 @@ static QUEUE_REGISTRY: std::sync::Mutex<Option<(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());
|
||||
if let Some((ref existing_db, ref queue)) = *reg {
|
||||
if Arc::ptr_eq(existing_db, &db) && !queue.tx.is_closed() {
|
||||
return queue.clone();
|
||||
}
|
||||
if let Some((ref existing_db, ref queue)) = *reg
|
||||
&& Arc::ptr_eq(existing_db, &db)
|
||||
&& !queue.tx.is_closed()
|
||||
{
|
||||
return queue.clone();
|
||||
}
|
||||
let new_queue = DbWriteQueue::new(db.clone());
|
||||
*reg = Some((db, new_queue.clone()));
|
||||
@@ -334,19 +335,17 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
||||
let mut known = std::collections::HashSet::new();
|
||||
|
||||
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;
|
||||
known.insert(k_str.to_string());
|
||||
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);
|
||||
}
|
||||
for (k, v) in range.flatten() {
|
||||
let k_str = k.value();
|
||||
if !k_str.starts_with(&prefix) {
|
||||
break;
|
||||
}
|
||||
found_granular = true;
|
||||
known.insert(k_str.to_string());
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -461,6 +460,7 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn prepare_batch(
|
||||
&self,
|
||||
new_snapshot: &T,
|
||||
|
||||
+14
-6
@@ -172,8 +172,6 @@ pub enum SubgraphFormat {
|
||||
pub struct GetSubgraphTool {
|
||||
/// The root entity name to start the subgraph search from.
|
||||
pub root_entity: Option<String>,
|
||||
/// Legacy alias for root_entity.
|
||||
pub root_node: Option<String>,
|
||||
/// Maximum search depth (hops). Defaults to 2.
|
||||
pub depth: Option<u32>,
|
||||
/// Output format: 'json' (raw entities and relations) or 'markdown_tree' (compact topology tree). Defaults to 'json'.
|
||||
@@ -612,6 +610,7 @@ pub enum HandoffMemoAction {
|
||||
/// - 'leave': Post a handoff memo. Required: content. Optional: namespace.
|
||||
/// - 'read': Inspect active memos. Optional: namespace.
|
||||
/// - 'clear': Clear memos. Optional: namespace, ids.
|
||||
///
|
||||
/// Next steps on error: Check parameter requirements for 'leave' or 'clear' actions.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct HandoffMemosTool {
|
||||
@@ -655,6 +654,7 @@ pub enum TaskAction {
|
||||
/// - 'list': List active tasks. Optional: git_branch, summary_level ('compact'|'detailed'|'full'), max_tokens.
|
||||
/// - 'set_criteria': Set acceptance criteria. Required: id, criteria (array of strings).
|
||||
/// - 'verify': Verify criteria met. Required: id.
|
||||
///
|
||||
/// Next steps on error: Check required parameters or call list to verify task IDs.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct TasksTool {
|
||||
@@ -719,6 +719,7 @@ pub enum MilestoneAction {
|
||||
/// - 'add': Create milestone. Required: title. Optional: namespace.
|
||||
/// - 'update': Update milestone status. Required: id, status.
|
||||
/// - 'list': List milestones. Optional: namespace.
|
||||
///
|
||||
/// Next steps on error: Provide required title for 'add' or id/status for 'update'.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct MilestonesTool {
|
||||
@@ -765,6 +766,7 @@ pub enum SnippetAction {
|
||||
/// - 'search': Search snippets. Optional: query, tags, hybrid (boolean for BM25+vector search).
|
||||
/// - 'delete': Delete snippet. Required: id (or query) as snippet name.
|
||||
/// - 'tag': Tag snippet. Required: id (or query), tags (array of strings).
|
||||
///
|
||||
/// Next steps on error: Ensure snippet name/query or id is provided.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct SnippetsTool {
|
||||
@@ -815,6 +817,7 @@ pub enum DecisionAction {
|
||||
/// - 'query': Search ADRs. Optional: query, namespace.
|
||||
/// - 'update': Update ADR status or implementation info. Required: id. Optional: status, git_commit, git_branch, task_id, context, decision, consequences, title.
|
||||
/// - 'delete': Remove ADR. Required: id.
|
||||
///
|
||||
/// Next steps on error: Provide title for 'log' or id for 'update'/'delete'.
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone)]
|
||||
pub struct DecisionsTool {
|
||||
@@ -875,6 +878,7 @@ pub enum TechDebtAction {
|
||||
/// - 'log': Record technical debt. Required: description. Optional: ideal_solution, git_commit, git_branch, symbol_references, line_range, namespace.
|
||||
/// - 'resolve': Mark tech debt resolved. Required: id.
|
||||
/// - 'list': List tech debt items. Optional: include_resolved, namespace.
|
||||
///
|
||||
/// Next steps on error: Provide description for 'log' or valid ID for 'resolve'.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct TechDebtTool {
|
||||
@@ -963,6 +967,7 @@ pub enum EnvAction {
|
||||
/// - 'log_requirement': Log environment variable requirement. Required: key. Optional: description, is_secret, namespace.
|
||||
/// - 'register': Register remote target environment. Required: name. Optional: url, description, requires_vpn, namespace.
|
||||
/// - 'get_details': Get full environment details. Optional: namespace.
|
||||
///
|
||||
/// Next steps on error: Provide key for 'log_requirement' or name for 'register'.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct EnvironmentTool {
|
||||
@@ -993,7 +998,13 @@ pub struct EnvironmentTool {
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClipboardAction {
|
||||
#[serde(alias = "image", alias = "IMAGE", alias = "Image", alias = "screenshot", alias = "SCREENSHOT")]
|
||||
#[serde(
|
||||
alias = "image",
|
||||
alias = "IMAGE",
|
||||
alias = "Image",
|
||||
alias = "screenshot",
|
||||
alias = "SCREENSHOT"
|
||||
)]
|
||||
Image,
|
||||
#[serde(alias = "text", alias = "TEXT", alias = "Text")]
|
||||
Text,
|
||||
@@ -1001,8 +1012,6 @@ pub enum ClipboardAction {
|
||||
History,
|
||||
#[serde(alias = "clear", alias = "CLEAR", alias = "Clear")]
|
||||
Clear,
|
||||
#[serde(alias = "read", alias = "READ", alias = "Read")]
|
||||
Read,
|
||||
#[serde(alias = "write", alias = "WRITE", alias = "Write")]
|
||||
Write,
|
||||
}
|
||||
@@ -1013,7 +1022,6 @@ pub enum ClipboardAction {
|
||||
/// - 'text': Get latest clipboard text (or normalized Markdown if HTML was copied).
|
||||
/// - 'history': View recent clipboard history ring buffer (images and text with timestamps).
|
||||
/// - 'clear': Clear OS clipboard and memory cache.
|
||||
/// - 'read': Read current clipboard contents (legacy alias).
|
||||
/// - 'write': Write content to OS clipboard. Optional: text, html, files, image_path.
|
||||
///
|
||||
/// Triggers: Call 'image' immediately when user says "look at image in clipboard", "see screenshot",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
test
|
||||
@@ -1 +0,0 @@
|
||||
test
|
||||
+109
-89
@@ -79,7 +79,11 @@ pub fn spawn_watcher(state: Arc<MemoryState>) {
|
||||
|
||||
fn should_review(path: &Path) -> bool {
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.contains(".git") || path_str.contains("target") || path_str.contains(".gemini") || path_str.contains("node_modules") {
|
||||
if path_str.contains(".git")
|
||||
|| path_str.contains("target")
|
||||
|| path_str.contains(".gemini")
|
||||
|| path_str.contains("node_modules")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -89,106 +93,123 @@ fn should_review(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
|
||||
info!("Triggering autonomous review & incremental AST index for {:?}", path);
|
||||
state.broadcast_activity("AUTONOMOUS", &format!("Modified: {:?}", path.file_name().unwrap_or_default()));
|
||||
info!(
|
||||
"Triggering autonomous review & incremental AST index for {:?}",
|
||||
path
|
||||
);
|
||||
state.broadcast_activity(
|
||||
"AUTONOMOUS",
|
||||
&format!("Modified: {:?}", path.file_name().unwrap_or_default()),
|
||||
);
|
||||
|
||||
// ADR-0109: Incremental Background AST Indexing & Differential Graph Updates
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if matches!(ext, "rs" | "ts" | "js" | "py" | "go" | "java" | "c" | "cpp") {
|
||||
if let Ok(content) = std::fs::read_to_string(path) {
|
||||
let language = match ext {
|
||||
"rs" => Some(tree_sitter_rust::LANGUAGE),
|
||||
"ts" | "js" => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT),
|
||||
"py" => Some(tree_sitter_python::LANGUAGE),
|
||||
"java" => Some(tree_sitter_java::LANGUAGE),
|
||||
"c" => Some(tree_sitter_c::LANGUAGE),
|
||||
"cpp" => Some(tree_sitter_cpp::LANGUAGE),
|
||||
"go" => Some(tree_sitter_go::LANGUAGE),
|
||||
_ => None,
|
||||
};
|
||||
if matches!(ext, "rs" | "ts" | "js" | "py" | "go" | "java" | "c" | "cpp")
|
||||
&& let Ok(content) = std::fs::read_to_string(path)
|
||||
{
|
||||
let language = match ext {
|
||||
"rs" => Some(tree_sitter_rust::LANGUAGE),
|
||||
"ts" | "js" => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT),
|
||||
"py" => Some(tree_sitter_python::LANGUAGE),
|
||||
"java" => Some(tree_sitter_java::LANGUAGE),
|
||||
"c" => Some(tree_sitter_c::LANGUAGE),
|
||||
"cpp" => Some(tree_sitter_cpp::LANGUAGE),
|
||||
"go" => Some(tree_sitter_go::LANGUAGE),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(lang) = language {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
if parser.set_language(&lang.into()).is_ok() {
|
||||
if let Some(tree) = parser.parse(&content, None) {
|
||||
let mut chunks = Vec::new();
|
||||
crate::indexer::extract_chunks(tree.root_node(), &content, &mut chunks, ext);
|
||||
let file_str = path.to_string_lossy().to_string();
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
if let Some(lang) = language {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
if parser.set_language(&lang.into()).is_ok()
|
||||
&& let Some(tree) = parser.parse(&content, None)
|
||||
{
|
||||
let mut chunks = Vec::new();
|
||||
crate::indexer::extract_chunks(tree.root_node(), &content, &mut chunks, ext);
|
||||
let file_str = path.to_string_lossy().to_string();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let chunks_count = chunks.len();
|
||||
let file_str_clone = file_str.clone();
|
||||
state.modify_graph(|g| {
|
||||
// Ensure File entity exists
|
||||
g.entities.entry(file_str.clone()).or_insert_with(|| {
|
||||
crate::models::Entity {
|
||||
name: file_str.clone(),
|
||||
entity_type: "File".to_string(),
|
||||
namespace: "global".to_string(),
|
||||
file_path: Some(file_str.clone()),
|
||||
created_at: Some(now),
|
||||
updated_at: Some(now),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
|
||||
for (chunk_name, chunk_code, chunk_desc) in chunks {
|
||||
let symbol_name = format!("{}::{}", file_str, chunk_name);
|
||||
let symbol_type = if chunk_desc.contains("struct") {
|
||||
"DataStructure".to_string()
|
||||
} else {
|
||||
"McpTool".to_string()
|
||||
};
|
||||
|
||||
g.entities.insert(symbol_name.clone(), crate::models::Entity {
|
||||
name: symbol_name.clone(),
|
||||
entity_type: symbol_type,
|
||||
observations: vec![format!("AST definition: {} chars", chunk_code.len())],
|
||||
namespace: "global".to_string(),
|
||||
file_path: Some(file_str.clone()),
|
||||
created_at: Some(now),
|
||||
updated_at: Some(now),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
|
||||
let rel = crate::models::Relation {
|
||||
from: file_str.clone(),
|
||||
to: symbol_name,
|
||||
relation_type: "declares".to_string(),
|
||||
namespace: "global".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
if !g.relations.contains(&rel) {
|
||||
g.relations.push(rel);
|
||||
}
|
||||
}
|
||||
let chunks_count = chunks.len();
|
||||
let file_str_clone = file_str.clone();
|
||||
state.modify_graph(|g| {
|
||||
// Ensure File entity exists
|
||||
g.entities
|
||||
.entry(file_str.clone())
|
||||
.or_insert_with(|| crate::models::Entity {
|
||||
name: file_str.clone(),
|
||||
entity_type: "File".to_string(),
|
||||
namespace: "global".to_string(),
|
||||
file_path: Some(file_str.clone()),
|
||||
created_at: Some(now),
|
||||
updated_at: Some(now),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let _ = state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: "ast:symbol_updated".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({
|
||||
"file": file_str_clone,
|
||||
"extension": ext,
|
||||
"symbols_count": chunks_count,
|
||||
}),
|
||||
});
|
||||
for (chunk_name, chunk_code, chunk_desc) in chunks {
|
||||
let symbol_name = format!("{}::{}", file_str, chunk_name);
|
||||
let symbol_type = if chunk_desc.contains("struct") {
|
||||
"DataStructure".to_string()
|
||||
} else {
|
||||
"McpTool".to_string()
|
||||
};
|
||||
|
||||
let _ = state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: "resource:updated".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({
|
||||
"uri": "memory://graph"
|
||||
}),
|
||||
});
|
||||
g.entities.insert(
|
||||
symbol_name.clone(),
|
||||
crate::models::Entity {
|
||||
name: symbol_name.clone(),
|
||||
entity_type: symbol_type,
|
||||
observations: vec![format!(
|
||||
"AST definition: {} chars",
|
||||
chunk_code.len()
|
||||
)],
|
||||
namespace: "global".to_string(),
|
||||
file_path: Some(file_str.clone()),
|
||||
created_at: Some(now),
|
||||
updated_at: Some(now),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let rel = crate::models::Relation {
|
||||
from: file_str.clone(),
|
||||
to: symbol_name,
|
||||
relation_type: "declares".to_string(),
|
||||
namespace: "global".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
if !g.relations.contains(&rel) {
|
||||
g.relations.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: "ast:symbol_updated".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({
|
||||
"file": file_str_clone,
|
||||
"extension": ext,
|
||||
"symbols_count": chunks_count,
|
||||
}),
|
||||
});
|
||||
|
||||
let _ = state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: "resource:updated".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({
|
||||
"uri": "memory://graph"
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Autonomous review & incremental AST index complete for {:?}", path);
|
||||
info!(
|
||||
"Autonomous review & incremental AST index complete for {:?}",
|
||||
path
|
||||
);
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -220,4 +241,3 @@ mod tests {
|
||||
spawn_watcher(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#[test]
|
||||
fn test_img() {
|
||||
let mut img = image::DynamicImage::new_rgb8(10, 10);
|
||||
img.invert();
|
||||
let _img2 = img.adjust_contrast(20.0);
|
||||
}
|
||||
+16
-10
@@ -93,18 +93,18 @@ fn test_dashboard_route_parity() {
|
||||
"server/src/api/setup.rs"
|
||||
};
|
||||
|
||||
let dashboard_content = std::fs::read_to_string(dashboard_path)
|
||||
.expect("Failed to read dashboard.ts");
|
||||
let setup_content = std::fs::read_to_string(setup_path)
|
||||
.expect("Failed to read setup.rs");
|
||||
let dashboard_content =
|
||||
std::fs::read_to_string(dashboard_path).expect("Failed to read dashboard.ts");
|
||||
let setup_content = std::fs::read_to_string(setup_path).expect("Failed to read setup.rs");
|
||||
|
||||
// 1. Extract routes from setup.rs
|
||||
let mut registered_routes = HashSet::new();
|
||||
for part in setup_content.split(".route(") {
|
||||
let trimmed = part.trim_start();
|
||||
if trimmed.starts_with('"') {
|
||||
if let Some(end_quote) = trimmed[1..].find('"') {
|
||||
let route = &trimmed[1..=end_quote];
|
||||
#[allow(clippy::collapsible_if)]
|
||||
if let Some(stripped) = trimmed.strip_prefix('"') {
|
||||
if let Some(end_quote) = stripped.find('"') {
|
||||
let route = &stripped[..end_quote];
|
||||
registered_routes.insert(route.to_string());
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,10 @@ fn test_dashboard_route_parity() {
|
||||
let rest = &dashboard_content[start..];
|
||||
let quote_char = dashboard_content[..start].chars().last();
|
||||
let mut endpoint = String::new();
|
||||
#[allow(clippy::collapsible_if)]
|
||||
if let Some(q) = quote_char {
|
||||
if q == '`' || q == '"' || q == '\'' {
|
||||
let is_quote = q == '`' || q == '"' || q == '\'';
|
||||
if is_quote {
|
||||
if let Some(close_idx) = rest.find(q) {
|
||||
let full_call = &rest[..close_idx];
|
||||
let clean = if let Some(q_idx) = full_call.find('?') {
|
||||
@@ -142,7 +144,8 @@ fn test_dashboard_route_parity() {
|
||||
}
|
||||
}
|
||||
if endpoint.is_empty() {
|
||||
let end_idx = rest.find(['"', '`', '\'', ' ', '\n', '?'])
|
||||
let end_idx = rest
|
||||
.find(['"', '`', '\'', ' ', '\n', '?'])
|
||||
.unwrap_or(rest.len());
|
||||
endpoint = rest[..end_idx].to_string();
|
||||
}
|
||||
@@ -153,7 +156,10 @@ fn test_dashboard_route_parity() {
|
||||
cursor = start + 5;
|
||||
}
|
||||
|
||||
assert!(!frontend_endpoints.is_empty(), "No frontend endpoints found in dashboard.ts");
|
||||
assert!(
|
||||
!frontend_endpoints.is_empty(),
|
||||
"No frontend endpoints found in dashboard.ts"
|
||||
);
|
||||
|
||||
for endpoint in &frontend_endpoints {
|
||||
assert!(
|
||||
|
||||
+24
-8
@@ -48,28 +48,45 @@ mod tests {
|
||||
#[test]
|
||||
fn test_extract_log_prefix_response() {
|
||||
let json_str = r#"{"jsonrpc":"2.0","id":123,"result":{}}"#;
|
||||
assert_eq!(extract_log_prefix(json_str, true), "Response id=123 [Error: false]");
|
||||
assert_eq!(
|
||||
extract_log_prefix(json_str, true),
|
||||
"Response id=123 [Error: false]"
|
||||
);
|
||||
|
||||
let err_json = r#"{"jsonrpc":"2.0","id":456,"error":{"code":-32600,"message":"Invalid Request"}}"#;
|
||||
assert_eq!(extract_log_prefix(err_json, true), "Response id=456 [Error: true]");
|
||||
let err_json =
|
||||
r#"{"jsonrpc":"2.0","id":456,"error":{"code":-32600,"message":"Invalid Request"}}"#;
|
||||
assert_eq!(
|
||||
extract_log_prefix(err_json, true),
|
||||
"Response id=456 [Error: true]"
|
||||
);
|
||||
|
||||
let is_err_json = r#"{"jsonrpc":"2.0","id":789,"result":{"isError":true}}"#;
|
||||
assert_eq!(extract_log_prefix(is_err_json, true), "Response id=789 [Error: true]");
|
||||
assert_eq!(
|
||||
extract_log_prefix(is_err_json, true),
|
||||
"Response id=789 [Error: true]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_log_prefix_tool_call() {
|
||||
let json_str = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search"}}"#;
|
||||
let json_str =
|
||||
r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search"}}"#;
|
||||
assert_eq!(extract_log_prefix(json_str, false), "ToolCall[search] id=1");
|
||||
|
||||
let unknown_tool = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{}}"#;
|
||||
assert_eq!(extract_log_prefix(unknown_tool, false), "ToolCall[unknown] id=2");
|
||||
assert_eq!(
|
||||
extract_log_prefix(unknown_tool, false),
|
||||
"ToolCall[unknown] id=2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_log_prefix_request() {
|
||||
let json_str = r#"{"jsonrpc":"2.0","id":10,"method":"initialize"}"#;
|
||||
assert_eq!(extract_log_prefix(json_str, false), "Request[initialize] id=10");
|
||||
assert_eq!(
|
||||
extract_log_prefix(json_str, false),
|
||||
"Request[initialize] id=10"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -84,4 +101,3 @@ mod tests {
|
||||
assert_eq!(extract_log_prefix(json_str, false), "Message");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user